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)); 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: 'AFK 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() }); } db() .prepare('UPDATE native_instances SET created_at=?') .run(new Date(Date.now() - 86460000).toISOString()); // Gameplay snapshots may report up to a day of actual active time in these fixtures. db() .prepare('UPDATE leaderboard_matches SET started_at=?') .run(new Date(Date.now() - 86400000).toISOString()); }); afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); vi.restoreAllMocks(); }); const base = (source = server) => ({ server: source, guid: 'ace', match: 'match-one', request_id: `afk-${++serial}` }); const event = (extra = {}) => ({ ...base(), seq: 1, active_seconds: 0, kills: 0, headshots: 0, rounds: 0, revives: 0, activity: 'playing', ...extra }); const reserve = (extra = {}) => ({ ...base(), session_id: `session-${++serial}`, seconds: 604800, ...extra }); const start = (session_id: string) => ({ ...base(), session_id }); const settle = ( session_id: string, consumed_seconds: number | null = 30, end_reason = 'manual', extra = {} ) => ({ ...base(), session_id, consumed_seconds, end_reason, ...extra }); async function post(action: string, body: unknown, credential = secret) { const { POST } = await import('../../src/routes/api/afk/[action]/+server.js'); const response = await POST({ params: { action }, request: new Request(`https://test/api/afk/${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(points = 100000) { const { db } = await import('../../src/lib/server/db/index.js'); db().prepare('INSERT INTO bank_accounts(guid,balance) VALUES (?,?)').run('ace', points); } it('authenticates before reading bodies and binds new operations to current membership', async () => { expect(routePermission('/api/afk/[action]', 'POST')).toBe('machine'); expect(routePermission('/api/afk/[action]', 'GET')).toBe('viewer'); expect(routePermission('/api/afk/corrections', 'POST')).toBe('admin'); const { POST } = await import('../../src/routes/api/afk/[action]/+server.js'); for (const credential of ['', process.env.EC_TELEMETRY_TOKEN!]) { const request = new Request('https://test/api/afk/event', { method: 'POST', headers: { Authorization: `Bearer ${credential}` }, body: 'invalid JSON' }); expect((await POST({ params: { action: 'event' }, request })).status).toBe(401); expect(request.bodyUsed).toBe(false); } expect(await post('balance', base(), otherSecret)).toMatchObject({ status: 403, code: 'wrong_server' }); expect(await post('balance', { ...base(), guid: 'outsider' })).toMatchObject({ code: 'not_in_match' }); expect(await post('reserve', { ...reserve(), match: 'old' })).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('purchase', { ...base(), packs: 1, confirmed: true, expected_purchase_points: 10000, expected_purchase_seconds: 600 }) ).toMatchObject({ code: 'not_in_match' }); }); it('shares GUID balances across games and rolls free allowance for actual spending without accumulation', async () => { expect(await post('balance', base())).toMatchObject({ free_seconds: 1800, saved_seconds: 0 }); const session = reserve(); expect(await post('reserve', session)).toMatchObject({ authorized_seconds: 1800, free_seconds: 0 }); await post('start', start(session.session_id)); expect(await post('settle', settle(session.session_id, 600))).toMatchObject({ free_seconds: 1200, reserved_seconds: 0 }); expect(await post('balance', base(other), otherSecret)).toMatchObject({ free_seconds: 1200 }); const { db, closeDb } = await import('../../src/lib/server/db/index.js'); closeDb(); expect(await post('balance', base())).toMatchObject({ free_seconds: 1200 }); db().prepare('UPDATE afk_sessions SET free_charge_at=free_charge_at-?').run(86400001); expect(await post('balance', base())).toMatchObject({ free_seconds: 1800 }); db() .prepare('UPDATE afk_sessions SET free_charge_at=free_charge_at-?') .run(10 * 86400000); expect(await post('balance', base())).toMatchObject({ free_seconds: 1800 }); }); it('never refunds a held free authorization because a day, heartbeat, or process has passed', async () => { const session = reserve(); await post('reserve', session); const { db } = await import('../../src/lib/server/db/index.js'); db() .prepare('UPDATE afk_accounts SET free_started_at=free_started_at-?') .run(2 * 86400000); expect(await post('balance', base(other), otherSecret)).toMatchObject({ free_seconds: 0, reserved_seconds: 1800 }); expect(await post('reserve', reserve({ server: other }), otherSecret)).toMatchObject({ code: 'session_pending' }); expect(await post('settle', settle(session.session_id, 100))).toMatchObject({ free_seconds: 1700 }); }); it('retains partial progress across reconnects and games, caps performance, and never caps repeated revives', async () => { expect( await post('event', event({ active_seconds: 299, kills: 99, headshots: 24 })) ).toMatchObject({ awarded_seconds: 0 }); expect( await post( 'event', event({ server: other, active_seconds: 1, kills: 1, headshots: 1 }), otherSecret ) ).toMatchObject({ awarded_seconds: 105 }); const big = event({ seq: 2, active_seconds: 300, kills: 2000, headshots: 24, rounds: 50, revives: 20 }); expect(await post('event', big)).toMatchObject({ awarded_seconds: 1035 }); // remaining 435 cap + 600 revive const more = event({ seq: 3, active_seconds: 300, kills: 2100, headshots: 25, rounds: 51, revives: 21 }); const first = await post('event', more); expect(first).toMatchObject({ awarded_seconds: 30 }); expect(await post('event', more)).toEqual(first); expect(await post('event', { ...more, request_id: `new-${++serial}` })).toMatchObject({ awarded_seconds: 0 }); expect( await post( 'event', event({ seq: 4, active_seconds: 300, kills: 2100, headshots: 25, rounds: 51, revives: 22 }) ) ).toMatchObject({ awarded_seconds: 30 }); expect( await post( 'event', event({ seq: 5, active_seconds: 3900, kills: 2200, headshots: 25, rounds: 52, revives: 22 }) ) ).toMatchObject({ awarded_seconds: 765 }); // 720 active +45 performance }); it('rejects spectator, AFK, impossible active time and counter regression; stale snapshots never replay progress', async () => { for (const activity of ['afk', 'spectator', 'connected']) expect(await post('event', event({ activity }))).toMatchObject({ status: 400 }); const snapshot = event({ seq: 3, active_seconds: 300, kills: 100, revives: 1 }); expect(await post('event', snapshot)).toMatchObject({ awarded_seconds: 120 }); expect(await post('event', event({ seq: 2, active_seconds: 200, kills: 50 }))).toMatchObject({ code: 'stale', awarded_seconds: 0 }); expect( await post('event', event({ seq: 3, active_seconds: 300, kills: 100, revives: 2 })) ).toMatchObject({ code: 'progress_conflict' }); expect(await post('event', event({ seq: 4, active_seconds: 0, kills: 100 }))).toMatchObject({ code: 'progress_regression' }); expect(await post('event', event({ seq: 5, active_seconds: 999999 }))).toMatchObject({ code: 'invalid_active_time' }); const session = reserve(); await post('reserve', session); expect( await post('event', event({ seq: 6, active_seconds: 300, kills: 200, revives: 2 })) ).toMatchObject({ code: 'session_pending' }); }); it('atomically purchases from the existing bank, preserves receipts, and protects pending bank transfers', async () => { await fund(); const purchase = { ...base(), packs: 2, confirmed: true, expected_purchase_points: 10000, expected_purchase_seconds: 600 }; const first = await post('purchase', purchase); expect(first).toMatchObject({ state: 'purchased', saved_seconds: 1200, bank_balance: 80000, max_purchase_packs: 8 }); expect(await post('purchase', purchase)).toEqual(first); expect(await post('purchase', { ...purchase, packs: 1 })).toMatchObject({ status: 409, code: 'request_conflict' }); const { db } = await import('../../src/lib/server/db/index.js'); expect(db().prepare('SELECT amount FROM bank_ledger').all()).toEqual([{ amount: -20000 }]); const { bankRequest } = await import('../../src/lib/server/fleet/bank.js'); bankRequest( 'deposit', { ...base(), operation: 'deposit', amount: 1000, capacity: 1000000 }, server, instanceId ); expect( await post('purchase', { ...base(), packs: 1, confirmed: true, expected_purchase_points: 10000, expected_purchase_seconds: 600 }) ).toMatchObject({ code: 'bank_pending' }); }); it('rejects invalid, unaffordable and overcapacity purchases and rolls both ledgers back together', async () => { await fund(10000); for (const packs of [0, -1, 1.5, '1', 1001]) expect( await post('purchase', { ...base(), packs, confirmed: true, expected_purchase_points: 10000, expected_purchase_seconds: 600 }) ).toMatchObject({ status: 400 }); expect( await post('purchase', { ...base(), packs: 1, confirmed: false, expected_purchase_points: 10000, expected_purchase_seconds: 600 }) ).toMatchObject({ status: 400 }); expect( await post('purchase', { ...base(), packs: 2, confirmed: true, expected_purchase_points: 10000, expected_purchase_seconds: 600 }) ).toMatchObject({ code: 'insufficient_balance' }); const { db } = await import('../../src/lib/server/db/index.js'); db().exec( "CREATE TRIGGER reject_afk BEFORE INSERT ON afk_ledger WHEN NEW.kind='purchase' BEGIN SELECT RAISE(ABORT,'test'); END" ); const purchase = { ...base(), packs: 1, confirmed: true, expected_purchase_points: 10000, expected_purchase_seconds: 600 }; expect(await post('purchase', purchase)).toMatchObject({ status: 503 }); expect(db().prepare('SELECT balance FROM bank_accounts').get()).toEqual({ balance: 10000 }); expect(db().prepare('SELECT * FROM bank_ledger').all()).toHaveLength(0); db().exec('DROP TRIGGER reject_afk'); expect(await post('purchase', purchase)).toMatchObject({ saved_seconds: 600, bank_balance: 0 }); vi.stubEnv('EC_AFK_CAPACITY_SECONDS', '600'); expect(await post('event', event({ revives: 100 }))).toMatchObject({ saved_seconds: 600, awarded_seconds: 0 }); }); it('serializes purchases and reservations across servers, counting held saved reserve toward capacity', async () => { await fund(10000); const purchases = await Promise.all([ post('purchase', { ...base(), packs: 1, confirmed: true, expected_purchase_points: 10000, expected_purchase_seconds: 600 }), post( 'purchase', { ...base(other), packs: 1, confirmed: true, expected_purchase_points: 10000, expected_purchase_seconds: 600 }, otherSecret ) ]); expect(purchases.filter((x) => x.state === 'purchased')).toHaveLength(1); const a = reserve(), b = reserve({ server: other }); const reservations = await Promise.all([post('reserve', a), post('reserve', b, otherSecret)]); expect(reservations.filter((x) => x.state === 'reserved')).toHaveLength(1); expect(reservations.filter((x) => x.code === 'session_pending')).toHaveLength(1); const { db } = await import('../../src/lib/server/db/index.js'); db().prepare('UPDATE bank_accounts SET balance=10000').run(); vi.stubEnv('EC_AFK_CAPACITY_SECONDS', '600'); expect( await post('purchase', { ...base(), packs: 1, confirmed: true, expected_purchase_points: 10000, expected_purchase_seconds: 600 }) ).toMatchObject({ code: 'reserve_capacity' }); const selected = reservations[0].state === 'reserved' ? a : b; expect( await post( 'settle', { ...settle(selected.session_id, 1800), server: selected.server }, selected.server === server ? secret : otherSecret ) ).toMatchObject({ saved_seconds: 600, free_seconds: 0 }); }); it('settles actual seconds after disconnect/deletion and never reopens completed or pre-cancelled sessions', async () => { const session = reserve(); await post('reserve', session); await post('start', start(session.session_id)); const { db, closeDb } = await import('../../src/lib/server/db/index.js'); db().prepare('DELETE FROM leaderboard_matches').run(); db().prepare('DELETE FROM servers').run(); closeDb(); const stop = settle(session.session_id, 45, 'disconnect'); const first = await post('settle', stop); expect(first).toMatchObject({ state: 'settled', free_seconds: 1755, consumed_seconds: 45 }); expect(await post('settle', stop)).toEqual(first); expect(await post('start', start(session.session_id))).toMatchObject({ state: 'settled' }); const early = reserve(); expect(await post('settle', settle(early.session_id, 0, 'never_started'))).toMatchObject({ state: 'settled' }); expect(await post('reserve', early)).toMatchObject({ state: 'settled', authorized_seconds: 0 }); const { afkProfile } = await import('../../src/lib/server/fleet/afk.js'); expect(afkProfile('ace')).toMatchObject({ confirmedAfkSeconds: 45, reservedSeconds: 0 }); expect(afkProfile('ace').entries[0]).toMatchObject({ matchAvailable: 0 }); }); it('records unresolved crashes without invented durations and allows only explicit stopped-session corrections', async () => { const session = reserve(); await post('reserve', session); await post('start', start(session.session_id)); const crash = settle(session.session_id, null, 'crash', { confirmed_seconds: 10 }); expect(await post('settle', crash)).toMatchObject({ state: 'unresolved', free_seconds: 0, reserved_seconds: 1800, consumed_seconds: null }); expect(await post('event', event({ revives: 1 }))).toMatchObject({ awarded_seconds: 30 }); const { afkCorrectSession, afkProfile } = await import('../../src/lib/server/fleet/afk.js'); expect(afkProfile('ace')).toMatchObject({ confirmedAfkSeconds: 0, unresolvedSessions: 1 }); const correction = { session_id: session.session_id, request_id: 'correction-one', consumed_seconds: 30, reason: 'Verified stopped process; approved accounting adjustment.', stopped: true }; expect(afkCorrectSession({ ...correction, consumed_seconds: 9 }, 'admin').body.code).toBe( 'invalid_consumption' ); expect(afkCorrectSession({ ...correction, stopped: false }, 'admin').status).toBe(400); const first = afkCorrectSession(correction, 'admin'); expect(first.body).toMatchObject({ state: 'corrected', free_seconds: 1770, reserved_seconds: 0, consumed_seconds: null }); expect(afkCorrectSession(correction, 'admin')).toEqual(first); expect(afkCorrectSession({ ...correction, consumed_seconds: 31 }, 'admin').status).toBe(409); expect(afkProfile('ace').sessions[0]).toMatchObject({ state: 'corrected', consumedSeconds: null, chargedSeconds: 30, confirmedSeconds: 10 }); expect(afkProfile('ace').entries.some((x) => x.kind === 'correction')).toBe(true); expect(await post('start', start(session.session_id))).toMatchObject({ state: 'corrected' }); const next = reserve(); await post('reserve', next); expect( afkCorrectSession( { ...correction, request_id: 'other', session_id: next.session_id }, 'admin' ).body.code ).toBe('not_unresolved'); }); it('requires full authorized consumption for expiration and forbids rewinding confirmed checkpoints', async () => { const session = reserve(); await post('reserve', session); expect(await post('settle', settle(session.session_id, 1799, 'expired'))).toMatchObject({ code: 'invalid_consumption' }); await post('settle', settle(session.session_id, null, 'crash', { confirmed_seconds: 20 })); expect(await post('settle', settle(session.session_id, 19))).toMatchObject({ code: 'invalid_consumption' }); expect(await post('settle', settle(session.session_id, 1800, 'expired'))).toMatchObject({ state: 'settled', free_seconds: 0, reserved_seconds: 0 }); const { afkProfile } = await import('../../src/lib/server/fleet/afk.js'); expect(afkProfile('ace').expiredSessions).toBe(1); }); it('authorizes assigned unfinished teardown recovery and only reads preexisting purchase/event receipts', async () => { await fund(); const purchase = { ...base(), packs: 1, confirmed: true, expected_purchase_points: 10000, expected_purchase_seconds: 600 }; const bought = await post('purchase', purchase); const session = reserve(); await post('reserve', session); const { db } = await import('../../src/lib/server/db/index.js'); const { queueHostOperation, createHost, createEnrollment, enrollHost } = await import('../../src/lib/server/hosts/index.js'); 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 { POST } = await import('../../src/routes/api/agent/afk/settle/+server.js'); const recover = ( action = 'settle', transfer: unknown = settle(session.session_id, 0, 'never_started'), credential = hostToken, operationId = removal.id ) => POST({ request: new Request('https://test/api/agent/afk/settle', { method: 'POST', headers: { Authorization: `Bearer ${credential}` }, body: JSON.stringify({ operationId, action, transfer }) }) }); await expect(recover('settle', undefined, secret)).rejects.toMatchObject({ status: 401 }); const stranger = createHost({ name: 'Other host' }); const strangerToken = enrollHost(createEnrollment(stranger.id).token, {}).credential; await expect(recover('settle', undefined, strangerToken)).rejects.toMatchObject({ status: 403 }); await expect(recover('start')).rejects.toMatchObject({ status: 400 }); expect(await (await recover('purchase', purchase)).json()).toMatchObject({ state: 'purchased', bank_balance: bought.bank_balance }); expect( await ( await recover('purchase', { ...base(), packs: 1, confirmed: true, expected_purchase_points: 10000, expected_purchase_seconds: 600 }) ).json() ).toMatchObject({ code: 'not_applied' }); expect(await (await recover()).json()).toMatchObject({ state: 'settled' }); db().prepare("UPDATE ec_host_operations SET state='completed' WHERE id=?").run(removal.id); await expect(recover()).rejects.toMatchObject({ status: 403 }); }); it('requires an administrator for accounting corrections', async () => { const { POST } = await import('../../src/routes/api/afk/corrections/+server.js'); for (const role of ['viewer', 'operator', 'moderator', 'super_viewer']) { await expect( POST({ request: new Request('https://test/api/afk/corrections', { method: 'POST', body: '{}' }), locals: { user: { id: 'someone', role } } as App.Locals }) ).rejects.toMatchObject({ status: 403 }); } }); it('recovers verified gameplay after match completion while terminally rejecting late purchases and missing history', async () => { const { db } = await import('../../src/lib/server/db/index.js'); const snapshot = event({ active_seconds: 300, revives: 2 }); db() .prepare("UPDATE leaderboard_matches SET status='completed',ended_at=last_received_at") .run(); db().prepare('UPDATE leaderboard_match_players SET present=0').run(); expect(await post('event', snapshot)).toMatchObject({ status: 200, state: 'rewarded', awarded_seconds: 120 }); expect(await post('event', snapshot)).toMatchObject({ awarded_seconds: 120 }); expect( await post('purchase', { ...base(), packs: 1, confirmed: true, expected_purchase_points: 10000, expected_purchase_seconds: 600 }) ).toMatchObject({ status: 200, code: 'not_in_match' }); db().prepare('DELETE FROM leaderboard_matches').run(); expect(await post('event', event({ seq: 2, active_seconds: 600, revives: 3 }))).toMatchObject({ status: 200, code: 'not_in_match' }); }); it('rejects stale purchase quotes without spending and retries initial membership publication lag', async () => { await fund(); expect( await post('purchase', { ...base(), packs: 1, confirmed: true, expected_purchase_points: 1, expected_purchase_seconds: 600 }) ).toMatchObject({ code: 'price_changed', bank_balance: 100000 }); const { db } = await import('../../src/lib/server/db/index.js'); const pending = reserve(); db() .prepare("UPDATE leaderboard_match_players SET guid='not-yet-published' WHERE guid='ace'") .run(); expect(await post('reserve', pending)).toMatchObject({ status: 409, code: 'not_in_match' }); db() .prepare("UPDATE leaderboard_match_players SET guid='ace' WHERE guid='not-yet-published'") .run(); expect(await post('reserve', pending)).toMatchObject({ state: 'reserved' }); }); it('binds reservation receipts to immutable instance/session owners and ignores raw score changes', async () => { const snapshot = event({ score: 999999, score_total: 999999 }); expect(await post('event', snapshot)).toMatchObject({ awarded_seconds: 0 }); const session = reserve(); const first = await post('reserve', session); expect(await post('reserve', session)).toEqual(first); expect(await post('reserve', { ...session, seconds: 1 })).toMatchObject({ status: 409, code: 'request_conflict' }); expect( await post('settle', { ...settle(session.session_id), server: other }, otherSecret) ).toMatchObject({ status: 409, code: 'session_conflict' }); const { db } = await import('../../src/lib/server/db/index.js'); const { bankCredential } = await import('../../src/lib/server/auth/bank.js'); db() .prepare('UPDATE native_instances SET retired_at=? WHERE id=?') .run(new Date().toISOString(), instanceId); expect(await post('balance', base())).toMatchObject({ status: 401 }); const replacement = randomUUID(); db() .prepare( `INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at) VALUES (?,?,?,'library','image',28960,?)` ) .run(replacement, server, hostId, new Date().toISOString()); const replacementSecret = bankCredential(replacement); expect(await post('reserve', session, replacementSecret)).toMatchObject({ status: 409, code: 'request_conflict' }); expect(await post('settle', settle(session.session_id), replacementSecret)).toMatchObject({ status: 409, code: 'session_conflict' }); }); it('terminally rejects departed-player new work without blocking historical contribution recovery', async () => { await fund(); const session = reserve(); await post('reserve', session); const { db } = await import('../../src/lib/server/db/index.js'); db().prepare('UPDATE leaderboard_match_players SET present=0').run(); const purchase = { ...base(), packs: 1, confirmed: true, expected_purchase_points: 10000, expected_purchase_seconds: 600 }; for (const [action, body] of [ ['balance', base()], ['reserve', reserve()], ['start', start(session.session_id)], ['purchase', purchase] ] as const) { expect(await post(action, body)).toMatchObject({ status: 200, state: 'rejected', code: 'not_in_match' }); } expect(await post('event', event({ active_seconds: 300, revives: 1 }))).toMatchObject({ status: 200, state: 'rewarded', awarded_seconds: 90 }); db().prepare('UPDATE leaderboard_match_players SET present=1').run(); expect(await post('purchase', purchase)).toMatchObject({ status: 200, state: 'rejected', code: 'not_in_match' }); expect(db().prepare('SELECT balance FROM bank_accounts WHERE guid=?').get('ace')).toEqual({ balance: 100000 }); });