diff --git a/.github/workflows/automated_release_process.yml b/.github/workflows/automated_release_process.yml new file mode 100644 index 000000000..72b765c01 --- /dev/null +++ b/.github/workflows/automated_release_process.yml @@ -0,0 +1,120 @@ +name: Automated Release Process + +permissions: + contents: write + pull-requests: write + issues: write + +on: + workflow_dispatch: + inputs: + version: + description: "Release version (x.y.z or x.y.z-rc.1)" + required: true + type: string + +env: + REPO_ORG: stellar + REPO_NAME: stellar-disbursement-platform-backend + REVIEWER: marcelosalloum,marwen-abid + +jobs: + create-release: + runs-on: ubuntu-latest + steps: + - name: Validate version format + run: | + if ! [[ ${{ inputs.version }} =~ ^[0-9]+\.[0-9]+\.[0-9]+(-(rc|alpha|beta)\.[0-9]+)?$ ]]; then + echo "Error: Version must be in format x.y.z or x.y.z-rc.n" + echo "Examples:" + echo " 1.2.3" + echo " 1.2.3-rc.1" + exit 1 + fi + + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup GitHub CLI + run: echo "${{ secrets.GITHUB_TOKEN }}" | gh auth login --with-token + + - name: Configure Git User + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + + - name: Create release/${{ inputs.version }} branch + run: | + git checkout -b release/${{ inputs.version }} origin/${{ github.ref_name }} + sed -i 's/const Version = ".*"/const Version = "${{ inputs.version }}"/' main.go + git add main.go + git commit -m "chore: bump version to ${{ inputs.version }}" + git push origin release/${{ inputs.version }} + + - name: Create main PR + id: create_main_pr + run: | + MAIN_PR_URL=$(sed "s/{{version}}/${{ inputs.version }}/g" .github/workflows/templates/release-pr-main.md | \ + gh pr create --repo ${{ env.REPO_ORG }}/${{ env.REPO_NAME }} \ + --base main \ + --head release/${{ inputs.version }} \ + --title "Release \`${{ inputs.version }}\` to \`main\`" \ + --body-file - \ + --assignee "${{ github.actor }}" \ + --reviewer "${{ env.REVIEWER }}") + echo "main_pr_url=${MAIN_PR_URL}" >> $GITHUB_OUTPUT + + - name: Create release/${{ inputs.version }}-dev branch + run: | + git checkout -b release/${{ inputs.version }}-dev release/${{ inputs.version }} + git push origin release/${{ inputs.version }}-dev + + - name: Create develop PR + id: create_dev_pr + run: | + DEV_PR_URL=$(sed -e "s/{{version}}/${{ inputs.version }}/g" \ + -e "s|{{ main_pr_url }}|${{ steps.create_main_pr.outputs.main_pr_url }}|g" \ + .github/workflows/templates/release-pr-dev.md | \ + gh pr create --repo ${{ env.REPO_ORG }}/${{ env.REPO_NAME }} \ + --base develop \ + --head release/${{ inputs.version }}-dev \ + --title "Release \`${{ inputs.version }}\` to \`dev\`" \ + --body-file - \ + --assignee "${{ github.actor }}" \ + --reviewer "${{ env.REVIEWER }}") + echo "dev_pr_url=${DEV_PR_URL}" >> $GITHUB_OUTPUT + + - name: Create Draft Release + id: create_release + run: | + RELEASE_URL=$(gh release create ${{ inputs.version }} \ + --title "${{ inputs.version }}" \ + --draft \ + --notes "Initial draft for release \`${{ inputs.version }}\`") + echo "release_url=${RELEASE_URL}" >> $GITHUB_OUTPUT + + - name: Create Issue + id: create_issue + run: | + ISSUE_URL=$(sed -e "s/{{version}}/${{ inputs.version }}/g" \ + -e "s|{{ main_pr_url }}|${{ steps.create_main_pr.outputs.main_pr_url }}|g" \ + -e "s|{{ dev_pr_url }}|${{ steps.create_dev_pr.outputs.dev_pr_url }}|g" \ + -e "s|{{ release_url }}|${{ steps.create_release.outputs.release_url }}|g" \ + .github/workflows/templates/release-issue.md | \ + gh issue create \ + --title "Release \`${{ inputs.version }}\`" \ + --body-file - \ + --label "release" \ + --assignee "${{ github.actor }}") + echo "issue_url=${ISSUE_URL}" >> $GITHUB_OUTPUT + + - name: Print Summary + run: | + echo "Release Process Summary for ${{ inputs.version }}" + echo "----------------------------------------" + echo "Issue: ${{ steps.create_issue.outputs.issue_url }}" + echo "Main PR: ${{ steps.create_main_pr.outputs.main_pr_url }}" + echo "Dev PR: ${{ steps.create_dev_pr.outputs.dev_pr_url }}" + echo "Draft Release: ${{ steps.create_release.outputs.release_url }}" diff --git a/.github/workflows/templates/release-issue.md b/.github/workflows/templates/release-issue.md new file mode 100644 index 000000000..66ed7e47f --- /dev/null +++ b/.github/workflows/templates/release-issue.md @@ -0,0 +1,23 @@ +Release `{{version}}` + +## Release Checklist + +### Git Preparation + +- [x] Create release branch `release/{{version}}` from `develop` +- [x] Create pull requests: + - Main PR: {{ main_pr_url }} + - Dev PR: {{ dev_pr_url }} + +### Code Preparation + +- [ ] Run tests and linting +- [ ] Complete the checklist and merge the main PR: {{ main_pr_url }} +- [ ] Complete the checklist and merge the dev PR: {{ dev_pr_url }} +- [ ] 🚨 DO NOT RELEASE before holidays or weekends! Mondays and Tuesdays are preferred. + +### Publishing the Release + +- [ ] After the main PR is merged, publish the draft release: {{ release_url }} -> [Release Page](https://github.com/stellar/stellar-disbursement-platform-backend/releases/tag/{{version}}) + - [ ] Verify the Docker image is published to [Docker Hub](https://hub.docker.com/r/stellar/stellar-disbursement-platform-backend/tags) +- [ ] Propagate the helmchart version update to the [stellar/helm-charts](https://github.com/stellar/helm-charts) repository diff --git a/.github/workflows/templates/release-pr-dev.md b/.github/workflows/templates/release-pr-dev.md new file mode 100644 index 000000000..4ab43673c --- /dev/null +++ b/.github/workflows/templates/release-pr-dev.md @@ -0,0 +1,7 @@ +Release `{{version}}` to `dev` + +### Pending + +- [ ] Merge the main PR {{ main_pr_url }} +- [ ] Rebase this branch onto `main` +- [ ] 🚨 Merge this PR using the **`Merge pull request`** button diff --git a/.github/workflows/templates/release-pr-main.md b/.github/workflows/templates/release-pr-main.md new file mode 100644 index 000000000..2d5b27681 --- /dev/null +++ b/.github/workflows/templates/release-pr-main.md @@ -0,0 +1,11 @@ +Release `{{version}}` to `main` + +### Pending + +- [x] Bump version in main.go +- [ ] Update CHANGELOG.md +- [ ] Bump version in helmchart/sdp/Chart.yaml +- [ ] Bump backend version in helmchart/sdp/values.yaml +- [ ] Bump frontend version in helmchart/sdp/values.yaml +- [ ] Regenerate the helm charts README.md with `readme-generator -v values.yaml -r README.md` +- [ ] 🚨 Merge this PR using the **`Merge pull request`** button diff --git a/CHANGELOG.md b/CHANGELOG.md index b90d7ee95..25347548d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/). +## [3.5.0](https://github.com/stellar/stellar-disbursement-platform-backend/releases/tag/3.5.0) ([diff](https://github.com/stellar/stellar-disbursement-platform-backend/compare/3.4.0...3.5.0)) + +> [!WARNING] +> This version is compatible with the [stellar/stellar-disbursement-platform-frontend] version `3.5.0`. + +### Added + +- Added short linking for Wallet Registration Links. + [#523](https://github.com/stellar/stellar-disbursement-platform-frontend/pull/523) +- Added a new `is_link_shortener_enabled` property to `GET` and `PATCH` organizations endpoints to enable/disable the short link feature. + [#523](https://github.com/stellar/stellar-disbursement-platform-frontend/pull/523) +- Added receiver contact info for Payments export. + [#538](https://github.com/stellar/stellar-disbursement-platform-frontend/pull/538) + + ## [3.4.0](https://github.com/stellar/stellar-disbursement-platform-backend/releases/tag/3.4.0) ([diff](https://github.com/stellar/stellar-disbursement-platform-backend/compare/3.3.0...3.4.0)) Release of the Stellar Disbursement Platform `v3.4.0`. This release adds support for `q={term}` query searches in the diff --git a/db/migrations/sdp-migrations/2025-01-30.0-add-short-urls-table.sql b/db/migrations/sdp-migrations/2025-01-30.0-add-short-urls-table.sql new file mode 100644 index 000000000..c0bd3fa53 --- /dev/null +++ b/db/migrations/sdp-migrations/2025-01-30.0-add-short-urls-table.sql @@ -0,0 +1,20 @@ +-- Add auditing to receiver_verifications + +-- +migrate Up +CREATE TABLE short_urls ( + id VARCHAR(10) PRIMARY KEY, + original_url TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE UNIQUE INDEX short_urls_original_url_idx ON short_urls (original_url); + +ALTER TABLE organizations + ADD COLUMN is_link_shortener_enabled boolean NOT NULL DEFAULT false; + + +-- +migrate Down +DROP TABLE short_urls; + +ALTER TABLE organizations + DROP COLUMN is_link_shortener_enabled; \ No newline at end of file diff --git a/helmchart/sdp/Chart.yaml b/helmchart/sdp/Chart.yaml index cde4a18b0..62df69785 100644 --- a/helmchart/sdp/Chart.yaml +++ b/helmchart/sdp/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v2 name: stellar-disbursement-platform description: A Helm chart for the Stellar Disbursement Platform Backend (A.K.A. `sdp`) -version: "3.3.0" -appVersion: "3.4.0" +version: "3.5.0" +appVersion: "3.5.0" type: application maintainers: - name: Stellar Development Foundation diff --git a/helmchart/sdp/README.md b/helmchart/sdp/README.md index 6e273a5ab..dec24b5f1 100644 --- a/helmchart/sdp/README.md +++ b/helmchart/sdp/README.md @@ -106,7 +106,7 @@ Configuration parameters for the SDP Core Service which is the core backend serv | `sdp.image` | Configuration related to the Docker image used by the SDP service. | | | `sdp.image.repository` | Docker image repository for the SDP backend service. | `stellar/stellar-disbursement-platform-backend` | | `sdp.image.pullPolicy` | Image pull policy for the SDP service. For locally built images, consider using "Never" or "IfNotPresent". | `Always` | -| `sdp.image.tag` | Docker image tag for the SDP service. If set, this overrides the default value from `.Chart.AppVersion`. | `3.4.0` | +| `sdp.image.tag` | Docker image tag for the SDP service. If set, this overrides the default value from `.Chart.AppVersion`. | `3.5.0` | | `sdp.deployment` | Configuration related to the deployment of the SDP service. | | | `sdp.deployment.annotations` | Annotations to be added to the deployment. | `nil` | | `sdp.deployment.podAnnotations` | Annotations specific to the pods. | `{}` | @@ -291,7 +291,7 @@ Configuration parameters for the Dashboard. This is the user interface administr | `dashboard.route.mtnDomain` | Public domain/address of the multi-tenant Dashboard. This is a wild-card domain used for multi-tenant setups e.g. "*.sdp-dashboard.localhost.com". | `nil` | | `dashboard.route.port` | Primary port on which the Dashboard listens. | `80` | | `dashboard.image` | Configuration related to the Docker image used by the Dashboard. | | -| `dashboard.image.fullName` | Full name of the Docker image. | `stellar/stellar-disbursement-platform-frontend:3.4.0` | +| `dashboard.image.fullName` | Full name of the Docker image. | `stellar/stellar-disbursement-platform-frontend:3.5.0` | | `dashboard.image.pullPolicy` | Image pull policy for the dashboard. For locally built images, consider using "Never" or "IfNotPresent". | `Always` | | `dashboard.deployment` | Configuration related to the deployment of the Dashboard. | | | `dashboard.deployment.annotations` | Annotations to be added to the deployment. | `{}` | diff --git a/helmchart/sdp/values.yaml b/helmchart/sdp/values.yaml index adcfe5c1f..af72a6605 100644 --- a/helmchart/sdp/values.yaml +++ b/helmchart/sdp/values.yaml @@ -111,7 +111,7 @@ sdp: image: repository: stellar/stellar-disbursement-platform-backend pullPolicy: Always - tag: "3.4.0" + tag: "3.5.0" ## @extra sdp.deployment Configuration related to the deployment of the SDP service. ## @param sdp.deployment.annotations Annotations to be added to the deployment. @@ -536,7 +536,7 @@ dashboard: ## @param dashboard.image.fullName Full name of the Docker image. ## @param dashboard.image.pullPolicy Image pull policy for the dashboard. For locally built images, consider using "Never" or "IfNotPresent". image: - fullName: stellar/stellar-disbursement-platform-frontend:3.4.0 + fullName: stellar/stellar-disbursement-platform-frontend:3.5.0 pullPolicy: Always ## @extra dashboard.deployment Configuration related to the deployment of the Dashboard. diff --git a/internal/data/fixtures.go b/internal/data/fixtures.go index 2b88f1067..227f0b9fd 100644 --- a/internal/data/fixtures.go +++ b/internal/data/fixtures.go @@ -817,6 +817,15 @@ func CreateMockImage(t *testing.T, width, height int, size ImageSize) image.Imag return img } +func CreateShortURLFixture(t *testing.T, ctx context.Context, sqlExec db.SQLExecuter, shortCode, url string) { + const query = ` + INSERT INTO short_urls (id, original_url) + VALUES ($1, $2) + ` + _, err := sqlExec.ExecContext(ctx, query, shortCode, url) + require.NoError(t, err) +} + func DeleteAllFixtures(t *testing.T, ctx context.Context, sqlExec db.SQLExecuter) { DeleteAllMessagesFixtures(t, ctx, sqlExec) DeleteAllPaymentsFixtures(t, ctx, sqlExec) diff --git a/internal/data/mocks/code_generator.go b/internal/data/mocks/code_generator.go new file mode 100644 index 000000000..5320565ed --- /dev/null +++ b/internal/data/mocks/code_generator.go @@ -0,0 +1,42 @@ +// Code generated by mockery v2.40.1. DO NOT EDIT. + +package mocks + +import mock "github.com/stretchr/testify/mock" + +// CodeGeneratorMock is an autogenerated mock type for the CodeGenerator type +type CodeGeneratorMock struct { + mock.Mock +} + +// Generate provides a mock function with given fields: length +func (_m *CodeGeneratorMock) Generate(length int) string { + ret := _m.Called(length) + + if len(ret) == 0 { + panic("no return value specified for Generate") + } + + var r0 string + if rf, ok := ret.Get(0).(func(int) string); ok { + r0 = rf(length) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// NewCodeGeneratorMock creates a new instance of CodeGeneratorMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewCodeGeneratorMock(t interface { + mock.TestingT + Cleanup(func()) +}) *CodeGeneratorMock { + mock := &CodeGeneratorMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/data/models.go b/internal/data/models.go index 4d9767171..bd22426f0 100644 --- a/internal/data/models.go +++ b/internal/data/models.go @@ -27,6 +27,7 @@ type Models struct { Message *MessageModel CircleTransferRequests *CircleTransferRequestModel CircleRecipient *CircleRecipientModel + URLShortener *URLShortenerModel DBConnectionPool db.DBConnectionPool } @@ -48,6 +49,7 @@ func NewModels(dbConnectionPool db.DBConnectionPool) (*Models, error) { Message: &MessageModel{dbConnectionPool: dbConnectionPool}, CircleTransferRequests: &CircleTransferRequestModel{dbConnectionPool: dbConnectionPool}, CircleRecipient: &CircleRecipientModel{dbConnectionPool: dbConnectionPool}, + URLShortener: NewURLShortenerModel(dbConnectionPool), DBConnectionPool: dbConnectionPool, }, nil } diff --git a/internal/data/organizations.go b/internal/data/organizations.go index 43b954284..491ed2e12 100644 --- a/internal/data/organizations.go +++ b/internal/data/organizations.go @@ -48,6 +48,7 @@ type Organization struct { PrivacyPolicyLink *string `json:"privacy_policy_link" db:"privacy_policy_link"` Logo []byte `db:"logo"` IsApprovalRequired bool `json:"is_approval_required" db:"is_approval_required"` + IsLinkShortenerEnabled bool `json:"is_link_shortener_enabled" db:"is_link_shortener_enabled"` MessageChannelPriority MessageChannelPriority `json:"message_channel_priority" db:"message_channel_priority"` CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` @@ -58,6 +59,7 @@ type OrganizationUpdate struct { Logo []byte `json:",omitempty"` TimezoneUTCOffset string `json:",omitempty"` IsApprovalRequired *bool `json:",omitempty"` + IsLinkShortenerEnabled *bool `json:",omitempty"` ReceiverInvitationResendIntervalDays *int64 `json:",omitempty"` PaymentCancellationPeriodDays *int64 `json:",omitempty"` @@ -126,6 +128,7 @@ func (ou *OrganizationUpdate) areAllFieldsEmpty() bool { len(ou.Logo) == 0 && ou.TimezoneUTCOffset == "" && ou.IsApprovalRequired == nil && + ou.IsLinkShortenerEnabled == nil && ou.ReceiverRegistrationMessageTemplate == nil && ou.OTPMessageTemplate == nil && ou.ReceiverInvitationResendIntervalDays == nil && @@ -197,6 +200,11 @@ func (om *OrganizationModel) Update(ctx context.Context, ou *OrganizationUpdate) args = append(args, *ou.IsApprovalRequired) } + if ou.IsLinkShortenerEnabled != nil { + fields = append(fields, "is_link_shortener_enabled = ?") + args = append(args, *ou.IsLinkShortenerEnabled) + } + if ou.ReceiverRegistrationMessageTemplate != nil { if *ou.ReceiverRegistrationMessageTemplate != "" { fields = append(fields, "receiver_registration_message_template = ?") diff --git a/internal/data/query_params.go b/internal/data/query_params.go index 4b5298fa9..2f53869a5 100644 --- a/internal/data/query_params.go +++ b/internal/data/query_params.go @@ -40,6 +40,7 @@ const ( type FilterKey string const ( + FilterKeyIDs FilterKey = "ids" FilterKeyStatus FilterKey = "status" FilterKeyReceiverID FilterKey = "receiver_id" FilterKeyPaymentID FilterKey = "payment_id" diff --git a/internal/data/receivers.go b/internal/data/receivers.go index bc3e64fdd..a7f0241d9 100644 --- a/internal/data/receivers.go +++ b/internal/data/receivers.go @@ -326,6 +326,10 @@ func newReceiverQuery(baseQuery string, queryParams *QueryParams, sqlExec db.SQL q := "%" + queryParams.Query + "%" qb.AddCondition("(r.id ILIKE ? OR r.phone_number ILIKE ? OR r.email ILIKE ?)", q, q, q) } + if queryParams.Filters[FilterKeyIDs] != nil { + ids := queryParams.Filters[FilterKeyIDs].([]string) + qb.AddCondition("r.id = ANY(?)", pq.Array(ids)) + } if queryParams.Filters[FilterKeyStatus] != nil { status := queryParams.Filters[FilterKeyStatus].(ReceiversWalletStatus) qb.AddCondition("rw.status = ?", status) diff --git a/internal/data/receivers_test.go b/internal/data/receivers_test.go index 5cff2a1d6..16419060e 100644 --- a/internal/data/receivers_test.go +++ b/internal/data/receivers_test.go @@ -665,6 +665,18 @@ func Test_ReceiversModel_GetAll(t *testing.T) { require.NoError(t, err) }) + t.Run("returns receivers successfully with IDs filter", func(t *testing.T) { + actualReceivers, err := receiverModel.GetAll(ctx, dbConnectionPool, &QueryParams{ + Filters: map[FilterKey]interface{}{ + FilterKeyIDs: []string{receiver1.ID, receiver2.ID}, + }, + }, QueryTypeSelectAll) + require.NoError(t, err) + assert.Equal(t, 2, len(actualReceivers)) + assert.Equal(t, receiver1.ID, actualReceivers[0].ID) + assert.Equal(t, receiver2.ID, actualReceivers[1].ID) + }) + t.Run("returns receivers successfully with query filter email", func(t *testing.T) { dbTx, err := dbConnectionPool.BeginTxx(ctx, nil) require.NoError(t, err) diff --git a/internal/data/url_shortener.go b/internal/data/url_shortener.go new file mode 100644 index 000000000..7832ce61e --- /dev/null +++ b/internal/data/url_shortener.go @@ -0,0 +1,110 @@ +package data + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/lib/pq" + + "github.com/stellar/stellar-disbursement-platform-backend/db" +) + +const ( + maxCodeGenerationAttempts = 5 + shortCodeLength = 6 +) + +type ShortURL struct { + ID string `json:"id"` + OriginalURL string `json:"original_url"` + CreatedAt time.Time `json:"created_at"` +} + +type URLShortenerModel struct { + dbConnectionPool db.DBConnectionPool + codeGenerator CodeGenerator +} + +func NewURLShortenerModel(db db.DBConnectionPool) *URLShortenerModel { + return &URLShortenerModel{ + dbConnectionPool: db, + codeGenerator: &RandomCodeGenerator{}, + } +} + +func (u *URLShortenerModel) GetOriginalURL(ctx context.Context, shortCode string) (string, error) { + var originalURL string + query := `SELECT original_url FROM short_urls WHERE id = $1` + err := u.dbConnectionPool.GetContext(ctx, &originalURL, query, shortCode) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", ErrRecordNotFound + } + return "", fmt.Errorf("getting URL for code %s: %w", shortCode, err) + } + return originalURL, nil +} + +func (u *URLShortenerModel) GetOrCreateShortCode(ctx context.Context, originalURL string) (string, error) { + // Attempt to generate a unique short code. + for attempts := 0; attempts < maxCodeGenerationAttempts; attempts++ { + result, err := db.RunInTransactionWithResult(ctx, u.dbConnectionPool, nil, func(dbTx db.DBTransaction) (string, error) { + // Check if there is already a short code for this original URL. + var code string + query := `SELECT id FROM short_urls WHERE original_url = $1` + err := dbTx.GetContext(ctx, &code, query, originalURL) + if err == nil { + return code, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return "", fmt.Errorf("checking for existing URL: %w", err) + } + + // Generate a new short code. + code = u.codeGenerator.Generate(shortCodeLength) + + // Insert the new short code. + query = ` + INSERT INTO short_urls (id, original_url) + VALUES ($1, $2) + ` + if _, err = dbTx.ExecContext(ctx, query, code, originalURL); err != nil { + return "", fmt.Errorf("inserting new URL: %w", err) + } + return code, nil + }) + + switch { + case err == nil: + return result, nil + case isDuplicateError(err): // Retry if the short code already exists. + continue + default: + return "", fmt.Errorf("getting or creating short code: %w", err) + } + } + return "", fmt.Errorf("generating unique code after %d attempts", maxCodeGenerationAttempts) +} + +// isDuplicateError checks if the error is a PostgreSQL unique violation +func isDuplicateError(err error) bool { + var pqErr *pq.Error + return err != nil && errors.As(err, &pqErr) && pqErr.Code == "23505" +} + +//go:generate mockery --name CodeGenerator --case=underscore --structname=CodeGeneratorMock --filename=code_generator.go +type CodeGenerator interface { + Generate(length int) string +} + +type RandomCodeGenerator struct{} + +func (g *RandomCodeGenerator) Generate(length int) string { + genUUID := uuid.New().String() + return strings.ReplaceAll(genUUID[:length], "-", "") +} diff --git a/internal/data/url_shortener_test.go b/internal/data/url_shortener_test.go new file mode 100644 index 000000000..2e66c548c --- /dev/null +++ b/internal/data/url_shortener_test.go @@ -0,0 +1,171 @@ +package data + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data/mocks" +) + +func Test_URLShortenerModel_GetOriginalURL(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, outerErr) + defer dbConnectionPool.Close() + + ctx := context.Background() + m := URLShortenerModel{dbConnectionPool: dbConnectionPool} + + existingCode := "exist123" + originalURL := "https://stellar.org/original" + CreateShortURLFixture(t, ctx, dbConnectionPool, existingCode, originalURL) + + testCases := []struct { + name string + shortCode string + expectedURL string + expectedErrContains string + setup func(*testing.T) + }{ + { + name: "🎉successfully retrieves existing URL", + shortCode: existingCode, + expectedURL: originalURL, + expectedErrContains: "", + }, + { + name: "returns ErrRecordNotFound for non-existent code", + shortCode: "does-not-exist", + expectedErrContains: ErrRecordNotFound.Error(), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.setup != nil { + tc.setup(t) + } + + url, err := m.GetOriginalURL(ctx, tc.shortCode) + if tc.expectedErrContains != "" { + assert.ErrorContains(t, err, tc.expectedErrContains) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expectedURL, url) + } + }) + } +} + +func Test_URLShortenerModel_GetOrCreateShortCode(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, outerErr) + defer dbConnectionPool.Close() + + ctx := context.Background() + + testCases := []struct { + name string + setup func(*testing.T, *mocks.CodeGeneratorMock, string) + expectedErr string + validateResult func(*testing.T, string, string) + }{ + { + name: "🎉 creates new code for new URL", + setup: func(t *testing.T, m *mocks.CodeGeneratorMock, originalURL string) { + m.On("Generate", shortCodeLength). + Return("abc123"). + Once() + }, + validateResult: func(t *testing.T, originalURL, code string) { + var actualURL string + err := dbConnectionPool.GetContext( + ctx, + &actualURL, + "SELECT original_url FROM short_urls WHERE id = $1", + "abc123", + ) + require.NoError(t, err) + require.Equal(t, originalURL, actualURL) + }, + }, + { + name: "🎉 returns existing code for duplicate URL", + setup: func(t *testing.T, m *mocks.CodeGeneratorMock, originalURL string) { + CreateShortURLFixture(t, ctx, dbConnectionPool, "existing", originalURL) + }, + validateResult: func(t *testing.T, originalURL, code string) { + assert.Equal(t, "existing", code) + }, + }, + { + name: "handle collisions for new URL", + setup: func(t *testing.T, m *mocks.CodeGeneratorMock, originalURL string) { + m.On("Generate", shortCodeLength). + Return("collide"). + Return("collide"). + Return("unique"). + Once() + }, + validateResult: func(t *testing.T, originalURL, code string) { + assert.Equal(t, "unique", code) + + var actualURL string + err := dbConnectionPool.GetContext( + ctx, + &actualURL, + "SELECT original_url FROM short_urls WHERE id = $1", + "unique", + ) + require.NoError(t, err) + assert.Equal(t, originalURL, actualURL) + }, + }, + { + name: "max attempts exceeded", + setup: func(t *testing.T, m *mocks.CodeGeneratorMock, originalURL string) { + m.On("Generate", shortCodeLength). + Return("exceed"). + Times(maxCodeGenerationAttempts) + + CreateShortURLFixture(t, ctx, dbConnectionPool, "exceed", "https://stellar.org/other") + }, + expectedErr: "generating unique code after 5 attempts", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + generatorMock := mocks.NewCodeGeneratorMock(t) + + model := &URLShortenerModel{ + dbConnectionPool: dbConnectionPool, + codeGenerator: generatorMock, + } + + originalURL := "https://stellar.org/" + t.Name() + if tc.setup != nil { + tc.setup(t, generatorMock, originalURL) + } + + code, err := model.GetOrCreateShortCode(ctx, originalURL) + if tc.expectedErr != "" { + assert.ErrorContains(t, err, tc.expectedErr) + return + } + + assert.NoError(t, err) + if tc.validateResult != nil { + tc.validateResult(t, originalURL, code) + } + }) + } +} diff --git a/internal/serve/httphandler/disbursement_handler_test.go b/internal/serve/httphandler/disbursement_handler_test.go index c7eab8e64..46a161d9e 100644 --- a/internal/serve/httphandler/disbursement_handler_test.go +++ b/internal/serve/httphandler/disbursement_handler_test.go @@ -1492,7 +1492,7 @@ func Test_DisbursementHandler_PatchDisbursementStatus(t *testing.T) { require.NoError(t, err) token := "token" - ctx := tenant.LoadDefaultTenantInContext(t, dbConnectionPool) + _, ctx := tenant.LoadDefaultTenantInContext(t, dbConnectionPool) ctx = context.WithValue(ctx, middleware.TokenContextKey, token) userID := "valid-user-id" user := &auth.User{ diff --git a/internal/serve/httphandler/export_handler.go b/internal/serve/httphandler/export_handler.go index cebfbf862..9cc07312d 100644 --- a/internal/serve/httphandler/export_handler.go +++ b/internal/serve/httphandler/export_handler.go @@ -1,6 +1,7 @@ package httphandler import ( + "context" "fmt" "net/http" "time" @@ -58,6 +59,8 @@ type PaymentCSV struct { Asset data.Asset Wallet data.Wallet ReceiverID string `csv:"Receiver.ID"` + ReceiverPhoneNumber string `csv:"Receiver.PhoneNumber"` + ReceiverEmail string `csv:"Receiver.Email"` ReceiverWalletAddress string `csv:"ReceiverWallet.Address"` ReceiverWalletStatus data.ReceiversWalletStatus `csv:"ReceiverWallet.Status"` CreatedAt time.Time @@ -89,7 +92,54 @@ func (e ExportHandler) ExportPayments(rw http.ResponseWriter, r *http.Request) { return } - // Convert payments to PaymentCSV + receiversMap, err := e.getPaymentReceiversMap(ctx, payments) + if err != nil { + httperror.InternalError(ctx, "Failed to get receivers", err, nil).Render(rw) + return + } + + paymentCSVs := e.convertPaymentsToCSV(payments, receiversMap) + + fileName := fmt.Sprintf("payments_%s.csv", time.Now().Format("2006-01-02-15-04-05")) + rw.Header().Set("Content-Type", "text/csv") + rw.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) + + if err := gocsv.Marshal(paymentCSVs, rw); err != nil { + httperror.InternalError(ctx, "Failed to write CSV", err, nil).Render(rw) + return + } +} + +// getPaymentReceiversMap returns a map of receivers by receiverID for the given payments. +func (e ExportHandler) getPaymentReceiversMap(ctx context.Context, payments []data.Payment) (map[string]data.Receiver, error) { + receiverIDs := make([]string, 0, len(payments)) + + if len(payments) == 0 { + return map[string]data.Receiver{}, nil + } + + for _, payment := range payments { + receiverIDs = append(receiverIDs, payment.ReceiverWallet.Receiver.ID) + } + + receivers, err := e.Models.Receiver.GetAll(ctx, e.Models.DBConnectionPool, &data.QueryParams{ + Filters: map[data.FilterKey]interface{}{ + data.FilterKeyIDs: receiverIDs, + }, + }, data.QueryTypeSelectAll) + if err != nil { + return nil, fmt.Errorf("failed to get receivers: %w", err) + } + + receiversMap := make(map[string]data.Receiver, len(receivers)) + for _, receiver := range receivers { + receiversMap[receiver.ID] = receiver + } + return receiversMap, nil +} + +// convertPaymentsToCSV converts the given payments and receivers to a slice of PaymentCSV. +func (e ExportHandler) convertPaymentsToCSV(payments []data.Payment, receiversMap map[string]data.Receiver) []*PaymentCSV { paymentCSVs := make([]*PaymentCSV, 0, len(payments)) for _, payment := range payments { paymentCSV := &PaymentCSV{ @@ -101,6 +151,8 @@ func (e ExportHandler) ExportPayments(rw http.ResponseWriter, r *http.Request) { Asset: payment.Asset, Wallet: payment.ReceiverWallet.Wallet, ReceiverID: payment.ReceiverWallet.Receiver.ID, + ReceiverPhoneNumber: receiversMap[payment.ReceiverWallet.Receiver.ID].PhoneNumber, + ReceiverEmail: receiversMap[payment.ReceiverWallet.Receiver.ID].Email, ReceiverWalletAddress: payment.ReceiverWallet.StellarAddress, ReceiverWalletStatus: payment.ReceiverWallet.Status, CreatedAt: payment.CreatedAt, @@ -110,15 +162,7 @@ func (e ExportHandler) ExportPayments(rw http.ResponseWriter, r *http.Request) { } paymentCSVs = append(paymentCSVs, paymentCSV) } - - fileName := fmt.Sprintf("payments_%s.csv", time.Now().Format("2006-01-02-15-04-05")) - rw.Header().Set("Content-Type", "text/csv") - rw.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) - - if err := gocsv.Marshal(paymentCSVs, rw); err != nil { - httperror.InternalError(ctx, "Failed to write CSV", err, nil).Render(rw) - return - } + return paymentCSVs } func (e ExportHandler) ExportReceivers(rw http.ResponseWriter, r *http.Request) { diff --git a/internal/serve/httphandler/export_handler_test.go b/internal/serve/httphandler/export_handler_test.go index d9a43986c..a227661fd 100644 --- a/internal/serve/httphandler/export_handler_test.go +++ b/internal/serve/httphandler/export_handler_test.go @@ -245,8 +245,8 @@ func Test_ExportHandler_ExportPayments(t *testing.T) { expectedHeaders := []string{ "ID", "Amount", "StellarTransactionID", "Status", "Disbursement.ID", "Asset.Code", "Asset.Issuer", "Wallet.Name", "Receiver.ID", - "ReceiverWallet.Address", "ReceiverWallet.Status", "CreatedAt", "UpdatedAt", - "ExternalPaymentID", "CircleTransferRequestID", + "Receiver.PhoneNumber", "Receiver.Email", "ReceiverWallet.Address", "ReceiverWallet.Status", + "CreatedAt", "UpdatedAt", "ExternalPaymentID", "CircleTransferRequestID", } assert.Equal(t, expectedHeaders, header) @@ -268,9 +268,11 @@ func Test_ExportHandler_ExportPayments(t *testing.T) { assert.Equal(t, tc.expectedPayments[i].Asset.Issuer, row[6]) assert.Equal(t, tc.expectedPayments[i].ReceiverWallet.Wallet.Name, row[7]) assert.Equal(t, tc.expectedPayments[i].ReceiverWallet.Receiver.ID, row[8]) - assert.Equal(t, tc.expectedPayments[i].ReceiverWallet.StellarAddress, row[9]) - assert.Equal(t, string(tc.expectedPayments[i].ReceiverWallet.Status), row[10]) - assert.Equal(t, tc.expectedPayments[i].ExternalPaymentID, row[13]) + assert.Equal(t, receiverReady.PhoneNumber, row[9]) + assert.Equal(t, receiverReady.Email, row[10]) + assert.Equal(t, tc.expectedPayments[i].ReceiverWallet.StellarAddress, row[11]) + assert.Equal(t, string(tc.expectedPayments[i].ReceiverWallet.Status), row[12]) + assert.Equal(t, tc.expectedPayments[i].ExternalPaymentID, row[15]) } }) } diff --git a/internal/serve/httphandler/profile_handler.go b/internal/serve/httphandler/profile_handler.go index 0be1ec756..11985d090 100644 --- a/internal/serve/httphandler/profile_handler.go +++ b/internal/serve/httphandler/profile_handler.go @@ -33,6 +33,7 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" authUtils "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/utils" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) // DefaultMaxMemoryAllocation limits the max of memory allocation up to 2MB @@ -54,6 +55,7 @@ type PatchOrganizationProfileRequest struct { OrganizationName string `json:"organization_name"` TimezoneUTCOffset string `json:"timezone_utc_offset"` IsApprovalRequired *bool `json:"is_approval_required"` + IsLinkShortenerEnabled *bool `json:"is_link_shortener_enabled"` ReceiverInvitationResendInterval *int64 `json:"receiver_invitation_resend_interval_days"` PaymentCancellationPeriodDays *int64 `json:"payment_cancellation_period_days"` ReceiverRegistrationMessageTemplate *string `json:"receiver_registration_message_template"` @@ -174,6 +176,7 @@ func (h ProfileHandler) PatchOrganizationProfile(rw http.ResponseWriter, req *ht Logo: fileContentBytes, TimezoneUTCOffset: reqBody.TimezoneUTCOffset, IsApprovalRequired: reqBody.IsApprovalRequired, + IsLinkShortenerEnabled: reqBody.IsLinkShortenerEnabled, ReceiverRegistrationMessageTemplate: reqBody.ReceiverRegistrationMessageTemplate, OTPMessageTemplate: reqBody.OTPMessageTemplate, ReceiverInvitationResendIntervalDays: reqBody.ReceiverInvitationResendInterval, @@ -359,13 +362,21 @@ func (h ProfileHandler) GetOrganizationInfo(rw http.ResponseWriter, req *http.Re return } + currentTenant, err := tenant.GetTenantFromContext(ctx) + if err != nil { + httperror.InternalError(ctx, "Cannot retrieve the tenant from the context", err, nil).Render(rw) + return + } + resp := map[string]interface{}{ "name": org.Name, "logo_url": lu.String(), + "base_url": currentTenant.BaseURL, "distribution_account": distributionAccount, "distribution_account_public_key": distributionAccount.Address, // TODO: deprecate `distribution_account_public_key` "timezone_utc_offset": org.TimezoneUTCOffset, "is_approval_required": org.IsApprovalRequired, + "is_link_shortener_enabled": org.IsLinkShortenerEnabled, "receiver_invitation_resend_interval_days": 0, "payment_cancellation_period_days": 0, "privacy_policy_link": org.PrivacyPolicyLink, diff --git a/internal/serve/httphandler/profile_handler_test.go b/internal/serve/httphandler/profile_handler_test.go index d91037a6b..1720a2b2c 100644 --- a/internal/serve/httphandler/profile_handler_test.go +++ b/internal/serve/httphandler/profile_handler_test.go @@ -1148,7 +1148,6 @@ func Test_ProfileHandler_GetOrganizationInfo(t *testing.T) { require.NoError(t, err) hostDistAccPublicKey := keypair.MustRandom().Address() - defaultTenantDistAcc := "GDIVVKL6QYF6C6K3C5PZZBQ2NQDLN2OSLMVIEQRHS6DZE7WRL33ZDNXL" distAccResolver, err := signing.NewDistributionAccountResolver(signing.DistributionAccountResolverOptions{ AdminDBConnectionPool: dbConnectionPool, HostDistributionAccountPublicKey: hostDistAccPublicKey, @@ -1164,7 +1163,7 @@ func Test_ProfileHandler_GetOrganizationInfo(t *testing.T) { return string(bytes) } - ctx := tenant.LoadDefaultTenantInContext(t, dbConnectionPool) + currentTenant, ctx := tenant.LoadDefaultTenantInContext(t, dbConnectionPool) t.Run("returns Unauthorized error when no token is found", func(t *testing.T) { w := httptest.NewRecorder() @@ -1252,17 +1251,19 @@ func Test_ProfileHandler_GetOrganizationInfo(t *testing.T) { wantsBody := fmt.Sprintf(` { "logo_url": "http://localhost:8000/organization/logo?token=mytoken", + "base_url": %q, "name": "MyCustomAid", "distribution_account": %s, "distribution_account_public_key": %q, "timezone_utc_offset": "+00:00", "is_approval_required": false, + "is_link_shortener_enabled": false, "privacy_policy_link": null, "receiver_invitation_resend_interval_days": 0, "payment_cancellation_period_days": 0, "message_channel_priority": ["SMS", "EMAIL"] } - `, newDistAccountJSON(t, defaultTenantDistAcc), defaultTenantDistAcc) + `, *currentTenant.BaseURL, newDistAccountJSON(t, *currentTenant.DistributionAccountAddress), *currentTenant.DistributionAccountAddress) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.JSONEq(t, wantsBody, string(respBody)) @@ -1290,18 +1291,20 @@ func Test_ProfileHandler_GetOrganizationInfo(t *testing.T) { wantsBody := fmt.Sprintf(` { "logo_url": "http://localhost:8000/organization/logo?token=mytoken", + "base_url": %q, "name": "MyCustomAid", "distribution_account": %s, "distribution_account_public_key": %q, "timezone_utc_offset": "+00:00", "is_approval_required":false, + "is_link_shortener_enabled": false, "receiver_registration_message_template": "My custom receiver wallet registration invite. MyOrg 👋", "receiver_invitation_resend_interval_days": 0, "payment_cancellation_period_days": 0, "privacy_policy_link": null, "message_channel_priority": ["SMS", "EMAIL"] } - `, newDistAccountJSON(t, defaultTenantDistAcc), defaultTenantDistAcc) + `, *currentTenant.BaseURL, newDistAccountJSON(t, *currentTenant.DistributionAccountAddress), *currentTenant.DistributionAccountAddress) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.JSONEq(t, wantsBody, string(respBody)) @@ -1325,11 +1328,13 @@ func Test_ProfileHandler_GetOrganizationInfo(t *testing.T) { wantsBody = fmt.Sprintf(` { "logo_url": "http://localhost:8000/organization/logo?token=mytoken", + "base_url": %q, "name": "MyCustomAid", "distribution_account": %s, "distribution_account_public_key": %q, "timezone_utc_offset": "+00:00", "is_approval_required":false, + "is_link_shortener_enabled": false, "receiver_registration_message_template": "My custom receiver wallet registration invite. MyOrg 👋", "otp_message_template": "Here's your OTP Code to complete your registration. MyOrg 👋", "receiver_invitation_resend_interval_days": 0, @@ -1337,7 +1342,7 @@ func Test_ProfileHandler_GetOrganizationInfo(t *testing.T) { "privacy_policy_link": null, "message_channel_priority": ["SMS", "EMAIL"] } - `, newDistAccountJSON(t, defaultTenantDistAcc), defaultTenantDistAcc) + `, *currentTenant.BaseURL, newDistAccountJSON(t, *currentTenant.DistributionAccountAddress), *currentTenant.DistributionAccountAddress) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.JSONEq(t, wantsBody, string(respBody)) @@ -1367,17 +1372,19 @@ func Test_ProfileHandler_GetOrganizationInfo(t *testing.T) { wantsBody := fmt.Sprintf(` { "logo_url": "http://localhost:8000/organization/logo?token=mytoken", + "base_url": %q, "name": "MyCustomAid", "distribution_account": %s, "distribution_account_public_key": %q, "timezone_utc_offset": "+00:00", "is_approval_required":false, + "is_link_shortener_enabled": false, "receiver_invitation_resend_interval_days": 2, "payment_cancellation_period_days": 0, "privacy_policy_link": null, "message_channel_priority": ["SMS", "EMAIL"] } - `, newDistAccountJSON(t, defaultTenantDistAcc), defaultTenantDistAcc) + `, *currentTenant.BaseURL, newDistAccountJSON(t, *currentTenant.DistributionAccountAddress), *currentTenant.DistributionAccountAddress) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.JSONEq(t, wantsBody, string(respBody)) @@ -1407,17 +1414,19 @@ func Test_ProfileHandler_GetOrganizationInfo(t *testing.T) { wantsBody := fmt.Sprintf(` { "logo_url": "http://localhost:8000/organization/logo?token=mytoken", + "base_url": %q, "name": "MyCustomAid", "distribution_account": %s, "distribution_account_public_key": %q, "timezone_utc_offset": "+00:00", "is_approval_required":false, + "is_link_shortener_enabled": false, "receiver_invitation_resend_interval_days": 0, "payment_cancellation_period_days": 5, "privacy_policy_link": null, "message_channel_priority": ["SMS", "EMAIL"] } - `, newDistAccountJSON(t, defaultTenantDistAcc), defaultTenantDistAcc) + `, *currentTenant.BaseURL, newDistAccountJSON(t, *currentTenant.DistributionAccountAddress), *currentTenant.DistributionAccountAddress) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.JSONEq(t, wantsBody, string(respBody)) @@ -1447,17 +1456,19 @@ func Test_ProfileHandler_GetOrganizationInfo(t *testing.T) { wantsBody := fmt.Sprintf(` { "logo_url": "http://localhost:8000/organization/logo?token=mytoken", + "base_url": %q, "name": "MyCustomAid", "distribution_account": %s, "distribution_account_public_key": %q, "timezone_utc_offset": "+00:00", "is_approval_required":false, + "is_link_shortener_enabled": false, "receiver_invitation_resend_interval_days": 0, "payment_cancellation_period_days": 0, "privacy_policy_link": "https://example.com/privacy-policy", "message_channel_priority": ["SMS", "EMAIL"] } - `, newDistAccountJSON(t, defaultTenantDistAcc), defaultTenantDistAcc) + `, *currentTenant.BaseURL, newDistAccountJSON(t, *currentTenant.DistributionAccountAddress), *currentTenant.DistributionAccountAddress) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.JSONEq(t, wantsBody, string(respBody)) diff --git a/internal/serve/httphandler/stellar_toml_handler_test.go b/internal/serve/httphandler/stellar_toml_handler_test.go index 66c2e8e71..968756746 100644 --- a/internal/serve/httphandler/stellar_toml_handler_test.go +++ b/internal/serve/httphandler/stellar_toml_handler_test.go @@ -288,7 +288,7 @@ func Test_StellarTomlHandler_ServeHTTP(t *testing.T) { models, err := data.NewModels(dbConnectionPool) require.NoError(t, err) - ctx := tenant.LoadDefaultTenantInContext(t, dbConnectionPool) + _, ctx := tenant.LoadDefaultTenantInContext(t, dbConnectionPool) data.ClearAndCreateAssetFixtures(t, ctx, dbConnectionPool) distAccResolver, err := signing.NewDistributionAccountResolver(signing.DistributionAccountResolverOptions{ diff --git a/internal/serve/httphandler/url_shortener_handler.go b/internal/serve/httphandler/url_shortener_handler.go new file mode 100644 index 000000000..ba73d4200 --- /dev/null +++ b/internal/serve/httphandler/url_shortener_handler.go @@ -0,0 +1,37 @@ +package httphandler + +import ( + "errors" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" +) + +type URLShortenerHandler struct { + Models *data.Models +} + +func (u URLShortenerHandler) HandleRedirect(w http.ResponseWriter, r *http.Request) { + shortCode := strings.TrimSpace(chi.URLParam(r, "code")) + if shortCode == "" { + httperror.BadRequest("Missing short code", nil, nil).Render(w) + return + } + + ctx := r.Context() + originalURL, err := u.Models.URLShortener.GetOriginalURL(ctx, shortCode) + if err != nil { + if errors.Is(err, data.ErrRecordNotFound) { + httperror.NotFound("Short URL not found", err, nil).Render(w) + } else { + httperror.InternalError(ctx, "Error retrieving URL", err, nil).Render(w) + } + return + } + + http.Redirect(w, r, originalURL, http.StatusMovedPermanently) +} diff --git a/internal/serve/httphandler/url_shortener_handler_test.go b/internal/serve/httphandler/url_shortener_handler_test.go new file mode 100644 index 000000000..8f1c1d1e1 --- /dev/null +++ b/internal/serve/httphandler/url_shortener_handler_test.go @@ -0,0 +1,80 @@ +package httphandler + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" +) + +func Test_URLShortenerHandler_HandleRedirect(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, outerErr) + defer dbConnectionPool.Close() + + models, err := data.NewModels(dbConnectionPool) + require.NoError(t, err) + + handler := URLShortenerHandler{Models: models} + + r := chi.NewRouter() + r.Get("/r/{code}", handler.HandleRedirect) + + _, ctx := tenant.LoadDefaultTenantInContext(t, dbConnectionPool) + + // 🧪 Creating test data + existingCode := "exist123" + originalURL := "https://stellar.org/original" + data.CreateShortURLFixture(t, ctx, dbConnectionPool, existingCode, originalURL) + moreCode := "moreCode" + moreURL := "https://stellar.org/more" + data.CreateShortURLFixture(t, ctx, dbConnectionPool, moreCode, moreURL) + + testCases := []struct { + name string + code string + expectedStatus int + expectedErrContains string + }{ + { + name: "🎉successfully redirects to original URL", + code: existingCode, + expectedStatus: http.StatusMovedPermanently, + expectedErrContains: "", + }, + { + name: "returns 404 for non-existent code", + code: "does-not-exist", + expectedStatus: http.StatusNotFound, + expectedErrContains: "Short URL not found", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("/r/%s", tc.code), nil) + require.NoError(t, reqErr) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + assert.Equal(t, tc.expectedStatus, rr.Code) + if tc.expectedErrContains != "" { + body, readErr := io.ReadAll(rr.Body) + require.NoError(t, readErr) + assert.Contains(t, string(body), tc.expectedErrContains) + } + }) + } +} diff --git a/internal/serve/serve.go b/internal/serve/serve.go index 997c070e0..dce01e516 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -459,6 +459,8 @@ func handleHTTP(o ServeOptions) *chi.Mux { AuthManager: authManager, PasswordValidator: o.PasswordValidator, }.ServeHTTP) + + r.Get("/r/{code}", httphandler.URLShortenerHandler{Models: o.Models}.HandleRedirect) }) // SEP-24 and miscellaneous endpoints that are tenant-unaware diff --git a/internal/serve/serve_test.go b/internal/serve/serve_test.go index 135f17a55..415b96841 100644 --- a/internal/serve/serve_test.go +++ b/internal/serve/serve_test.go @@ -1,6 +1,7 @@ package serve import ( + "context" "fmt" "io" "net/http" @@ -376,6 +377,7 @@ func Test_handleHTTP_unauthenticatedEndpoints(t *testing.T) { defer dbConnectionPool.Close() serveOptions := getServeOptionsForTests(t, dbConnectionPool) + data.CreateShortURLFixture(t, context.Background(), dbConnectionPool, "123", "https://stellar.org") handlerMux := handleHTTP(serveOptions) @@ -390,6 +392,7 @@ func Test_handleHTTP_unauthenticatedEndpoints(t *testing.T) { {http.MethodPost, "/mfa"}, {http.MethodPost, "/forgot-password"}, {http.MethodPost, "/reset-password"}, + {http.MethodGet, "/r/123"}, } for _, endpoint := range unauthenticatedEndpoints { t.Run(fmt.Sprintf("%s %s", endpoint.method, endpoint.path), func(t *testing.T) { @@ -400,7 +403,7 @@ func Test_handleHTTP_unauthenticatedEndpoints(t *testing.T) { handlerMux.ServeHTTP(w, req) resp := w.Result() - assert.Contains(t, []int{http.StatusOK, http.StatusBadRequest}, resp.StatusCode) + assert.Contains(t, []int{http.StatusOK, http.StatusBadRequest, http.StatusMovedPermanently}, resp.StatusCode) }) } } diff --git a/internal/services/send_receiver_wallets_invite_service.go b/internal/services/send_receiver_wallets_invite_service.go index 46251b634..8e9b3e269 100644 --- a/internal/services/send_receiver_wallets_invite_service.go +++ b/internal/services/send_receiver_wallets_invite_service.go @@ -122,12 +122,9 @@ func (s SendReceiverWalletInviteService) SendInvite(ctx context.Context, receive TenantBaseURL: *currentTenant.BaseURL, } - registrationLink, err := wdl.GetSignedRegistrationLink(s.sep10SigningPrivateKey) + registrationLink, err := s.GetRegistrationLink(ctx, wdl, organization.IsLinkShortenerEnabled) if err != nil { - log.Ctx(ctx).Errorf( - "error getting signed registration link to receiver wallet ID %s for wallet ID %s and asset ID %s: %s", - rwa.ReceiverWallet.ID, wallet.ID, rwa.Asset.ID, err.Error(), - ) + log.Ctx(ctx).Errorf("getting registration link for receiver wallet ID %s: %v", rwa.ReceiverWallet.ID, err) continue } @@ -210,6 +207,29 @@ func (s SendReceiverWalletInviteService) SendInvite(ctx context.Context, receive }) } +func (s SendReceiverWalletInviteService) GetRegistrationLink(ctx context.Context, wdl WalletDeepLink, isLinkShortenerEnabled bool) (string, error) { + registrationLink, err := wdl.GetSignedRegistrationLink(s.sep10SigningPrivateKey) + if err != nil { + return "", fmt.Errorf("getting signed registration link: %w", err) + } + + if !isLinkShortenerEnabled { + return registrationLink, nil + } + + shortCode, err := s.Models.URLShortener.GetOrCreateShortCode(ctx, registrationLink) + if err != nil { + return "", fmt.Errorf("creating short URL for registration link: %w", err) + } + + shortenedRegistrationLink, err := url.JoinPath(wdl.TenantBaseURL, "r", shortCode) + if err != nil { + return "", fmt.Errorf("building shortened registration link: %w", err) + } + + return shortenedRegistrationLink, nil +} + // resolveReceiverWalletsPendingRegistration returns the receiver wallets pending registration based on the receiverWalletInvitationData. // If the receiverWalletInvitationData is empty, it will return all receiver wallets pending registration. func (s SendReceiverWalletInviteService) resolveReceiverWalletsPendingRegistration(ctx context.Context, receiverWalletInvitationData []schemas.EventReceiverWalletInvitationData) ([]*data.ReceiverWallet, error) { diff --git a/main.go b/main.go index 66972f117..3f98a1ad3 100644 --- a/main.go +++ b/main.go @@ -13,7 +13,7 @@ import ( // Version is the official version of this application. Whenever it's changed // here, it also needs to be updated at the `helmchart/Chart.yaml#appVersion“. -const Version = "3.4.0" +const Version = "3.5.0" // GitCommit is populated at build time by // go build -ldflags "-X main.GitCommit=$GIT_COMMIT" diff --git a/stellar-multitenant/internal/httphandler/tenants_handler_test.go b/stellar-multitenant/internal/httphandler/tenants_handler_test.go index df9a7b551..607b871f3 100644 --- a/stellar-multitenant/internal/httphandler/tenants_handler_test.go +++ b/stellar-multitenant/internal/httphandler/tenants_handler_test.go @@ -98,7 +98,7 @@ func Test_TenantHandler_Get(t *testing.T) { { "id": %q, "name": %q, - "base_url": null, + "base_url": %q, "sdp_ui_base_url": null, "status": "TENANT_CREATED", "is_default": false, @@ -112,7 +112,7 @@ func Test_TenantHandler_Get(t *testing.T) { { "id": %q, "name": %q, - "base_url": null, + "base_url": %q, "sdp_ui_base_url": null, "status": "TENANT_CREATED", "is_default": false, @@ -126,7 +126,7 @@ func Test_TenantHandler_Get(t *testing.T) { { "id": %q, "name": %q, - "base_url": null, + "base_url": %q, "sdp_ui_base_url": null, "status": "TENANT_DEACTIVATED", "is_default": false, @@ -139,11 +139,11 @@ func Test_TenantHandler_Get(t *testing.T) { } ] `, - tnt1.ID, tnt1.Name, tnt1.CreatedAt.Format(time.RFC3339Nano), tnt1.UpdatedAt.Format(time.RFC3339Nano), + tnt1.ID, tnt1.Name, *tnt1.BaseURL, tnt1.CreatedAt.Format(time.RFC3339Nano), tnt1.UpdatedAt.Format(time.RFC3339Nano), *tnt1.DistributionAccountAddress, schema.DistributionAccountStellarDBVault, schema.AccountStatusActive, - tnt2.ID, tnt2.Name, tnt2.CreatedAt.Format(time.RFC3339Nano), tnt2.UpdatedAt.Format(time.RFC3339Nano), + tnt2.ID, tnt2.Name, *tnt2.BaseURL, tnt2.CreatedAt.Format(time.RFC3339Nano), tnt2.UpdatedAt.Format(time.RFC3339Nano), *tnt2.DistributionAccountAddress, schema.DistributionAccountStellarDBVault, schema.AccountStatusActive, - deactivatedTnt.ID, deactivatedTnt.Name, deactivatedTnt.CreatedAt.Format(time.RFC3339Nano), deactivatedTnt.UpdatedAt.Format(time.RFC3339Nano), + deactivatedTnt.ID, deactivatedTnt.Name, *deactivatedTnt.BaseURL, deactivatedTnt.CreatedAt.Format(time.RFC3339Nano), deactivatedTnt.UpdatedAt.Format(time.RFC3339Nano), *deactivatedTnt.DistributionAccountAddress, schema.DistributionAccountStellarDBVault, schema.AccountStatusActive, ) assert.JSONEq(t, expectedRespBody, string(respBody)) @@ -168,7 +168,7 @@ func Test_TenantHandler_Get(t *testing.T) { { "id": %q, "name": %q, - "base_url": null, + "base_url": %q, "sdp_ui_base_url": null, "status": "TENANT_CREATED", "is_default": false, @@ -179,7 +179,7 @@ func Test_TenantHandler_Get(t *testing.T) { "distribution_account_type": %q, "distribution_account_status": %q } - `, tnt1.ID, tnt1.Name, tnt1.CreatedAt.Format(time.RFC3339Nano), tnt1.UpdatedAt.Format(time.RFC3339Nano), + `, tnt1.ID, tnt1.Name, *tnt1.BaseURL, tnt1.CreatedAt.Format(time.RFC3339Nano), tnt1.UpdatedAt.Format(time.RFC3339Nano), *tnt1.DistributionAccountAddress, schema.DistributionAccountStellarDBVault, schema.AccountStatusActive, ) assert.JSONEq(t, expectedRespBody, string(respBody)) @@ -204,7 +204,7 @@ func Test_TenantHandler_Get(t *testing.T) { { "id": %q, "name": %q, - "base_url": null, + "base_url": %q, "sdp_ui_base_url": null, "status": "TENANT_CREATED", "is_default": false, @@ -215,7 +215,7 @@ func Test_TenantHandler_Get(t *testing.T) { "distribution_account_type": %q, "distribution_account_status": %q } - `, tnt2.ID, tnt2.Name, tnt2.CreatedAt.Format(time.RFC3339Nano), tnt2.UpdatedAt.Format(time.RFC3339Nano), + `, tnt2.ID, tnt2.Name, *tnt2.BaseURL, tnt2.CreatedAt.Format(time.RFC3339Nano), tnt2.UpdatedAt.Format(time.RFC3339Nano), *tnt2.DistributionAccountAddress, schema.DistributionAccountStellarDBVault, schema.AccountStatusActive, ) assert.JSONEq(t, expectedRespBody, string(respBody)) @@ -359,6 +359,7 @@ func Test_TenantHandler_Post(t *testing.T) { "receivers", "receivers_audit", "sdp_migrations", + "short_urls", "wallets", "wallets_assets", } @@ -860,7 +861,7 @@ func Test_TenantHandler_Patch_success(t *testing.T) { expectedBodyFn: func(tnt *tenant.Tenant) map[string]interface{} { return map[string]interface{}{ "id": tnt.ID, - "base_url": nil, + "base_url": *tnt.BaseURL, "sdp_ui_base_url": nil, "status": string(tenant.DeactivatedTenantStatus), "distribution_account_address": "GCTNUNQVX7BNIP5AUWW2R4YC7G6R3JGUDNMGT7H62BGBUY4A4V6ROAAH", @@ -888,7 +889,7 @@ func Test_TenantHandler_Patch_success(t *testing.T) { expectedBodyFn: func(tnt *tenant.Tenant) map[string]interface{} { return map[string]interface{}{ "id": tnt.ID, - "base_url": nil, + "base_url": *tnt.BaseURL, "sdp_ui_base_url": "http://ui.valid.com", "status": string(tenant.ActivatedTenantStatus), "distribution_account_address": "GCTNUNQVX7BNIP5AUWW2R4YC7G6R3JGUDNMGT7H62BGBUY4A4V6ROAAH", diff --git a/stellar-multitenant/internal/provisioning/manager_test.go b/stellar-multitenant/internal/provisioning/manager_test.go index 5ea2524a9..f6f56f31b 100644 --- a/stellar-multitenant/internal/provisioning/manager_test.go +++ b/stellar-multitenant/internal/provisioning/manager_test.go @@ -466,6 +466,7 @@ func getExpectedTablesAfterMigrationsApplied() []string { "receivers", "receivers_audit", "sdp_migrations", + "short_urls", "wallets", "wallets_assets", } diff --git a/stellar-multitenant/pkg/tenant/fixtures.go b/stellar-multitenant/pkg/tenant/fixtures.go index 5d41f6ec8..6fcb40798 100644 --- a/stellar-multitenant/pkg/tenant/fixtures.go +++ b/stellar-multitenant/pkg/tenant/fixtures.go @@ -95,31 +95,33 @@ func CreateTenantFixture(t *testing.T, ctx context.Context, sqlExec db.SQLExecut const query = ` WITH create_tenant AS ( INSERT INTO tenants - (name, distribution_account_address) + (name, distribution_account_address, base_url) VALUES - ($1, $2) + ($1, $2, $3) ON CONFLICT DO NOTHING RETURNING * ) SELECT * FROM create_tenant ct ` + baseURL := fmt.Sprintf("http://%s.stellar.local:8000", tenantName) tnt := &Tenant{ Name: tenantName, DistributionAccountAddress: &distributionPubKey, + BaseURL: &baseURL, } - err := sqlExec.GetContext(ctx, tnt, query, tnt.Name, tnt.DistributionAccountAddress) + err := sqlExec.GetContext(ctx, tnt, query, tnt.Name, tnt.DistributionAccountAddress, tnt.BaseURL) require.Nil(t, err) return tnt } -func LoadDefaultTenantInContext(t *testing.T, dbConnectionPool db.DBConnectionPool) context.Context { +func LoadDefaultTenantInContext(t *testing.T, dbConnectionPool db.DBConnectionPool) (*Tenant, context.Context) { ctx := context.Background() const publicKey = "GDIVVKL6QYF6C6K3C5PZZBQ2NQDLN2OSLMVIEQRHS6DZE7WRL33ZDNXL" tnt := CreateTenantFixture(t, ctx, dbConnectionPool, "default-tenant", publicKey) - return SaveTenantInContext(ctx, tnt) + return tnt, SaveTenantInContext(ctx, tnt) } func CheckSchemaExistsFixture(t *testing.T, ctx context.Context, dbConnectionPool db.DBConnectionPool, schemaName string) bool {