diff --git a/apps/payments/src/adminStore.test.ts b/apps/payments/src/adminStore.test.ts new file mode 100644 index 0000000..fd223bf --- /dev/null +++ b/apps/payments/src/adminStore.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AdminStoreError, createSpacetimeAdminStore, type AdminAuditLog } from './adminStore.js'; +import type { SpacetimeAdminConfig } from './config.js'; + +// Need to export the internal row type for tests — but it's not exported. +// We reference it via the auditLog contract instead. +type AuditRow = Parameters[0]; + +const testConfig: SpacetimeAdminConfig = { + uri: 'https://stdb.example', + database: 'test-db', +}; + +function rowsToSql(rows: Record[]): string { + if (rows.length === 0) return ''; + const columns = Object.keys(rows[0]); + return JSON.stringify([{ + schema: { elements: columns.map(col => ({ name: col })) }, + rows: rows.map(row => columns.map(col => row[col])), + }]); +} + +function accountRow(overrides: Record = {}): Record { + return { + id: 'telegram:99', + name: 'Alice', + rating: 1200, + wins: 1, + losses: 0, + balance: 500, + balance_kind: 'paid_elm', + season_points: 50, + ...overrides, + }; +} + +function playerRow(overrides: Record = {}): Record { + return { + identity: '0xabc123', + name: 'Alice', + online: false, + rating: 1200, + wins: 1, + losses: 0, + balance: 500, + balance_kind: 'paid_elm', + account_id: 'telegram:99', + season_points: 50, + ...overrides, + }; +} + +function makeFetch( + accounts: Record[] = [], + players: Record[] = [], +): { fetchImpl: typeof fetch; sqlCalls: () => string[] } { + const calls: string[] = []; + const fetchImpl = vi.fn(async (_url: unknown, init?: RequestInit) => { + const query = (init?.body as string) ?? ''; + calls.push(query); + if (query.startsWith('SELECT * FROM account')) return new Response(rowsToSql(accounts), { status: 200 }); + if (query.startsWith('SELECT * FROM player')) return new Response(rowsToSql(players), { status: 200 }); + return new Response('', { status: 200 }); + }); + return { fetchImpl: fetchImpl as unknown as typeof fetch, sqlCalls: () => calls }; +} + +function makeAuditLog(): { log: AdminAuditLog; rows: () => AuditRow[] } { + const rows: AuditRow[] = []; + const log: AdminAuditLog = { + append: vi.fn(async row => { rows.push(row); }), + read: vi.fn(async () => rows), + }; + return { log, rows: () => rows }; +} + +describe('createSpacetimeAdminStore adjustBalance', () => { + it('credits increase account balance and produce a positive delta audit row', async () => { + const { fetchImpl, sqlCalls } = makeFetch([accountRow()]); + const { log, rows } = makeAuditLog(); + const store = createSpacetimeAdminStore(testConfig, fetchImpl, log); + + const result = await store.adjustBalance({ + admin: { telegramId: 1 }, + accountId: 'telegram:99', + balanceKind: 'paid_elm', + operation: 'credit', + amount: 200, + }); + + expect(result.account.balance).toBe(700); + expect(result.audit.operation).toBe('credit'); + expect(result.audit.previousBalance).toBe(500); + expect(result.audit.newBalance).toBe(700); + expect(result.audit.delta).toBe(200); + + const updateCall = sqlCalls().find(q => q.startsWith('UPDATE account SET balance')); + expect(updateCall).toMatch(/balance = 700/); + expect(rows()).toHaveLength(1); + expect(rows()[0].delta).toBe(200); + }); + + it('debits decrease account balance and produce a negative delta audit row', async () => { + const { fetchImpl, sqlCalls } = makeFetch([accountRow()]); + const { log, rows } = makeAuditLog(); + const store = createSpacetimeAdminStore(testConfig, fetchImpl, log); + + const result = await store.adjustBalance({ + admin: { telegramId: 1 }, + accountId: 'telegram:99', + balanceKind: 'paid_elm', + operation: 'debit', + amount: 100, + }); + + expect(result.account.balance).toBe(400); + expect(result.audit.delta).toBe(-100); + expect(sqlCalls().find(q => q.startsWith('UPDATE account SET balance'))).toMatch(/balance = 400/); + expect(rows()[0].operation).toBe('debit'); + }); + + it('set changes balance to exact value regardless of previous balance', async () => { + const { fetchImpl, sqlCalls } = makeFetch([accountRow()]); + const { log, rows } = makeAuditLog(); + const store = createSpacetimeAdminStore(testConfig, fetchImpl, log); + + const result = await store.adjustBalance({ + admin: { telegramId: 1 }, + accountId: 'telegram:99', + balanceKind: 'paid_elm', + operation: 'set', + amount: 300, + }); + + expect(result.account.balance).toBe(300); + expect(result.audit.delta).toBe(-200); + expect(sqlCalls().find(q => q.startsWith('UPDATE account SET balance'))).toMatch(/balance = 300/); + expect(rows()[0].operation).toBe('set'); + }); + + it('synchronizes linked player balance in the same operation', async () => { + const { fetchImpl, sqlCalls } = makeFetch([accountRow()], [playerRow()]); + const { log } = makeAuditLog(); + const store = createSpacetimeAdminStore(testConfig, fetchImpl, log); + + await store.adjustBalance({ + admin: { telegramId: 1 }, + accountId: 'telegram:99', + balanceKind: 'paid_elm', + operation: 'credit', + amount: 50, + }); + + const playerUpdate = sqlCalls().find(q => q.startsWith('UPDATE player SET balance')); + expect(playerUpdate).toMatch(/balance = 550/); + }); + + it('skips balance_event insert when delta is zero (set to same value)', async () => { + const { fetchImpl, sqlCalls } = makeFetch([accountRow()]); + const { log } = makeAuditLog(); + const store = createSpacetimeAdminStore(testConfig, fetchImpl, log); + + await store.adjustBalance({ + admin: { telegramId: 1 }, + accountId: 'telegram:99', + balanceKind: 'paid_elm', + operation: 'set', + amount: 500, + }); + + const balanceInsert = sqlCalls().find(q => q.startsWith('INSERT INTO balance_event')); + expect(balanceInsert).toBeUndefined(); + }); + + it('writes audit via SQL when no auditLog is provided', async () => { + const { fetchImpl, sqlCalls } = makeFetch([accountRow()]); + const store = createSpacetimeAdminStore(testConfig, fetchImpl); + + await store.adjustBalance({ + admin: { telegramId: 1 }, + accountId: 'telegram:99', + balanceKind: 'paid_elm', + operation: 'credit', + amount: 100, + }); + + const auditInsert = sqlCalls().find(q => q.startsWith('INSERT INTO admin_audit_event')); + expect(auditInsert).toBeDefined(); + }); + + it('includes admin telegramId and reason in audit row', async () => { + const { fetchImpl } = makeFetch([accountRow()]); + const { log, rows } = makeAuditLog(); + const store = createSpacetimeAdminStore(testConfig, fetchImpl, log); + + await store.adjustBalance({ + admin: { telegramId: 42 }, + accountId: 'telegram:99', + balanceKind: 'paid_elm', + operation: 'credit', + amount: 10, + reason: 'support refund', + }); + + expect(rows()[0].adminTelegramId).toBe('42'); + expect(rows()[0].reason).toBe('support refund'); + expect(rows()[0].targetAccountId).toBe('telegram:99'); + }); + + it('throws not_found when account does not exist', async () => { + const { fetchImpl } = makeFetch([]); + const store = createSpacetimeAdminStore(testConfig, fetchImpl); + + await expect(store.adjustBalance({ + admin: { telegramId: 1 }, + accountId: 'telegram:99', + balanceKind: 'paid_elm', + operation: 'credit', + amount: 100, + })).rejects.toThrow(AdminStoreError); + + await expect(store.adjustBalance({ + admin: { telegramId: 1 }, + accountId: 'telegram:99', + balanceKind: 'paid_elm', + operation: 'credit', + amount: 100, + })).rejects.toMatchObject({ code: 'not_found' }); + }); + + it('throws invalid_input when account balance kind does not match', async () => { + const { fetchImpl } = makeFetch([accountRow({ balance_kind: 'demo_teml' })]); + const store = createSpacetimeAdminStore(testConfig, fetchImpl); + + await expect(store.adjustBalance({ + admin: { telegramId: 1 }, + accountId: 'telegram:99', + balanceKind: 'paid_elm', + operation: 'credit', + amount: 100, + })).rejects.toMatchObject({ code: 'invalid_input' }); + }); + + it('throws conflict when debit would make balance negative', async () => { + const { fetchImpl } = makeFetch([accountRow({ balance: 100 })]); + const store = createSpacetimeAdminStore(testConfig, fetchImpl); + + await expect(store.adjustBalance({ + admin: { telegramId: 1 }, + accountId: 'telegram:99', + balanceKind: 'paid_elm', + operation: 'debit', + amount: 200, + })).rejects.toMatchObject({ code: 'conflict' }); + }); + + it('throws invalid_input for zero credit amount', async () => { + const { fetchImpl } = makeFetch([accountRow()]); + const store = createSpacetimeAdminStore(testConfig, fetchImpl); + + await expect(store.adjustBalance({ + admin: { telegramId: 1 }, + accountId: 'telegram:99', + balanceKind: 'paid_elm', + operation: 'credit', + amount: 0, + })).rejects.toMatchObject({ code: 'invalid_input' }); + }); + + it('throws invalid_input for zero debit amount', async () => { + const { fetchImpl } = makeFetch([accountRow()]); + const store = createSpacetimeAdminStore(testConfig, fetchImpl); + + await expect(store.adjustBalance({ + admin: { telegramId: 1 }, + accountId: 'telegram:99', + balanceKind: 'paid_elm', + operation: 'debit', + amount: 0, + })).rejects.toMatchObject({ code: 'invalid_input' }); + }); + + it('throws invalid_input for non-integer amount', async () => { + const { fetchImpl } = makeFetch([accountRow()]); + const store = createSpacetimeAdminStore(testConfig, fetchImpl); + + await expect(store.adjustBalance({ + admin: { telegramId: 1 }, + accountId: 'telegram:99', + balanceKind: 'paid_elm', + operation: 'credit', + amount: 1.5, + })).rejects.toMatchObject({ code: 'invalid_input' }); + }); + + it('throws invalid_input for negative amount', async () => { + const { fetchImpl } = makeFetch([accountRow()]); + const store = createSpacetimeAdminStore(testConfig, fetchImpl); + + await expect(store.adjustBalance({ + admin: { telegramId: 1 }, + accountId: 'telegram:99', + balanceKind: 'paid_elm', + operation: 'credit', + amount: -50, + })).rejects.toMatchObject({ code: 'invalid_input' }); + }); +}); diff --git a/apps/payments/src/server.test.ts b/apps/payments/src/server.test.ts index 1ac744b..cb0961d 100644 --- a/apps/payments/src/server.test.ts +++ b/apps/payments/src/server.test.ts @@ -413,6 +413,416 @@ describe('payments server', () => { reason: 'support correction', }); }); + + it('rejects admin requests with missing initData', async () => { + const telegram = createTelegramMock(); + const adminStore = createAdminStoreMock(); + const baseUrl = await listen(telegram, undefined, undefined, undefined, adminStore); + + const response = await fetch(`${baseUrl}/admin/session`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + + expect(response.status).toBe(401); + const body = await response.json() as Record; + expect(body['error']).toEqual(expect.objectContaining({ code: 'missing_telegram_auth' })); + expect(adminStore.getStats).not.toHaveBeenCalled(); + }); + + it('rejects admin requests with a bad signature', async () => { + const telegram = createTelegramMock(); + const adminStore = createAdminStoreMock(); + const baseUrl = await listen(telegram, undefined, undefined, undefined, adminStore); + + const tampered = buildInitData({ id: 99, first_name: 'Admin' }, 'wrong_bot_token'); + + const response = await fetch(`${baseUrl}/admin/session`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ initData: tampered }), + }); + + expect(response.status).toBe(401); + const body = await response.json() as Record; + expect(body['error']).toEqual(expect.objectContaining({ code: 'invalid_telegram_auth' })); + }); + + it('rejects admin requests with a stale auth_date', async () => { + const telegram = createTelegramMock(); + const adminStore = createAdminStoreMock(); + const baseUrl = await listen(telegram, undefined, undefined, undefined, adminStore); + + const staleAuthDate = Math.floor(Date.now() / 1000) - 90_000; // 25 hours ago + const stale = buildInitData({ id: 99, first_name: 'Admin' }, botToken, staleAuthDate); + + const response = await fetch(`${baseUrl}/admin/session`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ initData: stale }), + }); + + expect(response.status).toBe(401); + const body = await response.json() as Record; + expect(body['error']).toEqual(expect.objectContaining({ code: 'invalid_telegram_auth' })); + }); + + it('returns 503 when ADMIN_TELEGRAM_IDS is empty', async () => { + const telegram = createTelegramMock(); + const adminStore = createAdminStoreMock(); + const disabledConfig = loadConfig({ + NODE_ENV: 'test', + TELEGRAM_BOT_TOKEN: botToken, + PAYMENT_PAYLOAD_SECRET: 'test_payment_secret', + ADMIN_TELEGRAM_IDS: '', + }); + server = createPaymentsServer({ config: disabledConfig, telegram, adminStore }); + await new Promise(resolve => server?.listen(0, resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('no port'); + const baseUrl = `http://127.0.0.1:${address.port}`; + + const response = await fetch(`${baseUrl}/admin/session`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ initData: signedInitData({ id: 99, first_name: 'Admin' }) }), + }); + + expect(response.status).toBe(503); + const body = await response.json() as Record; + expect(body['error']).toEqual(expect.objectContaining({ code: 'admin_disabled' })); + }); + + // --- stats (#64) --- + + it('uses 24h window by default when no window param is given', async () => { + const telegram = createTelegramMock(); + const adminStore = createAdminStoreMock(); + const baseUrl = await listen(telegram, undefined, undefined, undefined, adminStore); + + const response = await fetch(`${baseUrl}/admin/stats`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ initData: signedInitData({ id: 99, first_name: 'Admin' }) }), + }); + + expect(response.status).toBe(200); + expect(adminStore.getStats).toHaveBeenCalledWith('24h'); + }); + + it('accepts 30d window for stats', async () => { + const telegram = createTelegramMock(); + const adminStore = createAdminStoreMock(); + const baseUrl = await listen(telegram, undefined, undefined, undefined, adminStore); + + const response = await fetch(`${baseUrl}/admin/stats`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ initData: signedInitData({ id: 99, first_name: 'Admin' }), window: '30d' }), + }); + + expect(response.status).toBe(200); + expect(adminStore.getStats).toHaveBeenCalledWith('30d'); + }); + + it('rejects invalid stats window with 400', async () => { + const telegram = createTelegramMock(); + const adminStore = createAdminStoreMock(); + const baseUrl = await listen(telegram, undefined, undefined, undefined, adminStore); + + const response = await fetch(`${baseUrl}/admin/stats`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ initData: signedInitData({ id: 99, first_name: 'Admin' }), window: '1y' }), + }); + + expect(response.status).toBe(400); + const body = await response.json() as Record; + expect(body['error']).toEqual(expect.objectContaining({ code: 'invalid_input' })); + }); + + it('stats response contains all required top-level fields', async () => { + const telegram = createTelegramMock(); + const adminStore = createAdminStoreMock(); + const baseUrl = await listen(telegram, undefined, undefined, undefined, adminStore); + + const response = await fetch(`${baseUrl}/admin/stats`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ initData: signedInitData({ id: 99, first_name: 'Admin' }), window: '24h' }), + }); + + const body = await response.json() as Record; + expect(body).toMatchObject({ + window: '24h', + generatedAt: expect.any(String), + users: expect.objectContaining({ dau: expect.any(Number), totalAccounts: expect.any(Number) }), + matches: expect.objectContaining({ total: expect.any(Number), active: expect.any(Number) }), + payments: expect.objectContaining({ count: expect.any(Number), starsAmount: expect.any(Number) }), + balances: expect.objectContaining({ paidElm: expect.any(Number), demoTeml: expect.any(Number) }), + }); + }); + + // --- user search (#64) --- + + it('passes search query to the store and returns users array', async () => { + const telegram = createTelegramMock(); + const adminStore = createAdminStoreMock(); + vi.mocked(adminStore.searchUsers).mockResolvedValueOnce([ + { accountId: 'telegram:99', name: 'Alice', balanceKind: 'paid_elm', balance: 500, + rating: 1200, wins: 1, losses: 0, seasonPoints: 50, refundableElm: 0, online: false, queued: false }, + ]); + const baseUrl = await listen(telegram, undefined, undefined, undefined, adminStore); + + const response = await fetch(`${baseUrl}/admin/users/search`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ initData: signedInitData({ id: 99, first_name: 'Admin' }), query: 'Alice' }), + }); + + expect(response.status).toBe(200); + expect(adminStore.searchUsers).toHaveBeenCalledWith('Alice'); + const body = await response.json() as Record; + expect(Array.isArray(body['users'])).toBe(true); + expect((body['users'] as unknown[]).length).toBe(1); + }); + + it('returns empty users array when search finds nothing', async () => { + const telegram = createTelegramMock(); + const adminStore = createAdminStoreMock(); + const baseUrl = await listen(telegram, undefined, undefined, undefined, adminStore); + + const response = await fetch(`${baseUrl}/admin/users/search`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ initData: signedInitData({ id: 99, first_name: 'Admin' }), query: 'nobody' }), + }); + + expect(response.status).toBe(200); + const body = await response.json() as Record; + expect(body['users']).toEqual([]); + }); + + // --- user detail (#64) --- + + it('returns user detail when account exists', async () => { + const telegram = createTelegramMock(); + const adminStore = createAdminStoreMock(); + vi.mocked(adminStore.getUser).mockResolvedValueOnce({ + accountId: 'telegram:99', + name: 'Alice', + balanceKind: 'paid_elm', + balance: 500, + rating: 1200, + wins: 1, + losses: 0, + seasonPoints: 50, + refundableElm: 0, + online: false, + queued: false, + balanceEvents: [], + }); + const baseUrl = await listen(telegram, undefined, undefined, undefined, adminStore); + + const response = await fetch(`${baseUrl}/admin/users/detail`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ initData: signedInitData({ id: 99, first_name: 'Admin' }), accountId: 'telegram:99' }), + }); + + expect(response.status).toBe(200); + expect(adminStore.getUser).toHaveBeenCalledWith('telegram:99'); + const body = await response.json() as Record; + expect(body['user']).toMatchObject({ accountId: 'telegram:99', name: 'Alice' }); + }); + + it('returns 404 when user detail account is not found', async () => { + const telegram = createTelegramMock(); + const adminStore = createAdminStoreMock(); + const baseUrl = await listen(telegram, undefined, undefined, undefined, adminStore); + + const response = await fetch(`${baseUrl}/admin/users/detail`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ initData: signedInitData({ id: 99, first_name: 'Admin' }), accountId: 'telegram:0' }), + }); + + expect(response.status).toBe(404); + const body = await response.json() as Record; + expect(body['error']).toEqual(expect.objectContaining({ code: 'not_found' })); + }); + + it('returns 400 when accountId is missing from user detail request', async () => { + const telegram = createTelegramMock(); + const adminStore = createAdminStoreMock(); + const baseUrl = await listen(telegram, undefined, undefined, undefined, adminStore); + + const response = await fetch(`${baseUrl}/admin/users/detail`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ initData: signedInitData({ id: 99, first_name: 'Admin' }) }), + }); + + expect(response.status).toBe(400); + const body = await response.json() as Record; + expect(body['error']).toEqual(expect.objectContaining({ code: 'invalid_input' })); + }); + + // --- audit (#64) --- + + it('returns audit events array', async () => { + const telegram = createTelegramMock(); + const adminStore = createAdminStoreMock(); + const baseUrl = await listen(telegram, undefined, undefined, undefined, adminStore); + + const response = await fetch(`${baseUrl}/admin/audit`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ initData: signedInitData({ id: 99, first_name: 'Admin' }) }), + }); + + expect(response.status).toBe(200); + const body = await response.json() as Record; + expect(Array.isArray(body['events'])).toBe(true); + }); + + it('passes audit filters to the store', async () => { + const telegram = createTelegramMock(); + const adminStore = createAdminStoreMock(); + const baseUrl = await listen(telegram, undefined, undefined, undefined, adminStore); + + await fetch(`${baseUrl}/admin/audit`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + initData: signedInitData({ id: 99, first_name: 'Admin' }), + accountId: 'telegram:99', + adminTelegramId: '99', + operation: 'credit', + window: '7d', + }), + }); + + expect(adminStore.getAuditEvents).toHaveBeenCalledWith({ + accountId: 'telegram:99', + adminTelegramId: '99', + operation: 'credit', + window: '7d', + }); + }); + + it('rejects invalid audit operation with 400', async () => { + const telegram = createTelegramMock(); + const adminStore = createAdminStoreMock(); + const baseUrl = await listen(telegram, undefined, undefined, undefined, adminStore); + + const response = await fetch(`${baseUrl}/admin/audit`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + initData: signedInitData({ id: 99, first_name: 'Admin' }), + operation: 'transfer', + }), + }); + + expect(response.status).toBe(400); + const body = await response.json() as Record; + expect(body['error']).toEqual(expect.objectContaining({ code: 'invalid_input' })); + }); + + // --- balance adjust error cases (#64) --- + + it('returns 404 when balance adjust target account is not found', async () => { + const telegram = createTelegramMock(); + const adminStore = createAdminStoreMock(); + const { AdminStoreError } = await import('./adminStore.js'); + vi.mocked(adminStore.adjustBalance).mockRejectedValueOnce( + new AdminStoreError('not_found', 'Account not found'), + ); + const baseUrl = await listen(telegram, undefined, undefined, undefined, adminStore); + + const response = await fetch(`${baseUrl}/admin/balance/adjust`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + initData: signedInitData({ id: 99, first_name: 'Admin' }), + accountId: 'telegram:0', + balanceKind: 'paid_elm', + operation: 'credit', + amount: 100, + }), + }); + + expect(response.status).toBe(404); + const body = await response.json() as Record; + expect(body['error']).toEqual(expect.objectContaining({ code: 'not_found' })); + }); + + it('returns 409 when debit would make balance negative', async () => { + const telegram = createTelegramMock(); + const adminStore = createAdminStoreMock(); + const { AdminStoreError } = await import('./adminStore.js'); + vi.mocked(adminStore.adjustBalance).mockRejectedValueOnce( + new AdminStoreError('conflict', 'Debit would make balance negative'), + ); + const baseUrl = await listen(telegram, undefined, undefined, undefined, adminStore); + + const response = await fetch(`${baseUrl}/admin/balance/adjust`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + initData: signedInitData({ id: 99, first_name: 'Admin' }), + accountId: 'telegram:99', + balanceKind: 'paid_elm', + operation: 'debit', + amount: 9999, + }), + }); + + expect(response.status).toBe(409); + const body = await response.json() as Record; + expect(body['error']).toEqual(expect.objectContaining({ code: 'conflict' })); + }); + + it('returns 400 for invalid balance kind in adjust request', async () => { + const telegram = createTelegramMock(); + const adminStore = createAdminStoreMock(); + const baseUrl = await listen(telegram, undefined, undefined, undefined, adminStore); + + const response = await fetch(`${baseUrl}/admin/balance/adjust`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + initData: signedInitData({ id: 99, first_name: 'Admin' }), + accountId: 'telegram:99', + balanceKind: 'fake_coin', + operation: 'credit', + amount: 100, + }), + }); + + expect(response.status).toBe(400); + const body = await response.json() as Record; + expect(body['error']).toEqual(expect.objectContaining({ code: 'invalid_input' })); + }); + + // --- admin backend unavailable (#64) --- + + it('returns 503 admin_backend_unavailable when admin store is not configured', async () => { + const telegram = createTelegramMock(); + const baseUrl = await listen(telegram); + + const response = await fetch(`${baseUrl}/admin/stats`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ initData: signedInitData({ id: 99, first_name: 'Admin' }) }), + }); + + expect(response.status).toBe(503); + const body = await response.json() as Record; + expect(body['error']).toEqual(expect.objectContaining({ code: 'admin_backend_unavailable' })); + }); }); async function listen( @@ -472,7 +882,7 @@ function createAdminStoreMock(): AdminStore { adminTelegramId: '99', targetAccountId: 'telegram:99', balanceKind: 'paid_elm', - operation: 'credit', + operation: 'credit' as const, previousBalance: 0, newBalance: 100, delta: 100, @@ -507,13 +917,21 @@ function createPayload(packageId: string, telegramUserId: number): string { } function signedInitData(user: { id: number; first_name: string }): string { + return buildInitData(user, botToken); +} + +function buildInitData( + user: { id: number; first_name: string }, + token: string, + authDate: number = Math.floor(Date.now() / 1000), +): string { const params = new URLSearchParams({ - auth_date: String(Math.floor(Date.now() / 1000)), + auth_date: String(authDate), user: JSON.stringify(user), }); const entries = [...params.entries()].map(([key, value]) => `${key}=${value}`).sort(); const dataCheckString = entries.join('\n'); - const secretKey = crypto.createHmac('sha256', 'WebAppData').update(botToken).digest(); + const secretKey = crypto.createHmac('sha256', 'WebAppData').update(token).digest(); const hash = crypto.createHmac('sha256', secretKey).update(dataCheckString).digest('hex'); params.set('hash', hash); return params.toString();