354 lines
16 KiB
TypeScript
354 lines
16 KiB
TypeScript
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 });
|
|
});
|