Three security-review findings on migration 002: - pages.space_id and refs.space_id changed to NOT NULL + ON DELETE CASCADE (was nullable + SET NULL, which allowed orphan rows surviving space deletion). - pages.parent_id wrapped in composite FK (parent_id, space_id) -> pages(id, space_id) to prevent cross-space parent linkage (same pattern as tasks.project_id in 001). - idx_refs_external promoted to UNIQUE on (space_id, source_kind, external_id); upsertByExternal now requires space_id and dedups per-space, not globally. Added 3 regression tests covering composite FK rejection, CASCADE-on-space-delete, and per-space dedup independence.
85 lines
3.3 KiB
JavaScript
85 lines
3.3 KiB
JavaScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { resetDb, withClient } from '../helpers/db.js';
|
|
import { migrateUp } from '../../lib/db/migrate.js';
|
|
|
|
describe('migration 002 — knowledge', () => {
|
|
beforeEach(async () => { await resetDb(); await migrateUp(); });
|
|
|
|
it('creates pages, page_revisions, refs', async () => {
|
|
await withClient(async (c) => {
|
|
for (const t of ['pages','page_revisions','refs']) {
|
|
const { rows } = await c.query(
|
|
`SELECT to_regclass('public.' || $1) AS t;`, [t]
|
|
);
|
|
expect(rows[0].t).toBe(t);
|
|
}
|
|
});
|
|
});
|
|
|
|
it('refs.kind check enforces enum', async () => {
|
|
await withClient(async (c) => {
|
|
const { rows: [s] } = await c.query(
|
|
`INSERT INTO spaces(slug,name) VALUES('h','H') RETURNING id;`
|
|
);
|
|
await expect(c.query(
|
|
`INSERT INTO refs(space_id, kind) VALUES($1, 'invalid');`, [s.id]
|
|
)).rejects.toThrow(/check/i);
|
|
});
|
|
});
|
|
|
|
it('pages.parent_id cannot cross spaces (composite FK)', async () => {
|
|
await withClient(async (c) => {
|
|
const { rows: [s1] } = await c.query(
|
|
`INSERT INTO spaces(slug,name) VALUES('a','A') RETURNING id;`);
|
|
const { rows: [s2] } = await c.query(
|
|
`INSERT INTO spaces(slug,name) VALUES('b','B') RETURNING id;`);
|
|
const { rows: [parent] } = await c.query(
|
|
`INSERT INTO pages(space_id, slug, title) VALUES($1,'p','P') RETURNING id;`,
|
|
[s1.id]
|
|
);
|
|
await expect(c.query(
|
|
`INSERT INTO pages(space_id, parent_id, slug, title) VALUES($1,$2,'c','C');`,
|
|
[s2.id, parent.id]
|
|
)).rejects.toThrow(/foreign key/i);
|
|
});
|
|
});
|
|
|
|
it('deleting a space cascades pages and refs', async () => {
|
|
await withClient(async (c) => {
|
|
const { rows: [s] } = await c.query(
|
|
`INSERT INTO spaces(slug,name) VALUES('s','S') RETURNING id;`);
|
|
await c.query(
|
|
`INSERT INTO pages(space_id, slug, title) VALUES($1,'p','P');`, [s.id]);
|
|
await c.query(
|
|
`INSERT INTO refs(space_id, kind) VALUES($1, 'url');`, [s.id]);
|
|
await c.query(`DELETE FROM spaces WHERE id=$1;`, [s.id]);
|
|
const { rows: [{ n: pn }] } = await c.query(`SELECT count(*)::int n FROM pages`);
|
|
const { rows: [{ n: rn }] } = await c.query(`SELECT count(*)::int n FROM refs`);
|
|
expect(pn).toBe(0);
|
|
expect(rn).toBe(0);
|
|
});
|
|
});
|
|
|
|
it('refs external dedup is per-space (cross-space same external_id is distinct)', async () => {
|
|
await withClient(async (c) => {
|
|
const { rows: [s1] } = await c.query(
|
|
`INSERT INTO spaces(slug,name) VALUES('a','A') RETURNING id;`);
|
|
const { rows: [s2] } = await c.query(
|
|
`INSERT INTO spaces(slug,name) VALUES('b','B') RETURNING id;`);
|
|
const { rows: [r1] } = await c.query(
|
|
`INSERT INTO refs(space_id, kind, source_kind, external_id) VALUES($1,'url','karakeep','kk-1') RETURNING id;`,
|
|
[s1.id]
|
|
);
|
|
const { rows: [r2] } = await c.query(
|
|
`INSERT INTO refs(space_id, kind, source_kind, external_id) VALUES($1,'url','karakeep','kk-1') RETURNING id;`,
|
|
[s2.id]
|
|
);
|
|
expect(r1.id).not.toBe(r2.id);
|
|
await expect(c.query(
|
|
`INSERT INTO refs(space_id, kind, source_kind, external_id) VALUES($1,'url','karakeep','kk-1');`,
|
|
[s1.id]
|
|
)).rejects.toThrow(/unique|duplicate/i);
|
|
});
|
|
});
|
|
});
|