diff --git a/README.md b/README.md index 04af095c..1df4e883 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ when the failure is field-level. `correlation_id` is on every error; | `FORBIDDEN` | 403 | Reserved name, blocked address | | `NOT_FOUND` | 404 | No such tag, address, or route | | `METHOD_NOT_ALLOWED` | 405 | Wrong verb on a known path | -| `CONFLICT` | 409 | Username or address already registered | +| `CONFLICT` | 409 | Username already taken, or an address is at its 5-username limit | | `PAYLOAD_TOO_LARGE` | 413 | Body over the 10kb cap | | `UNSUPPORTED_MEDIA_TYPE` | 415 | Non-JSON body on a JSON endpoint | | `VALIDATION_FAILED` | 422 | Body failed its schema | @@ -252,7 +252,7 @@ turns an error into a response: ```js const { ApiError } = require('./src/errors'); -return next(new ApiError('CONFLICT', 'Address already registered')); +return next(new ApiError('CONFLICT', 'Username is already taken. Please choose another.')); ``` A `5xx` from an unexpected throw always reports the generic message so @@ -310,19 +310,23 @@ Resolves a given username tag to a Stellar address. - `500 Internal Server Error`: Database lookup failed. ### `POST /register` -Registers a new username and associates it with a Stellar address. +Registers a new username and associates it with a Stellar address. An address +may hold up to 5 usernames (aliases), e.g. `payments*domain` and +`support*domain` for one business account. The first username registered for an +address is its primary; reverse (`type=id`) federation lookups resolve to it. - **Body Parameters (JSON):** - `username` (string) - The desired username. - `address` (string) - The user's Stellar address. -- **Returns:** A JSON object with registration details `{ ok: true, username, address }`. +- **Returns:** A JSON object with registration details `{ ok: true, username, address, is_primary }`. - **Status Codes:** - `200 OK`: Registration successful. - `400 Bad Request`: Missing `username` or `address`. - - `409 Conflict`: Address or username already registered. + - `409 Conflict`: Username already taken, or the address already has the maximum of 5 usernames. - `500 Internal Server Error`: Database lookup or insertion failed. ### `GET /lookup` -Resolves a given Stellar address to its registered username. +Resolves a given Stellar address to its registered username. When an address has +several usernames, the primary one is returned. - **Query Parameter:** `address` (string) - The Stellar address to lookup. - **Returns:** A JSON object with `username` and `address`. - **Status Codes:** @@ -443,7 +447,7 @@ The repository includes a dedicated CLI tool (`scripts/deploy.js` and `./scripts ./scripts/deploy_contract.sh deploy --network mainnet --source S... --admin G... # Upgrade an existing contract to newly compiled WASM -./scripts/deploy_contract.sh upgrade --contract-id CDNQ7... --network testnet --source S... +./scripts/deploy_contract.sh upgrade --contract-id C... --network testnet --source S... # Compile and optimize WASM only ./scripts/deploy_contract.sh build diff --git a/stellar-payment-platform/prisma/migrations/20260829120000_add_username_aliases/migration.sql b/stellar-payment-platform/prisma/migrations/20260829120000_add_username_aliases/migration.sql new file mode 100644 index 00000000..fba46a32 --- /dev/null +++ b/stellar-payment-platform/prisma/migrations/20260829120000_add_username_aliases/migration.sql @@ -0,0 +1,12 @@ +-- #613 — allow several federation usernames (aliases) per Stellar address. + +-- The address can no longer be unique now that multiple usernames may point +-- at it. A plain index keeps reverse (type=id) lookups fast. +DROP INDEX "username_registry_address_key"; +CREATE INDEX "username_registry_address_idx" ON "username_registry"("address"); + +-- `is_primary` marks the username that reverse federation lookups resolve to. +ALTER TABLE "username_registry" ADD COLUMN "is_primary" BOOLEAN NOT NULL DEFAULT false; + +-- Every address currently has exactly one username; it becomes the primary. +UPDATE "username_registry" SET "is_primary" = true WHERE "deleted_at" IS NULL; diff --git a/stellar-payment-platform/prisma/schema.prisma b/stellar-payment-platform/prisma/schema.prisma index 12cd28df..762dfa34 100644 --- a/stellar-payment-platform/prisma/schema.prisma +++ b/stellar-payment-platform/prisma/schema.prisma @@ -17,9 +17,16 @@ generator client { // Federation registry mapping a human-readable username (e.g. "lekan*localhost") // to its Stellar address. Maps to the legacy "username_registry" table so the // data shape is preserved across the SQLite -> PostgreSQL migration. +// +// #613 — an address may carry several usernames (aliases), e.g. +// "payments*domain" and "support*domain" for one business account. `address` +// is therefore no longer unique; `isPrimary` marks the one username that +// reverse (type=id) federation lookups resolve to. The first username +// registered for an address is its primary. model User { username String @id - address String @unique + address String + isPrimary Boolean @default(false) @map("is_primary") memoType String? @map("memo_type") memo String? createdAt DateTime @default(now()) @map("created_at") @@ -28,6 +35,9 @@ model User { webhooks Webhook[] // <-- add this line @@index([username]) + // Reverse federation lookups (type=id) and /lookup?address= filter by + // address, which is no longer backed by a unique index. + @@index([address]) // Serves the keyset (cursor) pagination seeks used by the /users and // /lookup list endpoints: (created_at DESC, username DESC) walks this // index backwards, so page depth no longer affects query cost. diff --git a/stellar-payment-platform/register-endpoint.test.js b/stellar-payment-platform/register-endpoint.test.js index b703f533..0686be91 100644 --- a/stellar-payment-platform/register-endpoint.test.js +++ b/stellar-payment-platform/register-endpoint.test.js @@ -29,6 +29,7 @@ jest.mock("./prismaClient", () => ({ user: { findUnique: jest.fn(), findFirst: jest.fn(), + count: jest.fn(), create: jest.fn(), }, $queryRaw: jest.fn().mockResolvedValue([{ '1': 1 }]), @@ -68,11 +69,12 @@ describe("POST /register - integration test coverage", () => { ({ prisma } = require("./prismaClient")); prisma.user.findFirst.mockReset(); + prisma.user.count.mockReset(); prisma.user.create.mockReset(); }); test("registers successfully with valid payload", async () => { - prisma.user.findFirst.mockResolvedValue(null); + prisma.user.count.mockResolvedValue(0); prisma.user.create.mockResolvedValue({ username: "alice*localhost", address: VALID_ADDRESS, @@ -87,21 +89,24 @@ describe("POST /register - integration test coverage", () => { ok: true, username: "alice*localhost", address: VALID_ADDRESS, + is_primary: true, }); - expect(prisma.user.findFirst).toHaveBeenCalledWith({ + expect(prisma.user.count).toHaveBeenCalledWith({ where: { address: VALID_ADDRESS, deletedAt: null }, }); expect(prisma.user.create).toHaveBeenCalledWith({ data: { username: "alice*localhost", address: VALID_ADDRESS, + isPrimary: true, }, }); }); - test("returns 409 when address already exists", async () => { - prisma.user.findFirst.mockResolvedValue({ - username: "existing*localhost", + test("registers an alias (non-primary) when the address already has a username", async () => { + prisma.user.count.mockResolvedValue(1); + prisma.user.create.mockResolvedValue({ + username: "bob*localhost", address: VALID_ADDRESS, }); @@ -109,11 +114,31 @@ describe("POST /register - integration test coverage", () => { .post("/register") .send({ username: "bob", address: VALID_ADDRESS }); + expect(response.status).toBe(201); + expect(response.body).toMatchObject({ ok: true, is_primary: false }); + expect(prisma.user.create).toHaveBeenCalledWith({ + data: { + username: "bob*localhost", + address: VALID_ADDRESS, + isPrimary: false, + }, + }); + }); + + test("returns 409 once the address has the maximum of 5 usernames", async () => { + prisma.user.count.mockResolvedValue(5); + + const response = await request(app) + .post("/register") + .send({ username: "sixth", address: VALID_ADDRESS }); + expect(response.status).toBe(409); expect(response.body).toMatchObject({ success: false, - error: { code: 'CONFLICT', message: 'Address already registered' }, + error: { code: 'CONFLICT' }, }); + expect(response.body.error.message).toMatch(/maximum of 5/); + expect(prisma.user.create).not.toHaveBeenCalled(); }); test("returns 422 when required payload fields are missing", async () => { diff --git a/stellar-payment-platform/register-multisigner.test.js b/stellar-payment-platform/register-multisigner.test.js index 9b2eabb3..0835b561 100644 --- a/stellar-payment-platform/register-multisigner.test.js +++ b/stellar-payment-platform/register-multisigner.test.js @@ -281,10 +281,10 @@ describe('POST /register - Multi-Signer Threshold Verification', () => { }); describe('Account Lookup and Conflict Detection', () => { - it('should reject duplicate address registration', async () => { + it('should reject registration once the address has 5 usernames', async () => { const accountId = 'GDZST3XVCDTUJ76ZAV2HA72KYQM3DGLLFVDNNZ6XTQCR3BQFGMQ25E4Z'; - - prisma.user.findFirst.mockResolvedValue({ username: 'existing' }); + + prisma.user.count.mockResolvedValue(5); const response = await request(app) .post('/register') @@ -295,7 +295,24 @@ describe('POST /register - Multi-Signer Threshold Verification', () => { }); expect(response.status).toBe(409); - expect(response.body.error.message).toContain('Address already registered'); + expect(response.body.error.message).toMatch(/maximum of 5/); + }); + + it('should register an additional username as an alias for an existing address', async () => { + const accountId = 'GDZST3XVCDTUJ76ZAV2HA72KYQM3DGLLFVDNNZ6XTQCR3BQFGMQ25E4Z'; + + prisma.user.count.mockResolvedValue(2); + + const response = await request(app) + .post('/register') + .send({ + username: 'newuser', + address: accountId, + signature: accountId, + }); + + expect(response.status).toBe(201); + expect(response.body).toMatchObject({ ok: true, is_primary: false }); }); it('should handle account not found error', async () => { diff --git a/stellar-payment-platform/server.js b/stellar-payment-platform/server.js index c55c098e..d83b37fc 100644 --- a/stellar-payment-platform/server.js +++ b/stellar-payment-platform/server.js @@ -59,6 +59,8 @@ const { normalizeNameTag, validateMemo, RESERVED_NAMES, + MAX_USERNAMES_PER_ADDRESS, + PRIMARY_USERNAME_ORDER, USER_DATABASE, shouldFallbackToLocalRegistry, } = require('./src/utils'); @@ -349,14 +351,9 @@ const listLocalUsers = async (search, page, limit, cursorPoint = null) => { ); }; -const registerLocalUser = async ({ username, address }) => { - const existingByAddress = await getLocalUserByAddress(address); - if (existingByAddress) { - const conflictError = new Error('Address already registered'); - conflictError.statusCode = 409; - throw conflictError; - } - +const registerLocalUser = async ({ username, address, isPrimary = false }) => { + // #613 — several usernames may share an address, so an existing address is + // no longer a conflict; only a duplicate username is. const existingByUsername = await getLocalUserByUsername(username); if (existingByUsername) { const conflictError = new Error('Username is already taken. Please choose another.'); @@ -365,9 +362,9 @@ const registerLocalUser = async ({ username, address }) => { } await poolRun( - `INSERT INTO username_registry (username, address, created_at) - VALUES (?, ?, ?)`, - [username, address, new Date().toISOString()], + `INSERT INTO username_registry (username, address, is_primary, created_at) + VALUES (?, ?, ?, ?)`, + [username, address, isPrimary, new Date().toISOString()], ); }; @@ -390,9 +387,12 @@ app.get('/federation', etagCache, validateSchema({ query: federationQuerySchema if (type === 'id') { const cacheKey = federationIdKey(queryValue); const cached = await federationLookupCached(cacheKey, async () => { + // #613 — an address can have several usernames; a reverse lookup + // resolves to the primary one. const row = await prisma.user.findFirst({ where: { address: { equals: queryValue, mode: 'insensitive' }, deletedAt: null }, select: { username: true, address: true, memoType: true, memo: true, flaggedAt: true }, + orderBy: PRIMARY_USERNAME_ORDER, }); if (!row) return null; @@ -613,9 +613,13 @@ app.post('/register', idempotencyMiddleware(redisClient), requireJson, validateS } try { - let existing = null; + // #613 — an address may carry several usernames (aliases). Registration + // adds another while the address is under the cap; the first username + // registered for an address becomes its primary. Reverse (type=id) + // federation lookups resolve to that primary. + let usernameCount = 0; try { - existing = await prisma.user.findFirst({ + usernameCount = await prisma.user.count({ where: { address, deletedAt: null }, }); } catch (error) { @@ -623,14 +627,20 @@ app.post('/register', idempotencyMiddleware(redisClient), requireJson, validateS throw error; } - existing = await getLocalUserByAddress(address); + // Degraded path: the exact alias count is unavailable, so fall back to + // a presence check. The 5-username cap is enforced best-effort here. + usernameCount = (await getLocalUserByAddress(address)) ? 1 : 0; } - if (existing) { - const conflictError = new Error('Address already registered'); - conflictError.statusCode = 409; - return next(conflictError); + if (usernameCount >= MAX_USERNAMES_PER_ADDRESS) { + return next( + new ApiError( + 'CONFLICT', + `This address already has the maximum of ${MAX_USERNAMES_PER_ADDRESS} federation usernames.`, + ), + ); } + const isPrimary = usernameCount === 0; let verificationResult = null; if (signature) { @@ -688,6 +698,7 @@ app.post('/register', idempotencyMiddleware(redisClient), requireJson, validateS data: { username: normalizedUsername, address, + isPrimary, ...(memoType && { memoType, memo }), }, }); @@ -698,13 +709,14 @@ app.post('/register', idempotencyMiddleware(redisClient), requireJson, validateS throw error; } - await registerLocalUser({ username: normalizedUsername, address }); + await registerLocalUser({ username: normalizedUsername, address, isPrimary }); } return res.status(201).json({ ok: true, username: normalizedUsername, address, + is_primary: isPrimary, federation_address: `${normalizedUsername}*${process.env.DOMAIN || 'localhost'}`, ...(verificationResult && { verification: { @@ -752,9 +764,11 @@ app.get('/lookup', validateSchema({ query: lookupQuerySchema }), async (req, res const result = await lookupCached(address, async () => { let row; try { + // #613 — an address can have several usernames; return the primary. row = await prisma.user.findFirst({ where: { address, deletedAt: null }, select: { username: true }, + orderBy: PRIMARY_USERNAME_ORDER, }); } catch (error) { if (!shouldFallbackToLocalRegistry(error)) { diff --git a/stellar-payment-platform/server.test.js b/stellar-payment-platform/server.test.js index b0d53a31..8d60b400 100644 --- a/stellar-payment-platform/server.test.js +++ b/stellar-payment-platform/server.test.js @@ -982,7 +982,7 @@ describe('Database disconnection — 503 handling', () => { ['P1001'], ['P1008'], ])('POST /api/v1/register returns 503 when Prisma throws %s', async (code) => { - prisma.user.findFirst.mockRejectedValue(makePrismaError(code)); + prisma.user.count.mockRejectedValue(makePrismaError(code)); const res = await request(app) .post('/api/v1/register') diff --git a/stellar-payment-platform/sql-injection.test.js b/stellar-payment-platform/sql-injection.test.js index c8d0cca5..f829aae6 100644 --- a/stellar-payment-platform/sql-injection.test.js +++ b/stellar-payment-platform/sql-injection.test.js @@ -163,8 +163,9 @@ describe('#35 Injection safety — POST /register (address conflict check)', () // Either created (201) or rejected as a conflict (409) — never a crash. expect([201, 409]).toContain(res.status); - expect(prisma.user.findFirst).toHaveBeenCalledTimes(1); - const arg = prisma.user.findFirst.mock.calls[0][0]; + // The address feeds the alias-count check as a bound Prisma argument. + expect(prisma.user.count).toHaveBeenCalledTimes(1); + const arg = prisma.user.count.mock.calls[0][0]; expect(arg.where.address).toBe(payload); }, ); diff --git a/stellar-payment-platform/src/routes/v1/federationRoutes.js b/stellar-payment-platform/src/routes/v1/federationRoutes.js index 0e607580..772eb8b6 100644 --- a/stellar-payment-platform/src/routes/v1/federationRoutes.js +++ b/stellar-payment-platform/src/routes/v1/federationRoutes.js @@ -1,6 +1,7 @@ const express = require('express'); const { prisma } = require('../../../prismaClient'); const { normalizeNameTag, etagCache, USER_DATABASE } = require('../../db'); +const { PRIMARY_USERNAME_ORDER } = require('../../utils'); const { federationNameKey, federationIdKey, @@ -21,9 +22,12 @@ module.exports = (redisClient) => { if (type === 'id') { const cacheKey = federationIdKey(queryValue); const cached = await federationLookupCached(cacheKey, async () => { + // #613 — an address can have several usernames; a reverse lookup + // resolves to the primary one. const row = await prisma.user.findFirst({ where: { address: { equals: queryValue, mode: 'insensitive' }, deletedAt: null }, select: { username: true, address: true, memoType: true, memo: true }, + orderBy: PRIMARY_USERNAME_ORDER, }); if (!row) return null; diff --git a/stellar-payment-platform/src/routes/v1/userRoutes.js b/stellar-payment-platform/src/routes/v1/userRoutes.js index 8124a29b..a5147d52 100644 --- a/stellar-payment-platform/src/routes/v1/userRoutes.js +++ b/stellar-payment-platform/src/routes/v1/userRoutes.js @@ -19,6 +19,8 @@ const { normalizeNameTag, validateMemo, RESERVED_NAMES, + MAX_USERNAMES_PER_ADDRESS, + PRIMARY_USERNAME_ORDER, shouldFallbackToLocalRegistry, } = require('../../utils'); const { validateSchema } = require('../../middleware/validateSchema'); @@ -167,15 +169,22 @@ router.post('/register', requireJson, validateSchema({ body: registerBodySchema } try { - const existing = await prisma.user.findFirst({ - where: { address, deletedAt: null } + // #613 — an address may carry several usernames (aliases). Registration + // adds another while the address is under the cap; the first username + // registered for an address becomes its primary. + const usernameCount = await prisma.user.count({ + where: { address, deletedAt: null }, }); - if (existing) { - const conflictError = new Error('Address already registered'); - conflictError.statusCode = 409; - return next(conflictError); + if (usernameCount >= MAX_USERNAMES_PER_ADDRESS) { + return next( + new ApiError( + 'CONFLICT', + `This address already has the maximum of ${MAX_USERNAMES_PER_ADDRESS} federation usernames.`, + ), + ); } + const isPrimary = usernameCount === 0; let verificationResult = null; const signerToVerify = signerAddress || address; @@ -197,6 +206,7 @@ router.post('/register', requireJson, validateSchema({ body: registerBodySchema data: { username: normalizedUsername, address, + isPrimary, ...(memoType && { memoType, memo }), }, }); @@ -207,6 +217,7 @@ router.post('/register', requireJson, validateSchema({ body: registerBodySchema ok: true, username: normalizedUsername, address, + is_primary: isPrimary, federation_address: `${normalizedUsername}*${process.env.DOMAIN || 'localhost'}`, ...(verificationResult && { verification: { @@ -290,9 +301,11 @@ router.get('/lookup', validateSchema({ query: lookupQuerySchema }), asyncHandler if (address) { try { const result = await lookupCached(address, async () => { + // #613 — an address can have several usernames; return the primary. const row = await prisma.user.findFirst({ where: { address, deletedAt: null }, select: { username: true }, + orderBy: PRIMARY_USERNAME_ORDER, }); return row ? { username: row.username, address } : null; }); diff --git a/stellar-payment-platform/src/utils.js b/stellar-payment-platform/src/utils.js index 1fa8bcc1..36d47bbf 100644 --- a/stellar-payment-platform/src/utils.js +++ b/stellar-payment-platform/src/utils.js @@ -8,6 +8,15 @@ const MEMO_HASH_RE = /^[0-9a-fA-F]{64}$/; const RESERVED_NAMES = ['admin', 'root', 'support', 'system', 'stellar', 'api', 'help']; +// #613 — an address may carry at most this many federation usernames +// (one primary plus up to four aliases). +const MAX_USERNAMES_PER_ADDRESS = 5; + +// #613 — Prisma orderBy that resolves an address to its primary username: +// the row flagged primary wins, with oldest-registered as the tiebreak so +// the result stays stable if no row is flagged (e.g. legacy data). +const PRIMARY_USERNAME_ORDER = [{ isPrimary: 'desc' }, { createdAt: 'asc' }]; + function normalizeNameTag(value) { const trimmed = typeof value === 'string' ? value.trim() : ''; if (!trimmed) return ''; @@ -54,6 +63,8 @@ module.exports = { normalizeNameTag, validateMemo, RESERVED_NAMES, + MAX_USERNAMES_PER_ADDRESS, + PRIMARY_USERNAME_ORDER, VALID_MEMO_TYPES, USER_DATABASE, shouldFallbackToLocalRegistry, diff --git a/stellar-payment-platform/tests/register.test.js b/stellar-payment-platform/tests/register.test.js index fb0ffc41..cc3e8d8c 100644 --- a/stellar-payment-platform/tests/register.test.js +++ b/stellar-payment-platform/tests/register.test.js @@ -12,6 +12,7 @@ jest.mock('../prismaClient', () => ({ user: { findUnique: jest.fn().mockResolvedValue(null), findFirst: jest.fn().mockResolvedValue(null), + count: jest.fn().mockResolvedValue(0), create: jest.fn().mockResolvedValue({ username: 'alice123', address: 'GABC123', diff --git a/stellar-payment-platform/tests/username-aliases.test.js b/stellar-payment-platform/tests/username-aliases.test.js new file mode 100644 index 00000000..2427ceed --- /dev/null +++ b/stellar-payment-platform/tests/username-aliases.test.js @@ -0,0 +1,164 @@ +'use strict'; + +// #613 — an address may carry up to five federation usernames (aliases). +// The first registered is the primary; reverse (type=id) federation lookups +// resolve to it. These tests cover registration slot assignment, the cap, +// and the primary-first ordering of the reverse lookup. + +jest.mock('dotenv', () => ({ config: jest.fn() })); + +jest.mock('@stellar/stellar-sdk', () => ({ + Horizon: { Server: jest.fn() }, + StrKey: { + isValidEd25519PublicKey: jest.fn( + (key) => typeof key === 'string' && key.startsWith('G') && key.length === 56, + ), + }, +})); + +jest.mock('pdfkit', () => jest.fn()); +jest.mock('../src/cleanup-cron', () => ({ scheduleCleanupJob: jest.fn() })); +jest.mock('../src/soft-delete-purge-cron', () => ({ scheduleSoftDeletePurgeJob: jest.fn() })); + +jest.mock('bad-words', () => + jest.fn().mockImplementation(() => ({ isProfane: jest.fn(() => false) })), +); + +jest.mock('@prisma/client', () => ({ + Prisma: { PrismaClientKnownRequestError: class extends Error {} }, +})); + +jest.mock('../prismaClient', () => ({ + prisma: { + user: { + findFirst: jest.fn(), + findUnique: jest.fn(), + findMany: jest.fn(), + count: jest.fn(), + create: jest.fn(), + }, + $transaction: jest.fn(), + $queryRaw: jest.fn().mockResolvedValue([{ '1': 1 }]), + }, + isPrismaConnectionError: () => false, +})); + +jest.mock('../src/multisigner-verifier', () => ({ + verifyMultiSignerThreshold: jest.fn().mockResolvedValue({ + success: true, + accountId: 'GDUMMY', + requiredThreshold: 1, + totalWeight: 1, + signerCount: 1, + errorMessage: null, + }), + isSingleSignerAccount: jest.fn().mockReturnValue(true), +})); + +jest.mock('pg', () => ({ + Pool: jest.fn().mockImplementation(() => ({ + query: jest.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + on: jest.fn(), + end: jest.fn().mockResolvedValue(undefined), + })), +})); + +const request = require('supertest'); + +const ADDRESS = 'GDZST3XVCDTUJ76ZAV2HA72KYQM3DGLLFVDNNZ6XTQCR3BQFGMQ25E4Z'; + +describe('#613 username aliases', () => { + let app; + let prisma; + + beforeEach(() => { + jest.resetModules(); + ({ app } = require('../server')); + ({ prisma } = require('../prismaClient')); + prisma.user.findFirst.mockReset(); + prisma.user.count.mockReset(); + prisma.user.create.mockReset(); + prisma.user.create.mockResolvedValue({ username: 'x*localhost', address: ADDRESS }); + }); + + describe('POST /register slot assignment', () => { + test('the first username for an address is registered as the primary', async () => { + prisma.user.count.mockResolvedValue(0); + + const res = await request(app) + .post('/register') + .send({ username: 'payments', address: ADDRESS }); + + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ ok: true, is_primary: true }); + expect(prisma.user.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ username: 'payments*localhost', isPrimary: true }), + }), + ); + }); + + test.each([1, 2, 3, 4])( + 'username number %d for an address is registered as a non-primary alias', + async (existing) => { + prisma.user.count.mockResolvedValue(existing); + + const res = await request(app) + .post('/register') + .send({ username: 'billing', address: ADDRESS }); + + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ ok: true, is_primary: false }); + expect(prisma.user.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ isPrimary: false }), + }), + ); + }, + ); + + test('the sixth username for an address is rejected with 409', async () => { + prisma.user.count.mockResolvedValue(5); + + const res = await request(app) + .post('/register') + .send({ username: 'overflow', address: ADDRESS }); + + expect(res.status).toBe(409); + expect(res.body.error.code).toBe('CONFLICT'); + expect(res.body.error.message).toMatch(/maximum of 5/); + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + + test('the alias-count check filters by address and excludes soft-deleted rows', async () => { + prisma.user.count.mockResolvedValue(0); + + await request(app).post('/register').send({ username: 'payments', address: ADDRESS }); + + expect(prisma.user.count).toHaveBeenCalledWith({ + where: { address: ADDRESS, deletedAt: null }, + }); + }); + }); + + describe('GET /federation type=id returns the primary username', () => { + test('resolves the address to its primary username, ordered primary-first', async () => { + prisma.user.findFirst.mockResolvedValue({ + username: 'payments', + address: ADDRESS, + memoType: null, + memo: null, + flaggedAt: null, + }); + + const res = await request(app).get('/federation').query({ q: ADDRESS, type: 'id' }); + + expect(res.status).toBe(200); + expect(res.body.stellar_address).toBe('payments*localhost'); + expect(res.body.account_id).toBe(ADDRESS); + + const arg = prisma.user.findFirst.mock.calls[0][0]; + expect(arg.orderBy).toEqual([{ isPrimary: 'desc' }, { createdAt: 'asc' }]); + }); + }); +});