Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
148 lines
6.5 KiB
JavaScript
148 lines
6.5 KiB
JavaScript
// #3 fix — POST /projects/:projectId/documents must be registry-aware.
|
|
//
|
|
// A documents widget bound to a CUSTOM registry (e.g. the public space's
|
|
// _registry, whose folder_path doesn't match the project default) passes
|
|
// `registry_table_id` explicitly. The create handler must then:
|
|
// 1. resolve THAT registry row directly (not project_id + folder_path LIKE),
|
|
// 2. authorize against the registry's OWNER project, not the URL :projectId,
|
|
// 3. insert the document_content table under that owner project + folder.
|
|
//
|
|
// We mock _helpers.js so no live DB is needed — the assertions target the
|
|
// branch logic that is the actual bug fix (read/write registry asymmetry).
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import express from 'express';
|
|
import { createServer } from 'node:http';
|
|
|
|
const URL_PROJECT_ID = '11'; // project in the URL
|
|
const REGISTRY_OWNER_PROJECT = 999; // project that actually owns the custom registry
|
|
const CUSTOM_REGISTRY_ID = 2365; // public-space _registry
|
|
const REGISTRY_FOLDER = 'databases/public-docs/';
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
dbGet: vi.fn(),
|
|
dbRun: vi.fn(),
|
|
requireEditorAccess: vi.fn(),
|
|
createTableColumns: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../_helpers.js', () => ({
|
|
dbAll: vi.fn(async () => []),
|
|
dbGet: mocks.dbGet,
|
|
dbRun: mocks.dbRun,
|
|
isPostgres: () => true,
|
|
safeJsonParse: (s, d) => { try { return JSON.parse(s); } catch { return d; } },
|
|
generateBaseId: () => 'base_test',
|
|
apiLogger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
success: (res, data) => res.status(200).json({ success: true, data }),
|
|
created: (res, data) => res.status(201).json({ success: true, data }),
|
|
error: (res, code, message, status = 500, extra) =>
|
|
res.status(status).json({ error: { code, message, ...extra } }),
|
|
badRequest: (res, message) => res.status(400).json({ error: { code: 'BAD_REQUEST', message } }),
|
|
notFound: (res, message) => res.status(404).json({ error: { code: 'NOT_FOUND', message } }),
|
|
requireEditorAccess: mocks.requireEditorAccess,
|
|
slugify: (s) => String(s).toLowerCase().replace(/\s+/g, '-'),
|
|
createTableColumns: mocks.createTableColumns,
|
|
REGISTRY_COLUMNS: [], ATOMS_COLUMNS: [], DOCUMENT_TABLE_COLUMNS: [],
|
|
}));
|
|
|
|
const { default: crudRouter } = await import('../crud.js');
|
|
|
|
function startApp() {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => { req.user = { id: 1 }; next(); }); // stand-in for authenticate
|
|
app.use('/api/v3', crudRouter);
|
|
return new Promise((resolve) => {
|
|
const server = createServer(app).listen(0, '127.0.0.1', () => {
|
|
resolve({ server, baseUrl: `http://127.0.0.1:${server.address().port}` });
|
|
});
|
|
});
|
|
}
|
|
|
|
describe('POST /projects/:projectId/documents — registry-aware create (#3)', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mocks.requireEditorAccess.mockResolvedValue(true);
|
|
mocks.createTableColumns.mockResolvedValue(undefined);
|
|
// dbRun: registry-row insert → id 5001; doc_content table insert → id 7001; updates → noop
|
|
mocks.dbRun.mockImplementation(async (sql) => {
|
|
if (/INSERT INTO table_rows/i.test(sql)) return { lastInsertRowid: 5001 };
|
|
if (/INSERT INTO universal_tables/i.test(sql)) return { lastInsertRowid: 7001 };
|
|
return {};
|
|
});
|
|
});
|
|
|
|
it('resolves the explicit registry and authorizes against its owner project', async () => {
|
|
mocks.dbGet.mockImplementation(async (sql) => {
|
|
// explicit registry lookup by id
|
|
if (/project_id, folder_path FROM universal_tables WHERE id = \? AND name = '_registry'/i.test(sql))
|
|
return { id: CUSTOM_REGISTRY_ID, project_id: REGISTRY_OWNER_PROJECT, folder_path: REGISTRY_FOLDER };
|
|
if (/name = '_atoms'/i.test(sql)) return { id: 4242 };
|
|
return null; // no existing slug, no status col, no bdd widget
|
|
});
|
|
|
|
const ctx = await startApp();
|
|
try {
|
|
const res = await fetch(`${ctx.baseUrl}/api/v3/projects/${URL_PROJECT_ID}/documents`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ name: 'Public Door', registry_table_id: CUSTOM_REGISTRY_ID }),
|
|
});
|
|
expect(res.status).toBe(201);
|
|
const body = await res.json();
|
|
expect(body.data.document_id).toBe(5001);
|
|
|
|
// authorized against the registry owner (999), NOT the URL project (11)
|
|
expect(mocks.requireEditorAccess).toHaveBeenCalledTimes(1);
|
|
const authProjectArg = mocks.requireEditorAccess.mock.calls[0][2];
|
|
expect(authProjectArg).toBe(REGISTRY_OWNER_PROJECT);
|
|
expect(String(authProjectArg)).not.toBe(URL_PROJECT_ID);
|
|
|
|
// the registry row + doc_content table were inserted under the owner project + its folder
|
|
const docTableInsert = mocks.dbRun.mock.calls.find(([sql]) => /document_content/i.test(sql));
|
|
expect(docTableInsert).toBeDefined();
|
|
const params = docTableInsert[1];
|
|
expect(params[0]).toBe(REGISTRY_OWNER_PROJECT); // project_id
|
|
expect(params).toContain(REGISTRY_FOLDER); // folder_path from registry row
|
|
} finally {
|
|
await new Promise((r) => ctx.server.close(r));
|
|
}
|
|
});
|
|
|
|
it('400s when registry_table_id does not resolve to a registry', async () => {
|
|
mocks.dbGet.mockResolvedValue(null); // nothing resolves
|
|
const ctx = await startApp();
|
|
try {
|
|
const res = await fetch(`${ctx.baseUrl}/api/v3/projects/${URL_PROJECT_ID}/documents`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ name: 'Orphan', registry_table_id: 123456 }),
|
|
});
|
|
expect(res.status).toBe(400);
|
|
expect(mocks.requireEditorAccess).not.toHaveBeenCalled(); // rejected before auth/writes
|
|
} finally {
|
|
await new Promise((r) => ctx.server.close(r));
|
|
}
|
|
});
|
|
|
|
it('falls back to project_id + folder_path resolution when registry_table_id is absent', async () => {
|
|
mocks.dbGet.mockImplementation(async (sql) => {
|
|
if (/name = '_registry'/i.test(sql)) return { id: 111 };
|
|
if (/name = '_atoms'/i.test(sql)) return { id: 222 };
|
|
return null;
|
|
});
|
|
const ctx = await startApp();
|
|
try {
|
|
const res = await fetch(`${ctx.baseUrl}/api/v3/projects/${URL_PROJECT_ID}/documents`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ name: 'Legacy Doc' }),
|
|
});
|
|
expect(res.status).toBe(201);
|
|
// legacy path authorizes against the URL project
|
|
expect(mocks.requireEditorAccess.mock.calls[0][2]).toBe(URL_PROJECT_ID);
|
|
} finally {
|
|
await new Promise((r) => ctx.server.close(r));
|
|
}
|
|
});
|
|
});
|