120 lines
7.5 KiB
TypeScript
120 lines
7.5 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
|
|
|
let fixture: Fixture;
|
|
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
|
|
afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); });
|
|
|
|
async function setup() {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const history = await import('../../src/lib/server/provisioning/history.js');
|
|
const handle = db();
|
|
const insert = handle.prepare(`INSERT INTO server_deployments
|
|
(id,server,action,state,stage,started_at,triggered_by,server_deleted_at,observation_json)
|
|
VALUES(?,?,?,?,?,?,'fixture',?,?)`);
|
|
const add = (id: string, state = 'done', action = 'create', server = 't5_zombie_theater_1',
|
|
deleted: string | null = null, stage = 'Recorded', at = '2026-01-01') =>
|
|
insert.run(id, server, action, state, stage, at, deleted, JSON.stringify({ snapshot: 'x'.repeat(4096) }));
|
|
return { ...history, handle, add };
|
|
}
|
|
|
|
describe('bounded provisioning lists', () => {
|
|
it('polls every active row and counts review outcomes without transferring retained history', async () => {
|
|
const { handle, add, REMOVING_SERVER } = await setup();
|
|
handle.transaction(() => {
|
|
for (let i = 0; i < 6_000; i++) add(`history-${i}`, i % 2 ? 'done' : 'failed');
|
|
for (const state of ['queued', 'awaiting-health', 'running', 'waiting']) add(state, state);
|
|
for (let i = 0; i < 110; i++) add(`active-${i}`, 'waiting', 'destroy');
|
|
add('cleanup', 'waiting', 'destroy', 'cleanup', null, REMOVING_SERVER);
|
|
add('deleted-active', 'running', 'create', 'retired', '2026-01-02');
|
|
add('deleted-warning', 'warning', 'create', 'retired', '2026-01-02');
|
|
add('interrupted', 'interrupted');
|
|
})();
|
|
const { GET } = await import('../../src/routes/api/provisioning/+server.js');
|
|
const response = GET({ url: new URL('http://localhost/api/provisioning?view=active&limit=1') });
|
|
const body = await response.json();
|
|
expect(body.total).toBe(115);
|
|
expect(body.deployments).toHaveLength(115);
|
|
expect(body.needsReview).toBe(3_002);
|
|
expect(body.deployments.map((row: { id: string }) => row.id)).not.toContain('deleted-active');
|
|
expect(body.deployments.find((row: { id: string }) => row.id === 'active-0').canCancelRemoval).toBe(1);
|
|
expect(body.deployments.find((row: { id: string }) => row.id === 'cleanup').canCancelRemoval).toBe(0);
|
|
expect(JSON.stringify(body)).not.toContain('observation_json');
|
|
expect(Buffer.byteLength(JSON.stringify(body))).toBeLessThan(60_000);
|
|
expect(response.headers.get('cache-control')).toBe('no-store');
|
|
const history = await GET({ url: new URL('http://localhost/api/provisioning?view=history&limit=100') }).json();
|
|
expect(history.deployments).toHaveLength(100);
|
|
expect(history.total).toBe(6_003);
|
|
expect(Buffer.byteLength(JSON.stringify(history))).toBeLessThan(50_000);
|
|
|
|
// The legacy endpoint must not reintroduce the unbounded response.
|
|
const { GET: legacyGET } = await import('../../src/routes/api/deployments/+server.js');
|
|
expect(await legacyGET({ url: new URL('http://localhost/api/deployments') }).json()).toEqual({ ...body, limit: 25 });
|
|
});
|
|
|
|
it('paginates in a stable order and applies combined filters before the limit', async () => {
|
|
const { add, listDeployments, deploymentListPage } = await setup();
|
|
for (let i = 0; i < 130; i++) add(`done-${i}`);
|
|
add('failed', 'failed', 'restart');
|
|
add('warning', 'warning', 'restart', 't5_zombie_theater_1', '2026-01-02');
|
|
add('other-map', 'failed', 'restart', 't5_zombie_moon_1');
|
|
add('active', 'running');
|
|
add('deleted-active', 'running', 'create', 'retired', '2026-01-02');
|
|
add('old-but-inserted-last', 'done', 'create', 'old', null, 'Recorded', '2025-01-01');
|
|
expect(listDeployments()).toHaveLength(25);
|
|
const page = deploymentListPage({ view: 'history', limit: 100 });
|
|
expect(page.total).toBe(135);
|
|
expect(page.deployments).toHaveLength(100);
|
|
expect(page.deployments[0].id).toBe('deleted-active');
|
|
const next = deploymentListPage({ view: 'history', limit: 100, offset: 100 });
|
|
expect(next.deployments).toHaveLength(35);
|
|
expect(new Set([...page.deployments, ...next.deployments].map(row => row.id)).size).toBe(135);
|
|
expect(next.deployments.at(-1)?.id).toBe('old-but-inserted-last');
|
|
expect(page.deployments[0]).not.toHaveProperty('observation_json');
|
|
expect(page.deployments[0]).not.toHaveProperty('phase_key');
|
|
const { GET } = await import('../../src/routes/api/provisioning/+server.js');
|
|
const filtered = await GET({ url: new URL('http://localhost/api/provisioning?view=history&action=restart&status=attention&server=KINO&limit=1') }).json();
|
|
expect(filtered.total).toBe(2);
|
|
expect(filtered.needsReview).toBe(3);
|
|
expect(filtered.deployments).toEqual([expect.objectContaining({ id: 'warning', server_exists: 0 })]);
|
|
expect(deploymentListPage({ view: 'history', server: 'Lobby 1', action: 'restart' }).total).toBe(3);
|
|
expect(deploymentListPage({ view: 'history', server: "%' OR 1=1 --" }).total).toBe(0);
|
|
});
|
|
|
|
it('bounds malformed and oversized pagination parameters at the API', async () => {
|
|
const { add } = await setup();
|
|
for (let i = 0; i < 120; i++) add(String(i));
|
|
const { GET } = await import('../../src/routes/api/provisioning/+server.js');
|
|
for (const [query, limit, offset] of [
|
|
['limit=999999&offset=-5', 100, 0],
|
|
['limit=Infinity&offset=NaN', 25, 0],
|
|
['limit=-1&offset=1.9', 1, 1],
|
|
['limit=0&offset=Infinity', 25, 0]
|
|
] as const) {
|
|
const body = await GET({ url: new URL(`http://localhost/api/provisioning?view=history&${query}`) }).json();
|
|
expect(body).toMatchObject({ limit, offset, total: 120 });
|
|
expect(body.deployments).toHaveLength(limit);
|
|
}
|
|
});
|
|
|
|
it('uses indexes for active reads, SQL review counts, and history ordering', async () => {
|
|
const { handle, add, deploymentListPage, REMOVING_SERVER } = await setup();
|
|
for (let i = 0; i < 200; i++) add(String(i));
|
|
const prepare = vi.spyOn(handle, 'prepare');
|
|
deploymentListPage({ view: 'active' });
|
|
deploymentListPage({ view: 'history', limit: 100 });
|
|
const queries = prepare.mock.calls.map(([sql]) => sql);
|
|
prepare.mockRestore();
|
|
const explain = (sql: string, bindings: (string | number)[]) =>
|
|
(handle.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...bindings) as { detail: string }[]).map(row => row.detail).join('\n');
|
|
const active = queries.find(sql => sql.includes('AS canCancelRemoval') && !sql.includes('LIMIT'))!;
|
|
const history = queries.find(sql => sql.includes('AS canCancelRemoval') && sql.includes('LIMIT'))!;
|
|
const review = queries.find(sql => sql.includes('COUNT(*) AS needsReview'))!;
|
|
expect(explain(active, [REMOVING_SERVER])).toContain('idx_server_deployments_state');
|
|
expect(explain(review, [])).toContain('COVERING INDEX idx_server_deployments_state');
|
|
const historyPlan = explain(history, [REMOVING_SERVER, 100, 0]);
|
|
expect(historyPlan).toContain('idx_server_deployments_started_at');
|
|
expect(historyPlan).not.toContain('TEMP B-TREE');
|
|
});
|
|
});
|