diff --git a/src/lib/provisioning-list.ts b/src/lib/provisioning-list.ts new file mode 100644 index 0000000..2faf1b9 --- /dev/null +++ b/src/lib/provisioning-list.ts @@ -0,0 +1,31 @@ +import type { ProvisioningAction } from './provisioning.js'; +import type { ProvisioningDeployment } from './provisioning-detail.js'; + +export type ProvisioningListRow = Pick & { canCancelRemoval: number }; + +export interface ProvisioningListOptions { + view?: 'active' | 'history' | 'all'; + action?: ProvisioningAction; + status?: 'attention'; + server?: string; + limit?: number; + offset?: number; +} + +export interface ProvisioningListPage { + deployments: ProvisioningListRow[]; + total: number; + needsReview: number; + limit: number; + offset: number; +} + +export function provisioningPageSize(value: number): number { + return Number.isFinite(value) ? Math.min(100, Math.max(1, Math.floor(value) || 25)) : 25; +} + +export function provisioningPageOffset(value: number): number { + return Number.isFinite(value) ? Math.min(1_000_000, Math.max(0, Math.floor(value))) : 0; +} diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index 663e468..2f2d925 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -378,7 +378,9 @@ export function db(): Database.Database { handle.exec(`CREATE INDEX IF NOT EXISTS idx_server_deployments_server ON server_deployments(server, server_deleted_at); CREATE INDEX IF NOT EXISTS idx_server_deployments_state - ON server_deployments(state, action, server_deleted_at);`); + ON server_deployments(state, action, server_deleted_at); + CREATE INDEX IF NOT EXISTS idx_server_deployments_started_at + ON server_deployments(started_at);`); installReportRevisions(handle); } catch (err) { handle.close(); diff --git a/src/lib/server/provisioning/history.ts b/src/lib/server/provisioning/history.ts index a280225..6576a57 100644 --- a/src/lib/server/provisioning/history.ts +++ b/src/lib/server/provisioning/history.ts @@ -10,6 +10,8 @@ import { bindNativeOperation } from './native.js'; import { workerActivity } from '../pipeline/activity.js'; import { canCancelCreation } from './cancel-creation.js'; import { nativeDeployment } from '../backends/native.js'; +import { serverTitle } from '../../ui/server-labels.js'; +import { provisioningPageSize, provisioningPageOffset, type ProvisioningListOptions, type ProvisioningListPage, type ProvisioningListRow } from '../../provisioning-list.js'; export function beginDeployment(server: string, action: ProvisioningAction, triggeredBy = 'ui'): string { const id = randomUUID(); @@ -250,10 +252,54 @@ function preserveOutcome(id: string): void { details: { state: row.state, finishedAt: row.finished_at, error: row.error, warnings: warningMessages(row.warnings) } } }); } -export function listDeployments() { - return db().prepare(`SELECT d.*, (d.server_deleted_at IS NULL AND EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server AND s.destroyed_at IS NULL)) AS server_exists, +const activeDeployment = "d.server_deleted_at IS NULL AND d.state IN ('queued', 'awaiting-health', 'running', 'waiting')"; +const reviewDeployment = "d.state IN ('failed', 'interrupted', 'warning')"; +const deploymentListColumns = `d.id, d.server, d.server_deleted_at, d.action, d.state, d.stage, + d.started_at, d.finished_at, d.triggered_by, d.error, d.warnings, + (d.server_deleted_at IS NULL AND EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server AND s.destroyed_at IS NULL)) AS server_exists, (d.server_deleted_at IS NULL AND d.action = 'destroy' AND d.state = 'waiting' AND d.stage != ?) AS canCancelRemoval - FROM server_deployments d ORDER BY started_at DESC, rowid DESC`).all(REMOVING_SERVER); + `; + +function deploymentListQuery(options: ProvisioningListOptions) { + const conditions = [options.view === 'active' ? `(${activeDeployment})` + : options.view === 'history' ? `NOT (${activeDeployment})` : '1']; + const parameters: string[] = []; + if (options.action) { conditions.push('d.action = ?'); parameters.push(options.action); } + if (options.status === 'attention') conditions.push(reviewDeployment); + const search = (options.server ?? '').slice(0, 100).toLowerCase(); + if (search) { + // Read only distinct names to preserve friendly map/lobby searches, including deleted servers. + const names = (db().prepare('SELECT DISTINCT server FROM server_deployments').all() as { server: string }[]) + .map(row => row.server).filter(name => `${name} ${serverTitle(name)}`.toLowerCase().includes(search)); + conditions.push('d.server IN (SELECT value FROM json_each(?))'); + parameters.push(JSON.stringify(names)); + } + return { where: conditions.join(' AND '), parameters, + limit: provisioningPageSize(options.limit ?? 25), offset: provisioningPageOffset(options.offset ?? 0) }; +} + +function deploymentListRows(query: ReturnType, active: boolean): ProvisioningListRow[] { + return db().prepare(`SELECT ${deploymentListColumns} FROM server_deployments d WHERE ${query.where} + ORDER BY d.started_at DESC, d.rowid DESC ${active ? '' : 'LIMIT ? OFFSET ?'}`) + .all(REMOVING_SERVER, ...query.parameters, ...(active ? [] : [query.limit, query.offset])) as ProvisioningListRow[]; +} + +/** All list reads omit observation snapshots; retained history is always bounded. */ +export function listDeployments(options: ProvisioningListOptions = {}): ProvisioningListRow[] { + return deploymentListRows(deploymentListQuery(options), options.view === 'active'); +} + +export function deploymentListPage(options: ProvisioningListOptions = {}): ProvisioningListPage { + return db().transaction(() => { + const query = deploymentListQuery(options); + const deployments = deploymentListRows(query, options.view === 'active'); + const total = options.view === 'active' ? deployments.length : (db().prepare( + `SELECT COUNT(*) AS total FROM server_deployments d WHERE ${query.where}` + ).get(...query.parameters) as { total: number }).total; + const { needsReview } = db().prepare(`SELECT COUNT(*) AS needsReview FROM server_deployments d WHERE ${reviewDeployment}`) + .get() as { needsReview: number }; + return { deployments, total, needsReview, limit: query.limit, offset: query.offset }; + })(); } export function interruptDeployments(): void { diff --git a/src/lib/ui/provisioning-presentation.ts b/src/lib/ui/provisioning-presentation.ts index 0e4b939..630af2d 100644 --- a/src/lib/ui/provisioning-presentation.ts +++ b/src/lib/ui/provisioning-presentation.ts @@ -122,7 +122,7 @@ export function provisioningReturn(value: string | null | undefined): { href: st if (url.origin !== 'https://electric-cherry.invalid') return fallback; if (url.pathname === '/provisioning') { const params = new URLSearchParams(); - for (const key of ['view', 'action', 'status', 'server', 'limit']) { + for (const key of ['view', 'action', 'status', 'server', 'limit', 'offset']) { const item = url.searchParams.get(key); if (item) params.set(key, item); } diff --git a/src/routes/api/deployments/+server.ts b/src/routes/api/deployments/+server.ts index f08abd0..e59918a 100644 --- a/src/routes/api/deployments/+server.ts +++ b/src/routes/api/deployments/+server.ts @@ -1,3 +1 @@ -import { json } from '@sveltejs/kit'; -import { listDeployments } from '$lib/server/provisioning/index.js'; -export function GET() { return json({ deployments: listDeployments() }); } +export { GET } from '../provisioning/+server.js'; diff --git a/src/routes/api/provisioning/+server.ts b/src/routes/api/provisioning/+server.ts index e926834..94ee4a2 100644 --- a/src/routes/api/provisioning/+server.ts +++ b/src/routes/api/provisioning/+server.ts @@ -1,10 +1,21 @@ import { json } from '@sveltejs/kit'; -import { listDeployments } from '$lib/server/provisioning/index.js'; +import { deploymentListPage } from '$lib/server/provisioning/history.js'; import { requestLifecycle } from '$lib/server/provisioning/index.js'; -import { runtimeActions, type RuntimeAction } from '$lib/provisioning.js'; +import { provisioningActions, runtimeActions, type ProvisioningAction, type RuntimeAction } from '$lib/provisioning.js'; import { kick } from '$lib/server/pipeline/worker.js'; -export function GET() { return json({ deployments: listDeployments() }); } +export function GET({ url }: { url: URL }) { + const params = url.searchParams; + const action = params.get('action'); + return json(deploymentListPage({ + view: params.get('view') === 'history' ? 'history' : 'active', + action: provisioningActions.includes(action as ProvisioningAction) ? action as ProvisioningAction : undefined, + status: params.get('status') === 'attention' ? 'attention' : undefined, + server: params.get('server') ?? '', + limit: Number(params.get('limit') ?? 25), + offset: Number(params.get('offset') ?? 0) + }), { headers: { 'Cache-Control': 'no-store' } }); +} export async function POST({ request }: { request: Request }) { const body = await request.json().catch(() => null); diff --git a/src/routes/provisioning/+page.svelte b/src/routes/provisioning/+page.svelte index 8461bff..8f20e74 100644 --- a/src/routes/provisioning/+page.svelte +++ b/src/routes/provisioning/+page.svelte @@ -6,9 +6,7 @@ import { pollVisible } from '$ui/poll.js'; import Tabs from '$ui/components/primitives/Tabs.svelte'; import StatusBadge from '$ui/components/primitives/StatusBadge.svelte'; - import ListFooter from '$ui/components/primitives/ListFooter.svelte'; import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte'; - import { serverTitle } from '$ui/server-labels.js'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import Badge from '$ui/components/primitives/Badge.svelte'; import Button from '$ui/components/primitives/Button.svelte'; @@ -17,17 +15,17 @@ import HousePartyDialog from '$ui/components/HousePartyDialog.svelte'; import LifecycleDialog from '$ui/components/LifecycleDialog.svelte'; - import { provisioningActions, actionLabels, actionCompleted, type ProvisioningAction } from '$lib/provisioning.js'; + import { provisioningPageSize, provisioningPageOffset, type ProvisioningListRow as Deployment, type ProvisioningListPage } from '$lib/provisioning-list.js'; + import { provisioningActions, actionLabels, actionCompleted } from '$lib/provisioning.js'; let lifecycleOpen = $state(false); - interface Deployment { - id: string; server: string; server_deleted_at?: string | null; action: ProvisioningAction; - state: 'queued' | 'awaiting-health' | 'waiting' | 'running' | 'done' | 'failed' | 'warning' | 'interrupted' | 'cancelled'; - stage: string; started_at: string; finished_at: string | null; - triggered_by: string; error: string | null; warnings: string; server_exists: number; - canCancelRemoval: boolean; - } - let deployments = $state([]); + let active = $state([]); + let history = $state([]); + let needsReview = $state(0); + let historyTotal = $state(0); + let historyLoading = $state(false); + let historyError = $state(''); + let historyRefresh = $state(0); let loading = $state(true); let error = $state(''); let open = $state(false); @@ -35,6 +33,7 @@ let housePartyNotice = $state(''); let tab = $state('active'); let limit = $state(25); + let offset = $state(0); let historyState = $state('all'); let cancelling = $state(false); let cancellationNotice = $state(''); @@ -44,12 +43,13 @@ action = provisioningActions.some(value => value === params.get('action')) ? params.get('action')! : 'all'; historyState = params.get('status') === 'attention' ? 'attention' : 'all'; search = params.get('server') ?? ''; - limit = Math.max(25, Number(params.get('limit')) || 25); + limit = provisioningPageSize(Number(params.get('limit') ?? 25)); + offset = provisioningPageOffset(Number(params.get('offset') ?? 0)); }); function saveContext(reset = false) { - if (reset) limit = 25; + if (reset) offset = 0; const url = new URL($page.url); - for (const [key, value] of Object.entries({ view: tab === 'active' ? '' : tab, action: action === 'all' ? '' : action, status: historyState === 'all' ? '' : historyState, server: search, limit: limit > 25 ? String(limit) : '' })) { + for (const [key, value] of Object.entries({ view: tab === 'active' ? '' : tab, action: action === 'all' ? '' : action, status: historyState === 'all' ? '' : historyState, server: search, limit: limit !== 25 ? String(limit) : '', offset: offset ? String(offset) : '' })) { if (value) url.searchParams.set(key, value); else url.searchParams.delete(key); } void goto(url, { replaceState: true, noScroll: true, keepFocus: true }); @@ -67,23 +67,41 @@ let action = $state('all'); let search = $state(''); let refreshing = false; - let active = $derived(deployments.filter(d => !d.server_deleted_at && ['queued', 'awaiting-health', 'running', 'waiting'].includes(d.state))); - const needsAttention = (deployment: Deployment) => ['failed', 'interrupted', 'warning'].includes(deployment.state); - const attention = $derived(deployments.filter(needsAttention)); - let history = $derived(deployments.filter(d => (d.server_deleted_at || !['queued', 'awaiting-health', 'running', 'waiting'].includes(d.state)) && - (historyState === 'all' || needsAttention(d)) && - (action === 'all' || d.action === action) && (d.server + ' ' + serverTitle(d.server)).toLowerCase().includes(search.toLowerCase()))); - async function load() { + async function load(signal?: AbortSignal) { if (refreshing) return; refreshing = true; try { - const res = await fetch('/api/provisioning'); + const res = await fetch('/api/provisioning?view=active', { signal, cache: 'no-store' }); if (!res.ok) throw new Error('Could not load provisioning activity.'); - deployments = (await res.json()).deployments; + const result: ProvisioningListPage = await res.json(); + active = result.deployments; + needsReview = result.needsReview; error = ''; - } catch (err) { error = (err as Error).message; } + } catch (err) { if (!signal?.aborted) error = (err as Error).message; return false; } finally { loading = false; refreshing = false; } } + // History is fetched on navigation/filter changes or explicit refresh, never by the active poll. + $effect(() => { + if (tab !== 'history') return; + const params = new URLSearchParams({ view: 'history', action, status: historyState, + server: search, limit: String(limit), offset: String(offset) }); + void historyRefresh; + const controller = new AbortController(); + historyLoading = true; + historyError = ''; + history = []; + historyTotal = 0; + void (async () => { + try { + const response = await fetch(`/api/provisioning?${params}`, { signal: controller.signal, cache: 'no-store' }); + if (!response.ok) throw new Error('Could not load provisioning history.'); + const result: ProvisioningListPage = await response.json(); + if (!controller.signal.aborted) { history = result.deployments; historyTotal = result.total; } + } catch (err) { if (!controller.signal.aborted) historyError = (err as Error).message; } + finally { if (!controller.signal.aborted) historyLoading = false; } + })(); + return () => controller.abort(); + }); onMount(() => pollVisible(load, 5000)); function fmt(value: string) { return $timeFormat.dateTime(value); } function warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } } @@ -97,13 +115,13 @@ saveContext(true)} ariaLabel="Provisioning views" idPrefix="deployment-tabs" panelId="deployment-panel" /> - { housePartyNotice = `Destruction queued for ${count} server${count === 1 ? '' : 's'}. Follow progress in the Active tab.`; tab = 'active'; void load(); }} /> + { housePartyNotice = `Destruction queued for ${count} server${count === 1 ? '' : 's'}. Follow progress in the Active tab.`; tab = 'active'; saveContext(true); void load(); }} /> {#if housePartyNotice}

{housePartyNotice}

{/if} {#if cancellationNotice}

{cancellationNotice}

{/if}
- {#if error}{/if} + {#if error}{/if} {#if tab === 'active'} - {#if attention.length}
{attention.length} recorded operations need review
{/if} + {#if needsReview}
{needsReview} recorded operations need review
{/if} {#if active.some(d => d.canCancelRemoval)}
Waiting removals can be withdrawn until removal begins. Running and completed removals cannot be reversed.
{/if}

In flight {active.length}

@@ -113,15 +131,23 @@
{:else}
-

History

+

History

- {#if !loading && history.length === 0} - {:else}
{#each history.slice(0,limit) as d (d.id)}{@render row(d)}{/each}
{/if} - saveContext()} label="operations" /> + {#if historyError}{/if} + {#if historyLoading}

Loading provisioning history...

+ {:else if !historyError && history.length === 0} + {:else}
{#each history as d (d.id)}{@render row(d)}{/each}
{/if} +
{/if}
diff --git a/tests/unit/provisioning-history-performance.test.ts b/tests/unit/provisioning-history-performance.test.ts index e43197b..90516e9 100644 --- a/tests/unit/provisioning-history-performance.test.ts +++ b/tests/unit/provisioning-history-performance.test.ts @@ -70,7 +70,7 @@ describe('provisioning reads with retained lifecycle history', () => { expect(handle.prepare('SELECT message FROM server_deployment_events').all()).toEqual([{ message: 'Creation complete' }]); const indexes = handle.pragma('index_list(server_deployments)') as { name: string }[]; expect(indexes.map(index => index.name)).toEqual(expect.arrayContaining([ - 'idx_server_deployments_server', 'idx_server_deployments_state' + 'idx_server_deployments_server', 'idx_server_deployments_state', 'idx_server_deployments_started_at' ])); expect(handle.pragma('foreign_key_check')).toEqual([]); closeDb(); diff --git a/tests/unit/provisioning-list.test.ts b/tests/unit/provisioning-list.test.ts new file mode 100644 index 0000000..b69501e --- /dev/null +++ b/tests/unit/provisioning-list.test.ts @@ -0,0 +1,119 @@ +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'); + }); +}); diff --git a/tests/unit/provisioning-presentation.test.ts b/tests/unit/provisioning-presentation.test.ts index 5aace95..1e32fe0 100644 --- a/tests/unit/provisioning-presentation.test.ts +++ b/tests/unit/provisioning-presentation.test.ts @@ -146,6 +146,8 @@ describe('provisioning response and return-context boundaries', () => { it('retains list filters without accepting an external or unrelated return destination', () => { expect(provisioningReturn('/provisioning?view=history&action=restart&server=Lobby+1&limit=50&redirect=https://evil.test').href) .toBe('/provisioning?view=history&action=restart&server=Lobby+1&limit=50'); + expect(provisioningReturn('/provisioning?view=history&limit=25&offset=100').href) + .toBe('/provisioning?view=history&limit=25&offset=100'); for (const value of ['https://evil.test', '//evil.test', '/\\evil.test', '/\n/evil.test', '/settings', '/provisioning/other']) { expect(provisioningReturn(value).href).toBe('/provisioning'); } diff --git a/tests/unit/provisioning.test.ts b/tests/unit/provisioning.test.ts index 24d18fc..ad05c18 100644 --- a/tests/unit/provisioning.test.ts +++ b/tests/unit/provisioning.test.ts @@ -236,7 +236,7 @@ describe('provisioning operations', () => { const result = await submit({ server, action: 'start' }); expect(result.status).toBe(202); const { deploymentId } = await result.json(); - expect((await GET().json()).deployments).toContainEqual(expect.objectContaining({ id: deploymentId, action: 'start', state: 'waiting' })); + expect((await GET({ url: new URL('http://localhost/api/provisioning') }).json()).deployments).toContainEqual(expect.objectContaining({ id: deploymentId, action: 'start', state: 'waiting' })); }); it('does not create a replacement when teardown fails or resume verification of the old instance', async () => {