Files

338 lines
13 KiB
TypeScript
Raw Permalink Normal View History

// Snapshot writer integration test (spec §8.2.1).
//
// The applied_plugin_snapshots table has exactly one writer: the
// `snapshots.ts` module. This test exercises createSnapshot →
// linkSnapshotToRun → markSnapshotStale and asserts:
// - Inserted rows expose the AppliedPluginSnapshot shape.
// - Linking to a run pins expires_at = NULL.
// - Marking stale flips status to 'stale'.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import Database from 'better-sqlite3';
import { migratePlugins } from '../src/plugins/persistence.js';
import {
createSnapshot,
getSnapshot,
InvalidAppliedStrategySnapshotError,
linkSnapshotToRun,
linkSnapshotToProject,
markSnapshotStale,
restoreProjectSnapshotLink,
rowToSnapshot,
} from '../src/plugins/snapshots.js';
import { strategyPackageHashFromDigests } from '@open-design/plugin-runtime';
let db: Database.Database;
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), 'od-snapshot-'));
db = new Database(path.join(tmpDir, 'test.sqlite'));
// Minimal projects/conversations tables so the FK ON DELETE clauses
// don't blow up the migration.
db.exec(`
CREATE TABLE projects (id TEXT PRIMARY KEY, name TEXT);
CREATE TABLE conversations (id TEXT PRIMARY KEY, project_id TEXT, title TEXT);
`);
migratePlugins(db);
});
afterEach(async () => {
db.close();
await rm(tmpDir, { recursive: true, force: true });
});
const baseInput = (extra: Partial<Parameters<typeof createSnapshot>[1]> = {}) => ({
projectId: 'project-1',
conversationId: null,
runId: null,
pluginId: 'sample-plugin',
pluginVersion: '1.0.0',
pluginTitle: 'Sample Plugin',
pluginDescription: 'Fixture',
manifestSourceDigest: 'digest-1',
sourceMarketplaceId: null,
pinnedRef: null,
taskKind: 'new-generation' as const,
inputs: { topic: 'design' },
resolvedContext: { items: [] },
pipeline: undefined,
genuiSurfaces: [],
capabilitiesGranted: ['prompt:inject'],
capabilitiesRequired: ['prompt:inject'],
assetsStaged: [],
connectorsRequired: [],
connectorsResolved: [],
mcpServers: [],
query: 'Make a deck',
...extra,
});
function strategyBinding() {
const assetDigests = [
{ path: './SKILL.md', sha256: 'a'.repeat(64) },
{ path: './assets/task-profiles/prototype.md', sha256: 'b'.repeat(64) },
];
return {
schema: 'open-design.applied-strategy/v2' as const,
id: 'od-next-strategy' as const,
version: '2.0.0',
packageHash: strategyPackageHashFromDigests(assetDigests),
assetDigests,
selectedTaskProfile: {
taskType: 'prototype' as const,
version: '2.0.0',
path: './assets/task-profiles/prototype.md',
sha256: 'b'.repeat(64),
},
taskProfileVersions: ['2.0.0'],
promptRecipe: 'od-next-plan-build-v2' as const,
};
}
function strategySnapshotInput(
extra: Partial<Parameters<typeof createSnapshot>[1]> = {},
) {
return baseInput({
pluginId: 'od-next-strategy',
pluginVersion: '2.0.0',
strategy: strategyBinding(),
...extra,
});
}
describe('snapshots writer', () => {
it('adds strategy_json as a nullable reader-first migration column', () => {
const columns = db.prepare('PRAGMA table_info(applied_plugin_snapshots)').all() as Array<{
name: string;
notnull: number;
dflt_value: unknown;
}>;
expect(columns.find((column) => column.name === 'strategy_json')).toEqual(
expect.objectContaining({ notnull: 0, dflt_value: null }),
);
});
it('migrates an existing snapshot row additively before enabling the writer', () => {
const legacy = new Database(':memory:');
try {
legacy.exec(`
CREATE TABLE projects (id TEXT PRIMARY KEY, name TEXT);
CREATE TABLE conversations (id TEXT PRIMARY KEY, project_id TEXT, title TEXT);
CREATE TABLE applied_plugin_snapshots (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
run_id TEXT,
plugin_id TEXT NOT NULL,
plugin_spec_version TEXT NOT NULL DEFAULT '1.0.0',
plugin_version TEXT NOT NULL
);
INSERT INTO applied_plugin_snapshots (
id, project_id, run_id, plugin_id, plugin_version
) VALUES ('legacy-snapshot', 'legacy-project', NULL, 'legacy-plugin', '1.0.0');
`);
migratePlugins(legacy);
const row = legacy.prepare(
'SELECT * FROM applied_plugin_snapshots WHERE id = ?',
).get('legacy-snapshot') as Record<string, unknown>;
expect(row['strategy_json']).toBeNull();
expect(rowToSnapshot(row).strategy).toBeUndefined();
} finally {
legacy.close();
}
});
it('createSnapshot inserts a row and round-trips via getSnapshot', () => {
db.prepare('INSERT INTO projects (id, name) VALUES (?, ?)').run('project-1', 'Project 1');
const snap = createSnapshot(db, baseInput({
sourceMarketplaceId: 'official',
sourceMarketplaceEntryName: 'open-design/sample-plugin',
sourceMarketplaceEntryVersion: '1.0.0',
marketplaceTrust: 'official',
resolvedSource: 'github:open-design/plugins@abc123/sample-plugin',
resolvedRef: 'abc123',
archiveIntegrity: 'sha512-fixture',
}));
expect(snap.snapshotId).toMatch(/^[0-9a-f-]{36}$/);
const fetched = getSnapshot(db, snap.snapshotId);
expect(fetched).not.toBeNull();
expect(fetched!.pluginId).toBe('sample-plugin');
expect(fetched!.manifestSourceDigest).toBe('digest-1');
expect(fetched!.sourceMarketplaceId).toBe('official');
expect(fetched!.sourceMarketplaceEntryName).toBe('open-design/sample-plugin');
expect(fetched!.sourceMarketplaceEntryVersion).toBe('1.0.0');
expect(fetched!.marketplaceTrust).toBe('official');
expect(fetched!.resolvedSource).toBe('github:open-design/plugins@abc123/sample-plugin');
expect(fetched!.resolvedRef).toBe('abc123');
expect(fetched!.archiveIntegrity).toBe('sha512-fixture');
expect(fetched!.status).toBe('fresh');
});
it('persists apply-time craft requirements on the immutable snapshot', () => {
db.prepare('INSERT INTO projects (id, name) VALUES (?, ?)').run('project-1', 'Project 1');
const snap = createSnapshot(db, baseInput({
craftRequires: ['typography', 'color', 'anti-ai-slop'],
}));
const fetched = getSnapshot(db, snap.snapshotId);
expect(fetched?.craftRequires).toEqual(['typography', 'color', 'anti-ai-slop']);
});
it('round-trips a complete strategy binding and writes SQL NULL for ordinary plugins', () => {
db.prepare('INSERT INTO projects (id, name) VALUES (?, ?)').run('project-1', 'Project 1');
const strategy = createSnapshot(db, strategySnapshotInput());
const ordinary = createSnapshot(db, baseInput({ pluginId: 'ordinary-plugin' }));
expect(getSnapshot(db, strategy.snapshotId)?.strategy).toEqual(strategyBinding());
expect(getSnapshot(db, ordinary.snapshotId)?.strategy).toBeUndefined();
const rows = db.prepare(
'SELECT id, strategy_json AS strategyJson FROM applied_plugin_snapshots WHERE id IN (?, ?)',
).all(strategy.snapshotId, ordinary.snapshotId) as Array<{
id: string;
strategyJson: string | null;
}>;
expect(rows.find((row) => row.id === strategy.snapshotId)?.strategyJson).toBeTypeOf('string');
expect(rows.find((row) => row.id === ordinary.snapshotId)?.strategyJson).toBeNull();
});
it('reads a legacy row with no strategy column and preserves null compatibility', () => {
db.prepare('INSERT INTO projects (id, name) VALUES (?, ?)').run('project-1', 'Project 1');
const snapshot = createSnapshot(db, baseInput());
const row = db.prepare('SELECT * FROM applied_plugin_snapshots WHERE id = ?')
.get(snapshot.snapshotId) as Record<string, unknown>;
delete row['strategy_json'];
expect(rowToSnapshot(row).strategy).toBeUndefined();
expect(getSnapshot(db, snapshot.snapshotId)?.strategy).toBeUndefined();
});
it.each(['{invalid', ''])(
'fails closed for invalid strategy JSON %j',
(invalidJson) => {
db.prepare('INSERT INTO projects (id, name) VALUES (?, ?)').run('project-1', 'Project 1');
const snapshot = createSnapshot(db, strategySnapshotInput());
const update = db.prepare(
'UPDATE applied_plugin_snapshots SET strategy_json = ? WHERE id = ?',
).run(invalidJson, snapshot.snapshotId);
expect(update.changes).toBe(1);
expect(() => getSnapshot(db, snapshot.snapshotId)).toThrow(
InvalidAppliedStrategySnapshotError,
);
},
);
it('fails closed for a strategy package hash mismatch', () => {
db.prepare('INSERT INTO projects (id, name) VALUES (?, ?)').run('project-1', 'Project 1');
const snapshot = createSnapshot(db, strategySnapshotInput());
const mismatched = { ...strategyBinding(), packageHash: 'f'.repeat(64) };
db.prepare('UPDATE applied_plugin_snapshots SET strategy_json = ? WHERE id = ?')
.run(JSON.stringify(mismatched), snapshot.snapshotId);
expect(() => getSnapshot(db, snapshot.snapshotId)).toThrow(/does not match/i);
});
it('rejects an invalid binding before the single writer inserts a row', () => {
db.prepare('INSERT INTO projects (id, name) VALUES (?, ?)').run('project-1', 'Project 1');
const invalid = { ...strategyBinding(), packageHash: 'f'.repeat(64) };
const before = db.prepare('SELECT COUNT(*) AS count FROM applied_plugin_snapshots')
.get() as { count: number };
expect(() => createSnapshot(db, strategySnapshotInput({ strategy: invalid }))).toThrow(
/does not match/i,
);
const after = db.prepare('SELECT COUNT(*) AS count FROM applied_plugin_snapshots')
.get() as { count: number };
expect(after.count).toBe(before.count);
});
it.each([
['plugin id', { pluginId: 'ordinary-plugin' }],
['plugin version', { pluginVersion: '9.9.9' }],
])('rejects a strategy whose %s does not match before inserting a row', (_label, mismatch) => {
db.prepare('INSERT INTO projects (id, name) VALUES (?, ?)').run('project-1', 'Project 1');
const before = db.prepare('SELECT COUNT(*) AS count FROM applied_plugin_snapshots')
.get() as { count: number };
expect(() => createSnapshot(db, strategySnapshotInput(mismatch))).toThrow(
/snapshot plugin identity/i,
);
const after = db.prepare('SELECT COUNT(*) AS count FROM applied_plugin_snapshots')
.get() as { count: number };
expect(after.count).toBe(before.count);
});
it.each([
['plugin id', 'plugin_id', 'ordinary-plugin'],
['plugin version', 'plugin_version', '9.9.9'],
])('fails closed when a persisted strategy row tampers with its %s', (_label, column, value) => {
db.prepare('INSERT INTO projects (id, name) VALUES (?, ?)').run('project-1', 'Project 1');
const snapshot = createSnapshot(db, strategySnapshotInput());
const update = db.prepare(
`UPDATE applied_plugin_snapshots SET ${column} = ? WHERE id = ?`,
).run(value, snapshot.snapshotId);
expect(update.changes).toBe(1);
expect(() => getSnapshot(db, snapshot.snapshotId)).toThrow(
/snapshot plugin identity/i,
);
});
it('linkSnapshotToRun pins expires_at to NULL', () => {
db.prepare('INSERT INTO projects (id, name) VALUES (?, ?)').run('project-1', 'Project 1');
const snap = createSnapshot(db, baseInput());
const beforeRow = db.prepare('SELECT expires_at FROM applied_plugin_snapshots WHERE id = ?').get(snap.snapshotId) as { expires_at: number | null };
// Either null (TTL=0) or a numeric expiry — we care that linking nulls it.
void beforeRow;
linkSnapshotToRun(db, snap.snapshotId, 'run-1');
const after = db.prepare('SELECT run_id, expires_at FROM applied_plugin_snapshots WHERE id = ?').get(snap.snapshotId) as { run_id: string; expires_at: number | null };
expect(after.run_id).toBe('run-1');
expect(after.expires_at).toBeNull();
});
it('restoreProjectSnapshotLink makes an unlinked discarded snapshot expirable again', () => {
db.prepare('INSERT INTO projects (id, name) VALUES (?, ?)').run('project-1', 'Project 1');
const previous = createSnapshot(db, baseInput({ query: 'Previous {{topic}}' }));
linkSnapshotToProject(db, previous.snapshotId, 'project-1');
const discarded = createSnapshot(db, baseInput({ query: 'Discarded {{topic}}' }));
linkSnapshotToProject(db, discarded.snapshotId, 'project-1');
restoreProjectSnapshotLink(
db,
'project-1',
discarded.snapshotId,
previous.snapshotId,
'run-that-was-never-linked',
);
const project = db.prepare(
`SELECT applied_plugin_snapshot_id AS appliedPluginSnapshotId
FROM projects
WHERE id = ?`,
).get('project-1') as { appliedPluginSnapshotId: string | null };
const discardedRow = db.prepare(
`SELECT run_id AS runId, expires_at AS expiresAt
FROM applied_plugin_snapshots
WHERE id = ?`,
).get(discarded.snapshotId) as { runId: string | null; expiresAt: number | null };
expect(project.appliedPluginSnapshotId).toBe(previous.snapshotId);
expect(discardedRow.runId).toBeNull();
expect(discardedRow.expiresAt).not.toBeNull();
});
it('markSnapshotStale flips status', () => {
db.prepare('INSERT INTO projects (id, name) VALUES (?, ?)').run('project-1', 'Project 1');
const snap = createSnapshot(db, baseInput());
markSnapshotStale(db, snap.snapshotId);
const fetched = getSnapshot(db, snap.snapshotId);
expect(fetched!.status).toBe('stale');
});
});