import { randomUUID } from 'node:crypto'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { newFixture, type Fixture } from '../helpers/fixture.js'; import { routePermission } from '../../src/lib/server/auth/access.js'; let fixture: Fixture; const server = 't5_zombie_theater_1'; const other = 't6_zm_transit_1'; let secret: string; let otherSecret: string; let instanceId: string; let hostId: string; let hostToken: string; let serial = 0; beforeEach(async () => { vi.resetModules(); fixture = await newFixture([server, other]); vi.stubEnv('EC_TELEMETRY_TOKEN', 'telemetry_' + 't'.repeat(40)); vi.stubEnv('EC_BANK_MAX_BALANCE', '250000'); const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); await seedServerRegistry(); const { db } = await import('../../src/lib/server/db/index.js'); const { bankCredential } = await import('../../src/lib/server/auth/bank.js'); const { createHost, createEnrollment, enrollHost } = await import('../../src/lib/server/hosts/index.js'); hostId = createHost({ name: 'Bank host' }).id; hostToken = enrollHost(createEnrollment(hostId).token, {}).credential; for (const source of [server, other]) { const id = randomUUID(); db() .prepare( `INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at) VALUES (?,?,?,'library','image',28960,?)` ) .run(id, source, hostId, new Date().toISOString()); if (source === server) { instanceId = id; secret = bankCredential(id); } else otherSecret = bankCredential(id); saveTelemetry({ server: source, map: source === server ? 'zombie_theater' : 'zm_transit', match: 'match-one', seq: 1, event: 'game_start', players: [{ guid: 'ace', name: 'Ace' }], receivedAt: new Date().toISOString() }); } }); afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); }); const transfer = (operation = 'deposit', amount: number | string = 2000, source = server) => ({ server: source, guid: 'ace', match: 'match-one', request_id: `request-${++serial}`, operation, amount, capacity: 1000000 }); async function post(action: string, body: unknown, credential = secret) { const { POST } = await import('../../src/routes/api/bank/[action]/+server.js'); const response = await POST({ params: { action }, request: new Request(`https://example.test/api/bank/${action}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${credential}` }, body: JSON.stringify(body) }) }); expect(response.headers.get('cache-control')).toBe('no-store'); return { status: response.status, ...(await response.json()) }; } async function fund(amount = 2000) { const data = transfer('deposit', amount); expect(await post('deposit', data)).toMatchObject({ state: 'prepared', amount }); expect(await post('commit', data)).toMatchObject({ state: 'committed', balance: amount }); return data; } it('routes only bank POSTs as machine requests, authenticating before reading bodies', async () => { expect(routePermission('/api/bank/[action]', 'POST')).toBe('machine'); expect(routePermission('/api/bank/[action]', 'GET')).toBe('viewer'); const { POST } = await import('../../src/routes/api/bank/[action]/+server.js'); for (const credential of ['', process.env.EC_TELEMETRY_TOKEN!, otherSecret]) { const request = new Request('https://test/api/bank/deposit', { method: 'POST', headers: { Authorization: `Bearer ${credential}` }, body: 'invalid JSON' }); if (credential === otherSecret) continue; expect((await POST({ request, params: { action: 'deposit' } })).status).toBe(401); expect(request.bodyUsed).toBe(false); } expect(await post('deposit', transfer(), otherSecret)).toMatchObject({ status: 403, code: 'wrong_server' }); }); it('owns generated secrets by immutable instance, preserving rebuilds and revoking retired names', async () => { const { db, closeDb } = await import('../../src/lib/server/db/index.js'); const { bankCredential } = await import('../../src/lib/server/auth/bank.js'); const data = transfer(); await post('deposit', data); expect(secret).toMatch(/^ec_bank_[A-Za-z0-9_-]{43}$/); expect(otherSecret).not.toBe(secret); closeDb(); db().prepare('UPDATE native_instances SET generation=2 WHERE id=?').run(instanceId); expect(bankCredential(instanceId)).toBe(secret); db() .prepare('UPDATE native_instances SET retired_at=? WHERE id=?') .run(new Date().toISOString(), instanceId); expect(await post('balance', { server, guid: 'ace' })).toMatchObject({ status: 401 }); expect(() => bankCredential(instanceId)).toThrow('active native instance'); const next = randomUUID(); db() .prepare( `INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at) VALUES (?,?,?,'library','image',28960,?)` ) .run(next, server, hostId, new Date().toISOString()); const replacement = bankCredential(next); const { migrateBankCredentials } = await import('../../src/lib/server/db/bank-credentials.js'); migrateBankCredentials(db()); // A legacy migration rerun must not rebind old requests. expect(replacement).not.toBe(secret); expect(await post('commit', data, replacement)).toMatchObject({ status: 409, code: 'request_conflict' }); expect(await post('cancel', data, replacement)).toMatchObject({ status: 409 }); }); it('settles journals only for the host assigned to an unfinished teardown, even after retirement', async () => { const { db } = await import('../../src/lib/server/db/index.js'); const { queueHostOperation } = await import('../../src/lib/server/hosts/index.js'); const { POST } = await import('../../src/routes/api/agent/bank/settle/+server.js'); const data = transfer(); await post('deposit', data); const removal = queueHostOperation( hostId, 'instance.remove', {}, { instanceId, generation: 1 } ); db() .prepare('UPDATE native_instances SET retired_at=? WHERE id=?') .run(new Date().toISOString(), instanceId); const settle = ( action = 'commit', operationId = removal.id, credential = hostToken, value = data ) => POST({ request: new Request('https://test/api/agent/bank/settle', { method: 'POST', headers: { Authorization: `Bearer ${credential}` }, body: JSON.stringify({ operationId, action, transfer: value }) }) }); await expect(settle('commit', removal.id, secret)).rejects.toMatchObject({ status: 401 }); const { createHost, createEnrollment, enrollHost } = await import('../../src/lib/server/hosts/index.js'); const stranger = createHost({ name: 'Unassigned host' }); const strangerToken = enrollHost(createEnrollment(stranger.id).token, {}).credential; await expect(settle('commit', removal.id, strangerToken)).rejects.toMatchObject({ status: 403 }); await expect(settle('deposit')).rejects.toMatchObject({ status: 400 }); await expect(settle('commit', randomUUID())).rejects.toMatchObject({ status: 403 }); expect( await (await settle('commit', removal.id, hostToken, { ...data, server: other })).json() ).toMatchObject({ code: 'wrong_server' }); const first = await (await settle()).json(); expect(first).toMatchObject({ state: 'committed', balance: 2000 }); expect(await (await settle()).json()).toEqual(first); const cancelled = transfer(); expect(await (await settle('cancel', removal.id, hostToken, cancelled)).json()).toMatchObject({ state: 'cancelled' }); db().prepare("UPDATE ec_host_operations SET state='completed' WHERE id=?").run(removal.id); await expect(settle()).rejects.toMatchObject({ status: 403 }); }); it('persists one credit and one ledger row through lost replies, retries and database reopen', async () => { const data = transfer(); const initial = await post('deposit', data); expect(await post('deposit', data)).toEqual(initial); const first = await post('commit', data); const { closeDb, db } = await import('../../src/lib/server/db/index.js'); closeDb(); expect(await post('commit', data)).toEqual(first); expect(await post('deposit', data)).toEqual(first); expect(db().prepare('SELECT amount,balance FROM bank_ledger').all()).toEqual([ { amount: 2000, balance: 2000 } ]); expect(await post('deposit', { ...data, amount: 1 })).toMatchObject({ status: 409, code: 'request_conflict' }); }); it('shares one GUID balance across games and preserves GUID strings', async () => { await fund(); expect(await post('balance', { server: other, guid: 'ace' }, otherSecret)).toMatchObject({ balance: 2000 }); const data = transfer('withdraw', 'all', other); expect(await post('withdraw', data, otherSecret)).toMatchObject({ state: 'prepared', amount: 2000 }); expect(await post('commit', data, otherSecret)).toMatchObject({ balance: 0, state: 'committed' }); const big = { ...transfer(), guid: '18446744073709551615' }; expect(await post('deposit', big)).toMatchObject({ code: 'not_in_match' }); }); it('rejects overdraws, maximum overflow, invalid amounts and point overflow without a ledger change', async () => { await fund(); for (const [operation, amount, code] of [ ['withdraw', 2001, 'insufficient_balance'], ['deposit', 250000, 'bank_limit'] ] as const) { const data = transfer(operation, amount); const denied = await post(operation, data); expect(denied).toMatchObject({ ok: false, state: 'rejected', code }); expect(await post(operation, data)).toEqual(denied); } expect(await post('withdraw', { ...transfer('withdraw', 'all'), capacity: 100 })).toMatchObject( { code: 'points_limit' } ); for (const amount of [0, -1, 1.5, 1000001, '2000', 'all', null]) { expect(await post('deposit', { ...transfer(), amount })).toMatchObject({ status: 400 }); } const { bankProfile } = await import('../../src/lib/server/fleet/bank.js'); expect(bankProfile('ace')).toMatchObject({ balance: 2000, pending: false }); expect(bankProfile('ace').entries).toHaveLength(1); }); it('requires a present player in the exact current active match', async () => { expect(await post('deposit', { ...transfer(), guid: 'outsider' })).toMatchObject({ code: 'not_in_match' }); expect(await post('deposit', { ...transfer(), match: 'old-match' })).toMatchObject({ code: 'not_in_match' }); const { db } = await import('../../src/lib/server/db/index.js'); db().prepare('UPDATE leaderboard_match_players SET present = 0').run(); expect(await post('deposit', transfer())).toMatchObject({ code: 'not_in_match' }); db().prepare('UPDATE leaderboard_match_players SET present = 1').run(); db().prepare("UPDATE leaderboard_matches SET status = 'completed'").run(); expect(await post('deposit', transfer())).toMatchObject({ code: 'not_in_match' }); }); it('cancels uncertain prepares, including cancellation arriving before the prepare', async () => { await fund(); for (const cancelFirst of [false, true]) { const data = transfer('withdraw', 500); if (!cancelFirst) expect(await post('withdraw', data)).toMatchObject({ state: 'prepared' }); const cancelled = await post('cancel', data); expect(cancelled).toMatchObject({ state: 'cancelled' }); expect(await post('cancel', data)).toEqual(cancelled); expect(await post('withdraw', data)).toEqual(cancelled); expect(await post('commit', data)).toEqual(cancelled); } const { bankProfile } = await import('../../src/lib/server/fleet/bank.js'); expect(bankProfile('ace')).toMatchObject({ balance: 2000, available: 2000, pending: false }); expect(bankProfile('ace').entries).toHaveLength(1); }); it('serializes transfers across servers and reserves available funds until settled', async () => { await fund(); const data = transfer('withdraw', 1500); await post('withdraw', data); expect(await post('balance', { server, guid: 'ace' })).toMatchObject({ balance: 2000, amount: 500, code: 'pending' }); expect(await post('withdraw', transfer('withdraw', 1000, other), otherSecret)).toMatchObject({ code: 'pending' }); await post('commit', data); expect(await post('balance', { server, guid: 'ace' })).toMatchObject({ balance: 500, amount: 500 }); }); it('honors prepared deposits after configuration changes and match/server deletion', async () => { const data = transfer(); await post('deposit', data); vi.stubEnv('EC_BANK_MAX_BALANCE', '100'); const { db } = await import('../../src/lib/server/db/index.js'); db().prepare('DELETE FROM leaderboard_matches').run(); db().prepare('DELETE FROM servers').run(); expect(await post('commit', data)).toMatchObject({ state: 'committed', balance: 2000 }); const { bankProfile } = await import('../../src/lib/server/fleet/bank.js'); expect(bankProfile('ace').entries[0]).toMatchObject({ amount: 2000, matchAvailable: 0, server }); expect(await post('deposit', transfer())).toMatchObject({ code: 'not_in_match' }); expect(await post('cancel', data)).toMatchObject({ state: 'committed' }); }); it('rolls back all writes if the ledger cannot be saved', async () => { const data = transfer(); await post('deposit', data); const { db } = await import('../../src/lib/server/db/index.js'); db().exec( "CREATE TRIGGER reject_bank BEFORE INSERT ON bank_ledger BEGIN SELECT RAISE(ABORT,'test'); END" ); expect(await post('commit', data)).toMatchObject({ status: 503 }); const { bankProfile } = await import('../../src/lib/server/fleet/bank.js'); expect(bankProfile('ace')).toMatchObject({ balance: 0, pending: true, entries: [] }); db().exec('DROP TRIGGER reject_bank'); expect(await post('commit', data)).toMatchObject({ balance: 2000 }); }); it('returns the original settled result after later transfers change the account', async () => { const deposited = await fund(); const withdrawal = transfer('withdraw', 500); await post('withdraw', withdrawal); expect(await post('commit', withdrawal)).toMatchObject({ balance: 1500 }); expect(await post('commit', deposited)).toMatchObject({ balance: 2000 }); expect(await post('balance', { server, guid: 'ace' })).toMatchObject({ balance: 1500 }); }); it('concurrent preparations cannot reserve the same funds twice', async () => { await fund(); const first = transfer('withdraw', 2000); const second = transfer('withdraw', 2000, other); const results = await Promise.all([ post('withdraw', first), post('withdraw', second, otherSecret) ]); expect(results.filter((result) => result.state === 'prepared')).toHaveLength(1); expect(results.filter((result) => result.code === 'pending')).toHaveLength(1); const index = results.findIndex((result) => result.state === 'prepared'); expect( await post('commit', index === 0 ? first : second, index === 0 ? secret : otherSecret) ).toMatchObject({ balance: 0 }); });