This commit is contained in:
@@ -0,0 +1,735 @@
|
||||
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
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user