fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
408 lines
15 KiB
TypeScript
408 lines
15 KiB
TypeScript
// Tier-B · B15 artifact sidebar (todos / notes / memory) + legacy fallback.
|
|
/**
|
|
* The UI's artifact sidebar (`frontend/src/components/ArtifactSidebar.tsx`)
|
|
* opens on a question that triggered a tool call that wrote a `note` or a
|
|
* `todo_list` and reads from `GET /api/artifact/<artifact_id>` (see
|
|
* `application/api/user/tools/routes.py` — `GetArtifact`). The route is
|
|
* also exercised on reload of an older conversation whose stored artifact id
|
|
* is a legacy Mongo ObjectId — the todos repository has a `get_by_legacy_id`
|
|
* fallback explicitly for that case.
|
|
*
|
|
* Rather than drive a full tool end-to-end (which pulls in Celery-scheduled
|
|
* tool execution), we insert fixture rows into `notes` / `todos` / `memories`
|
|
* directly — the shape is locked by alembic 0001_initial and the route reads
|
|
* straight off those tables via the repository layer.
|
|
*
|
|
* Covered:
|
|
* 1. notes: `GET /api/artifact/<note-uuid>` returns artifact_type=note
|
|
* with the note content and line_count.
|
|
* 2. todos: `GET /api/artifact/<todo-uuid>` returns artifact_type=todo_list
|
|
* with all todos in the owning user_tool and open/completed counts.
|
|
* 3. legacy fallback: a todo row inserted with `legacy_mongo_id` set to an
|
|
* ObjectId-shaped hex string resolves via `GET /api/artifact/<legacy-id>`.
|
|
* 4. cross-tenant: user B's request for user A's note 404s (repositories
|
|
* are user-scoped, and the legacy fallback additionally re-checks the
|
|
* owning user_id before returning).
|
|
* 5. cascade delete: dropping the owning `user_tools` row cascades the
|
|
* artifact rows (notes/todos FK ON DELETE CASCADE). Asserted via SQL.
|
|
* 6. memories: route does not (yet) serve memory artifacts — asserted
|
|
* here as a 404 so a future route extension flips this test loudly.
|
|
*/
|
|
|
|
import * as playwright from '@playwright/test';
|
|
const { expect, test } = playwright;
|
|
|
|
import { authedRequest } from '../../helpers/api.js';
|
|
import { newUserContext } from '../../helpers/auth.js';
|
|
import { countRows, pg } from '../../helpers/db.js';
|
|
import { resetDb } from '../../helpers/reset.js';
|
|
|
|
/**
|
|
* Insert a minimal `user_tools` row owned by `userId`. The artifact tables
|
|
* (`notes`, `todos`, `memories`) all `REFERENCES user_tools(id) ON DELETE CASCADE`
|
|
* so every fixture artifact needs a parent tool.
|
|
*/
|
|
async function insertUserTool(userId: string, customName: string): Promise<string> {
|
|
const { rows } = await pg.query<{ id: string }>(
|
|
`INSERT INTO user_tools (user_id, name, custom_name, display_name, description)
|
|
VALUES ($1, 'notes', $2, 'Notes (fixture)', 'e2e fixture')
|
|
RETURNING id::text AS id`,
|
|
[userId, customName],
|
|
);
|
|
const id = rows[0]?.id;
|
|
if (!id) throw new Error('insertUserTool: no id returned');
|
|
return id;
|
|
}
|
|
|
|
async function insertNote(
|
|
userId: string,
|
|
toolId: string,
|
|
title: string,
|
|
content: string,
|
|
): Promise<string> {
|
|
const { rows } = await pg.query<{ id: string }>(
|
|
`INSERT INTO notes (user_id, tool_id, title, content)
|
|
VALUES ($1, CAST($2 AS uuid), $3, $4)
|
|
RETURNING id::text AS id`,
|
|
[userId, toolId, title, content],
|
|
);
|
|
const id = rows[0]?.id;
|
|
if (!id) throw new Error('insertNote: no id returned');
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* Insert a todo row. `todoId` is the per-tool monotonic integer the LLM uses
|
|
* as its handle (see `todos_tool_todo_id_uidx`). `legacyMongoId` is optional
|
|
* — set it to exercise the `get_by_legacy_id` fallback path.
|
|
*/
|
|
async function insertTodo(
|
|
userId: string,
|
|
toolId: string,
|
|
todoId: number,
|
|
title: string,
|
|
opts: { completed?: boolean; legacyMongoId?: string | null } = {},
|
|
): Promise<string> {
|
|
const { rows } = await pg.query<{ id: string }>(
|
|
`INSERT INTO todos (user_id, tool_id, todo_id, title, completed, legacy_mongo_id)
|
|
VALUES ($1, CAST($2 AS uuid), $3, $4, $5, $6)
|
|
RETURNING id::text AS id`,
|
|
[
|
|
userId,
|
|
toolId,
|
|
todoId,
|
|
title,
|
|
opts.completed ?? false,
|
|
opts.legacyMongoId ?? null,
|
|
],
|
|
);
|
|
const id = rows[0]?.id;
|
|
if (!id) throw new Error('insertTodo: no id returned');
|
|
return id;
|
|
}
|
|
|
|
async function insertMemory(
|
|
userId: string,
|
|
toolId: string,
|
|
path: string,
|
|
content: string,
|
|
): Promise<string> {
|
|
const { rows } = await pg.query<{ id: string }>(
|
|
`INSERT INTO memories (user_id, tool_id, path, content)
|
|
VALUES ($1, CAST($2 AS uuid), $3, $4)
|
|
RETURNING id::text AS id`,
|
|
[userId, toolId, path, content],
|
|
);
|
|
const id = rows[0]?.id;
|
|
if (!id) throw new Error('insertMemory: no id returned');
|
|
return id;
|
|
}
|
|
|
|
test.describe('tier-b · artifacts (todo/note sidebar)', () => {
|
|
test.beforeEach(async () => {
|
|
await resetDb();
|
|
});
|
|
|
|
test('GET /api/artifact/<note-uuid> returns a shaped note artifact', async ({
|
|
browser,
|
|
}) => {
|
|
const { context, sub, token } = await newUserContext(browser);
|
|
const api = await authedRequest(playwright, token);
|
|
try {
|
|
const toolId = await insertUserTool(sub, 'notes-fixture');
|
|
const content = 'line one\nline two\nline three';
|
|
const noteId = await insertNote(sub, toolId, 'Meeting notes', content);
|
|
|
|
const res = await api.get(`/api/artifact/${noteId}`);
|
|
expect(res.status()).toBe(200);
|
|
const body = (await res.json()) as {
|
|
success: boolean;
|
|
artifact: {
|
|
artifact_type: string;
|
|
data: { content: string; line_count: number; updated_at?: string };
|
|
};
|
|
};
|
|
expect(body.success).toBe(true);
|
|
expect(body.artifact.artifact_type).toBe('note');
|
|
expect(body.artifact.data.content).toBe(content);
|
|
expect(body.artifact.data.line_count).toBe(3);
|
|
// updated_at is NOT NULL (DB default now()); it should come back as an
|
|
// ISO-ish string and not `null`.
|
|
expect(body.artifact.data.updated_at ?? '').not.toBe('');
|
|
} finally {
|
|
await api.dispose();
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
test('GET /api/artifact/<todo-uuid> returns todo_list with all sibling todos', async ({
|
|
browser,
|
|
}) => {
|
|
const { context, sub, token } = await newUserContext(browser);
|
|
const api = await authedRequest(playwright, token);
|
|
try {
|
|
const toolId = await insertUserTool(sub, 'todos-fixture');
|
|
// Three siblings: two open, one completed. The requested todo is #2,
|
|
// but the artifact response returns the whole list.
|
|
const aId = await insertTodo(sub, toolId, 1, 'write tests');
|
|
void aId;
|
|
const bId = await insertTodo(sub, toolId, 2, 'ship feature');
|
|
const cId = await insertTodo(sub, toolId, 3, 'celebrate', {
|
|
completed: true,
|
|
});
|
|
void cId;
|
|
|
|
const res = await api.get(`/api/artifact/${bId}`);
|
|
expect(res.status()).toBe(200);
|
|
const body = (await res.json()) as {
|
|
success: boolean;
|
|
artifact: {
|
|
artifact_type: string;
|
|
data: {
|
|
items: Array<{ todo_id: number; title: string; status: string }>;
|
|
total_count: number;
|
|
open_count: number;
|
|
completed_count: number;
|
|
};
|
|
};
|
|
};
|
|
expect(body.success).toBe(true);
|
|
expect(body.artifact.artifact_type).toBe('todo_list');
|
|
expect(body.artifact.data.total_count).toBe(3);
|
|
expect(body.artifact.data.open_count).toBe(2);
|
|
expect(body.artifact.data.completed_count).toBe(1);
|
|
|
|
// The route sorts by todo_id (nulls last, then created_at) per
|
|
// TodosRepository.list_for_tool — assert that ordering.
|
|
const titles = body.artifact.data.items.map((t) => t.title);
|
|
expect(titles).toEqual(['write tests', 'ship feature', 'celebrate']);
|
|
const statuses = body.artifact.data.items.map((t) => t.status);
|
|
expect(statuses).toEqual(['open', 'open', 'completed']);
|
|
} finally {
|
|
await api.dispose();
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
test('legacy fallback: UUID-shaped legacy_mongo_id resolves via get_by_legacy_id', async ({
|
|
browser,
|
|
}) => {
|
|
const { context, sub, token } = await newUserContext(browser);
|
|
const api = await authedRequest(playwright, token);
|
|
try {
|
|
const toolId = await insertUserTool(sub, 'legacy-todo-fixture');
|
|
// Using a UUID-shaped string as the stored `legacy_mongo_id` to
|
|
// exercise the get_by_legacy_id branch. The ObjectId-shaped case
|
|
// (24 hex chars) is covered by the separately-fixmed test below
|
|
// which documents the known backend bug.
|
|
const legacyId = 'aaaabbbb-cccc-dddd-eeee-ffff00001111';
|
|
const pgUuid = await insertTodo(sub, toolId, 1, 'legacy-referenced', {
|
|
legacyMongoId: legacyId,
|
|
});
|
|
|
|
// Requesting by the legacy id should resolve to the same row via the
|
|
// repo's `get_by_legacy_id` fallback. The preceding `notes.get` +
|
|
// `todos.get` both miss cleanly (UUID cast is valid, just no row)
|
|
// so the SQLAlchemy transaction stays healthy into the fallback.
|
|
const res = await api.get(`/api/artifact/${legacyId}`);
|
|
expect(res.status()).toBe(200);
|
|
const body = (await res.json()) as {
|
|
success: boolean;
|
|
artifact: {
|
|
artifact_type: string;
|
|
data: { items: Array<{ todo_id: number; title: string }> };
|
|
};
|
|
};
|
|
expect(body.success).toBe(true);
|
|
expect(body.artifact.artifact_type).toBe('todo_list');
|
|
expect(body.artifact.data.items).toHaveLength(1);
|
|
expect(body.artifact.data.items[0].title).toBe('legacy-referenced');
|
|
|
|
// And the canonical PG id still works too — fallback doesn't mask it.
|
|
const pgRes = await api.get(`/api/artifact/${pgUuid}`);
|
|
expect(pgRes.status()).toBe(200);
|
|
} finally {
|
|
await api.dispose();
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
test(
|
|
'legacy fallback: non-UUID ObjectId-shaped legacy_mongo_id resolves via get_by_legacy_id',
|
|
async ({ browser }) => {
|
|
const { context, sub, token } = await newUserContext(browser);
|
|
const api = await authedRequest(playwright, token);
|
|
try {
|
|
const toolId = await insertUserTool(sub, 'legacy-objectid-fixture');
|
|
const legacyId = '6123456789abcdef01234567';
|
|
await insertTodo(sub, toolId, 1, 'legacy-objectid', {
|
|
legacyMongoId: legacyId,
|
|
});
|
|
const res = await api.get(`/api/artifact/${legacyId}`);
|
|
expect(res.status()).toBe(200);
|
|
} finally {
|
|
await api.dispose();
|
|
await context.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
test('cross-tenant: user B cannot fetch user A artifact (note → 404)', async ({
|
|
browser,
|
|
}) => {
|
|
const userA = await newUserContext(browser, { sub: 'e2e-artifact-user-a' });
|
|
const userB = await newUserContext(browser, { sub: 'e2e-artifact-user-b' });
|
|
const apiA = await authedRequest(playwright, userA.token);
|
|
const apiB = await authedRequest(playwright, userB.token);
|
|
try {
|
|
const toolA = await insertUserTool(userA.sub, 'a-notes');
|
|
const noteA = await insertNote(
|
|
userA.sub,
|
|
toolA,
|
|
'A private',
|
|
'secret stuff',
|
|
);
|
|
|
|
// User A gets a 200.
|
|
const resA = await apiA.get(`/api/artifact/${noteA}`);
|
|
expect(resA.status()).toBe(200);
|
|
|
|
// User B must NOT see it. The repo's `.get(id, user_id)` filters on
|
|
// user_id, so the lookup misses and the route returns 404.
|
|
const resB = await apiB.get(`/api/artifact/${noteA}`);
|
|
expect(resB.status()).toBe(404);
|
|
const body = (await resB.json()) as { success: boolean };
|
|
expect(body.success).toBe(false);
|
|
} finally {
|
|
await apiA.dispose();
|
|
await apiB.dispose();
|
|
await userA.context.close();
|
|
await userB.context.close();
|
|
}
|
|
});
|
|
|
|
test('cascade: deleting the owning user_tools row removes every artifact for that tool', async ({
|
|
browser,
|
|
}) => {
|
|
const { context, sub, token } = await newUserContext(browser);
|
|
const api = await authedRequest(playwright, token);
|
|
try {
|
|
const toolId = await insertUserTool(sub, 'cascade-fixture');
|
|
const noteId = await insertNote(sub, toolId, 'Note', 'hi');
|
|
const todoId = await insertTodo(sub, toolId, 1, 'Todo A');
|
|
const memoryId = await insertMemory(sub, toolId, '/root/a', 'mem body');
|
|
|
|
// Precondition: all rows exist and the route serves the note/todo.
|
|
expect(
|
|
await countRows('notes', {
|
|
sql: 'tool_id = CAST($1 AS uuid)',
|
|
params: [toolId],
|
|
}),
|
|
).toBe(1);
|
|
expect(
|
|
await countRows('todos', {
|
|
sql: 'tool_id = CAST($1 AS uuid)',
|
|
params: [toolId],
|
|
}),
|
|
).toBe(1);
|
|
expect(
|
|
await countRows('memories', {
|
|
sql: 'tool_id = CAST($1 AS uuid)',
|
|
params: [toolId],
|
|
}),
|
|
).toBe(1);
|
|
const precheck = await api.get(`/api/artifact/${noteId}`);
|
|
expect(precheck.status()).toBe(200);
|
|
|
|
// Nuke the parent. ON DELETE CASCADE must reap children.
|
|
const del = await pg.query<{ id: string }>(
|
|
'DELETE FROM user_tools WHERE id = CAST($1 AS uuid) RETURNING id::text AS id',
|
|
[toolId],
|
|
);
|
|
expect(del.rows).toHaveLength(1);
|
|
|
|
// All child artifact rows gone.
|
|
expect(
|
|
await countRows('notes', {
|
|
sql: 'tool_id = CAST($1 AS uuid)',
|
|
params: [toolId],
|
|
}),
|
|
).toBe(0);
|
|
expect(
|
|
await countRows('todos', {
|
|
sql: 'tool_id = CAST($1 AS uuid)',
|
|
params: [toolId],
|
|
}),
|
|
).toBe(0);
|
|
expect(
|
|
await countRows('memories', {
|
|
sql: 'tool_id = CAST($1 AS uuid)',
|
|
params: [toolId],
|
|
}),
|
|
).toBe(0);
|
|
// Also verify by PK lookup — in case some future patch widens the
|
|
// tool_id index and the tool_id filter stops matching.
|
|
expect(
|
|
await countRows('notes', { sql: 'id = CAST($1 AS uuid)', params: [noteId] }),
|
|
).toBe(0);
|
|
expect(
|
|
await countRows('todos', { sql: 'id = CAST($1 AS uuid)', params: [todoId] }),
|
|
).toBe(0);
|
|
expect(
|
|
await countRows('memories', {
|
|
sql: 'id = CAST($1 AS uuid)',
|
|
params: [memoryId],
|
|
}),
|
|
).toBe(0);
|
|
|
|
// The route now 404s — artifact doesn't exist post-cascade.
|
|
const postcheck = await api.get(`/api/artifact/${noteId}`);
|
|
expect(postcheck.status()).toBe(404);
|
|
} finally {
|
|
await api.dispose();
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
test('memory artifact ids are not served by /api/artifact/ (route only handles note/todo)', async ({
|
|
browser,
|
|
}) => {
|
|
// The route in routes.py returns 404 for anything that isn't a note or
|
|
// todo — memories don't have an artifact presenter yet. Pin this today so
|
|
// if/when the route starts serving memory artifacts, this test flips RED
|
|
// and a maintainer has to revisit the frontend sidebar contract.
|
|
const { context, sub, token } = await newUserContext(browser);
|
|
const api = await authedRequest(playwright, token);
|
|
try {
|
|
const toolId = await insertUserTool(sub, 'memory-fixture');
|
|
const memoryId = await insertMemory(sub, toolId, '/remember/this', 'mem');
|
|
|
|
const res = await api.get(`/api/artifact/${memoryId}`);
|
|
expect(res.status()).toBe(404);
|
|
} finally {
|
|
await api.dispose();
|
|
await context.close();
|
|
}
|
|
});
|
|
});
|