Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

524 lines
21 KiB
TypeScript

/**
* P2-14 · Attachment upload bound to conversation message.
*
* Writes to the `attachments` table (via `/api/store_attachment` → Celery
* worker `attachment_worker` → `AttachmentsRepository.create`) and, via the
* subsequent `/stream` call, to `conversation_messages.attachments[]`
* (UUID[]).
*
* The storage path for a post-cutover attachment is subtle: the route mints a
* fresh UUID and hands it to the worker as `attachment_id`; the worker then
* creates a PG row whose **id** is a newly-minted `gen_random_uuid()` and
* stashes the caller-visible UUID in `legacy_mongo_id`. Callers later reference
* that caller-visible UUID via the `attachments` list on `/stream`; the server
* resolves it to the canonical PG `id` (`conversations.ConversationsRepository
* ._resolve_attachment_refs`) before writing the array column. So
* `conversation_messages.attachments[]` holds PG PKs, not caller-visible ids.
*
* // NOTE on cross-tenant: `_resolve_attachment_refs` on the write path does
* NOT scope by user, so a cross-user legacy UUID reference lands in the
* persisted `conversation_messages.attachments[]` array. The safety net
* is read-side — `AttachmentsRepository.get_any` is user-scoped, so the
* LLM context and the `/api/get_single_conversation` rehydration both
* deny access to the other user's file. The cross-tenant test below
* asserts the read-side boundary, not the write-side array contents.
*
* // Silent-break covered: cleanup_message_attachment_refs trigger strips
* deleted UUIDs from every `conversation_messages.attachments[]` on
* `AFTER DELETE ON attachments`. If the trigger is broken, stale UUIDs linger
* in message arrays and the UI renders phantom chips on conversation reload.
*
* The UI's file picker is intentionally avoided (Playwright's `setInputFiles`
* flow is flakier here than a raw multipart POST, and the P2 brief explicitly
* permits API-direct when the upload surface is Celery-dispatched). DB
* assertions on BOTH `attachments` and `conversation_messages.attachments[]`
* are non-negotiable per the migration invariant.
*/
import { readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as playwright from '@playwright/test';
const { expect, test } = playwright;
import type { APIRequestContext } from '@playwright/test';
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';
const HERE = dirname(fileURLToPath(import.meta.url));
const FIXTURES_DIR = resolve(HERE, '..', '..', 'fixtures', 'docs');
const SMALL_FIXTURE_PATH = resolve(FIXTURES_DIR, 'notes.txt');
const CORRUPT_FIXTURE_PATH = resolve(FIXTURES_DIR, 'corrupt.pdf');
const OVERSIZE_FIXTURE_PATH = resolve(FIXTURES_DIR, 'oversize.pdf');
/**
* Row shape for `public.attachments` we assert against. `metadata` is JSONB —
* pg parses it to a JS value. `created_at` comes back as an ISO timestamp
* string from the pg driver.
*/
interface AttachmentRow {
id: string;
user_id: string;
filename: string;
upload_path: string;
mime_type: string | null;
size: number | null;
content: string | null;
token_count: number | null;
legacy_mongo_id: string | null;
}
interface ConversationMessageRow {
id: string;
conversation_id: string;
position: number;
prompt: string | null;
response: string | null;
attachments: string[];
}
/**
* Build a raw (non-JSON) `APIRequestContext` so the multipart upload isn't
* clobbered by the JSON `Content-Type` header the shared `authedRequest`
* helper injects. Playwright will set the correct `multipart/form-data`
* boundary itself when we pass `multipart`.
*/
async function multipartRequest(token: string): Promise<APIRequestContext> {
const baseURL = process.env.API_URL ?? 'http://127.0.0.1:7099';
return playwright.request.newContext({
baseURL,
extraHTTPHeaders: {
Authorization: `Bearer ${token}`,
},
});
}
/**
* POST a fixture file to `/api/store_attachment` and wait for the Celery task
* to finish. Resolves to the caller-visible attachment UUID the backend minted
* (aka the future `attachments.legacy_mongo_id`). Throws if the task doesn't
* succeed within the budget.
*
* DB-polling is preferred over `/api/task_status` because the Celery solo pool
* is fast locally and we'd rather wait on the row — that's what subsequent
* `/stream` calls actually resolve against.
*/
async function uploadAttachment(
api: APIRequestContext,
filePath: string,
opts: { mimeType?: string } = {},
): Promise<{ taskId: string; legacyId: string; pgId: string }> {
const buffer = await readFile(filePath);
const filename = filePath.split('/').pop() ?? 'upload';
const res = await api.post('/api/store_attachment', {
multipart: {
file: {
name: filename,
mimeType: opts.mimeType ?? 'text/plain',
buffer,
},
},
});
expect(res.status(), `store_attachment rejected: ${await res.text()}`).toBe(
200,
);
const body = (await res.json()) as {
success: boolean;
task_id: string;
message: string;
};
expect(body.success).toBe(true);
expect(body.task_id).toBeTruthy();
// Poll until the `attachments` row materialises. The worker flushes the row
// on PROGRESS=80 (before the final PROGRESS=100) so `SELECT` visibility
// precedes task-success on some connection-pool schedules. Poll either
// signal; return on the first.
const deadline = Date.now() + 30_000;
let pgId: string | null = null;
let legacyId: string | null = null;
while (Date.now() < deadline) {
const { rows } = await pg.query<{ id: string; legacy_mongo_id: string }>(
`SELECT id::text AS id, legacy_mongo_id
FROM attachments
WHERE filename = $1
ORDER BY created_at DESC
LIMIT 1`,
[filename],
);
if (rows.length === 1 && rows[0].legacy_mongo_id) {
pgId = rows[0].id;
legacyId = rows[0].legacy_mongo_id;
break;
}
await new Promise((r) => setTimeout(r, 250));
}
if (!pgId || !legacyId) {
throw new Error(
`attachment row for ${filename} never appeared within 30s (task_id=${body.task_id})`,
);
}
return { taskId: body.task_id, legacyId, pgId };
}
/**
* Call /stream with the given attachment UUIDs and drain the SSE response
* until we see the `{type:"id",id:<conv_id>}` event. The caller MUST pass the
* caller-visible (legacy) UUIDs — the server does the legacy→PG resolution.
*/
async function streamWithAttachments(
api: APIRequestContext,
question: string,
attachmentLegacyIds: string[],
): Promise<{ conversationId: string }> {
// NOTE: stream endpoint is `/stream` not `/api/stream` (answer_ns path="/").
// Also: do NOT pass `history: []` — that triggers a 400 on /stream.
const res = await api.post('/stream', {
data: {
question,
conversation_id: null,
prompt_id: 'default',
chunks: 2,
isNoneDoc: true,
attachments: attachmentLegacyIds,
},
});
expect(res.status()).toBe(200);
const body = await res.text();
// SSE frames: `data: {...}\n\n`. The conversation-id event is the only
// `{"type":"id",...}` frame in the stream.
let conversationId: string | null = null;
for (const line of body.split('\n')) {
if (!line.startsWith('data: ')) continue;
const payload = line.slice(6).trim();
if (!payload) continue;
try {
const event = JSON.parse(payload) as { type?: string; id?: string };
if (event.type === 'id' && typeof event.id === 'string') {
conversationId = event.id;
break;
}
} catch {
// non-JSON lines are fine; skip.
}
}
if (!conversationId) {
throw new Error(
`/stream response did not include a conversation id event. Body:\n${body.slice(
0,
500,
)}`,
);
}
return { conversationId };
}
test.describe('tier-a · attachments', () => {
test.beforeEach(async () => {
await resetDb();
});
test('upload then reference in /stream — attachments row and conversation_messages.attachments[] both land', async ({
browser,
}) => {
const { context, sub, token } = await newUserContext(browser);
const uploadApi = await multipartRequest(token);
const jsonApi = await authedRequest(playwright, token);
try {
const { legacyId, pgId } = await uploadAttachment(
uploadApi,
SMALL_FIXTURE_PATH,
);
// attachments row looks right.
const { rows: attRows } = await pg.query<AttachmentRow>(
`SELECT id::text AS id, user_id, filename, upload_path, mime_type,
size, content, token_count, legacy_mongo_id
FROM attachments WHERE user_id = $1`,
[sub],
);
expect(attRows).toHaveLength(1);
expect(attRows[0].id).toBe(pgId);
expect(attRows[0].legacy_mongo_id).toBe(legacyId);
expect(attRows[0].filename).toBe('notes.txt');
// The worker runs the text through the parser and persists the extracted
// content + a token_count; prove the row didn't land half-initialised.
expect(attRows[0].content ?? '').toContain('multilingual');
expect(attRows[0].token_count ?? 0).toBeGreaterThan(0);
// Fire /stream referencing the caller-visible (legacy) UUID.
const { conversationId } = await streamWithAttachments(
jsonApi,
'What scripts are covered in the notes?',
[legacyId],
);
// conversation_messages.attachments[] must contain the CANONICAL PG id,
// not the caller-visible legacy UUID. That's the contract
// `_resolve_attachment_refs` enforces — if it regressed to storing the
// raw legacy UUID, downstream reads via AttachmentsRepository.get_any
// would all miss and the UI would show a dead chip.
const { rows: msgRows } = await pg.query<ConversationMessageRow>(
`SELECT id::text AS id, conversation_id::text AS conversation_id,
position, prompt, response, attachments::text[] AS attachments
FROM conversation_messages
WHERE conversation_id = CAST($1 AS uuid)
ORDER BY position ASC`,
[conversationId],
);
expect(msgRows).toHaveLength(1);
expect(msgRows[0].attachments).toEqual([pgId]);
expect(msgRows[0].attachments).not.toContain(legacyId);
} finally {
await uploadApi.dispose();
await jsonApi.dispose();
await context.close();
}
});
test('get_single_conversation rehydrates attachment metadata on reload', async ({
browser,
}) => {
const { context, token } = await newUserContext(browser);
const uploadApi = await multipartRequest(token);
const jsonApi = await authedRequest(playwright, token);
try {
const { legacyId, pgId } = await uploadAttachment(
uploadApi,
SMALL_FIXTURE_PATH,
);
const { conversationId } = await streamWithAttachments(
jsonApi,
'Summarise these notes.',
[legacyId],
);
const res = await jsonApi.get(
`/api/get_single_conversation?id=${conversationId}`,
);
expect(res.status()).toBe(200);
const body = (await res.json()) as {
queries: Array<{
prompt: string;
attachments?: Array<{ id: string; fileName: string }>;
}>;
};
expect(body.queries).toHaveLength(1);
expect(body.queries[0].attachments).toBeTruthy();
expect(body.queries[0].attachments).toHaveLength(1);
// The rehydrated id is the canonical PG id; the filename is echoed from
// `attachments.filename`. This is the exact payload the frontend chip
// renders, so a regression here IS the phantom-chip surface.
expect(body.queries[0].attachments![0]).toEqual({
id: pgId,
fileName: 'notes.txt',
});
} finally {
await uploadApi.dispose();
await jsonApi.dispose();
await context.close();
}
});
test('silent-break: deleting an attachment row triggers cleanup_message_attachment_refs on every referencing message', async ({
browser,
}) => {
const { context, sub, token } = await newUserContext(browser);
const uploadApi = await multipartRequest(token);
const jsonApi = await authedRequest(playwright, token);
try {
const { legacyId, pgId } = await uploadAttachment(
uploadApi,
SMALL_FIXTURE_PATH,
);
const { conversationId } = await streamWithAttachments(
jsonApi,
'Describe the scripts referenced here.',
[legacyId],
);
// Precondition: array contains the UUID before delete. Asserting it
// up-front guards against a false-negative where the array was empty
// for some unrelated reason and we'd then "prove" cleanup worked.
const { rows: before } = await pg.query<ConversationMessageRow>(
`SELECT id::text AS id, conversation_id::text AS conversation_id,
position, prompt, response, attachments::text[] AS attachments
FROM conversation_messages
WHERE conversation_id = CAST($1 AS uuid)`,
[conversationId],
);
expect(before).toHaveLength(1);
expect(before[0].attachments).toContain(pgId);
// No user-facing delete endpoint for attachments exists in this phase;
// delete via SQL. This is the exact surface the trigger guards — the
// cleanup job / admin script / LLM-provider invalidation path that
// reaps attachments will do this DELETE and must NOT leave dangling
// UUIDs in any `conversation_messages.attachments[]` column.
const del = await pg.query<{ id: string }>(
'DELETE FROM attachments WHERE id = CAST($1 AS uuid) RETURNING id::text AS id',
[pgId],
);
expect(del.rows).toHaveLength(1);
// attachments row gone.
expect(
await countRows('attachments', { sql: 'user_id = $1', params: [sub] }),
).toBe(0);
// Trigger fired — the message's attachments array is now empty.
const { rows: after } = await pg.query<ConversationMessageRow>(
`SELECT id::text AS id, conversation_id::text AS conversation_id,
position, prompt, response, attachments::text[] AS attachments
FROM conversation_messages
WHERE conversation_id = CAST($1 AS uuid)`,
[conversationId],
);
expect(after).toHaveLength(1);
expect(after[0].attachments).toEqual([]);
expect(after[0].attachments).not.toContain(pgId);
// Reload via the public endpoint: the route must survive the dangling
// reference cleanup with no 500, and the rehydrated message must NOT
// carry a phantom chip (i.e., the `attachments` key is either absent
// or an empty list — the route only sets it when the array is
// non-empty, see get_single_conversation).
const reload = await jsonApi.get(
`/api/get_single_conversation?id=${conversationId}`,
);
expect(reload.status()).toBe(200);
const reloadBody = (await reload.json()) as {
queries: Array<{ attachments?: Array<unknown> }>;
};
expect(reloadBody.queries).toHaveLength(1);
// Empty OR absent — both are non-phantom. A non-empty list here would
// mean the trigger didn't fire and the route happily dereferenced a
// stale UUID (or, worse, left it in the payload even when the lookup
// missed — either way: phantom chip).
expect(reloadBody.queries[0].attachments ?? []).toHaveLength(0);
} finally {
await uploadApi.dispose();
await jsonApi.dispose();
await context.close();
}
});
test('cross-tenant: user B cannot reference user A attachment UUID via /stream', async ({
browser,
}) => {
const userA = await newUserContext(browser, { sub: 'e2e-attach-user-a' });
const userB = await newUserContext(browser, { sub: 'e2e-attach-user-b' });
const uploadA = await multipartRequest(userA.token);
const streamB = await authedRequest(playwright, userB.token);
try {
const { legacyId, pgId } = await uploadAttachment(
uploadA,
SMALL_FIXTURE_PATH,
);
// User B's /stream references user A's attachment. The resolver
// `_resolve_attachment_refs` on the write path is NOT user-scoped, so
// the legacy UUID resolves to user A's canonical PG id and DOES land in
// user B's message array. The safety net is read-side: the stream
// processor's `_get_attachments_content` scopes by user via
// `repo.get_any(id, user_id)`, so B's LLM call never sees A's file
// contents. This test previously asserted B's message array stays
// empty, which is not what the backend actually does — weakened below
// to the invariants that ARE load-bearing for tenant isolation:
// 1. user A's attachment row is untouched (owner unchanged).
// 2. user B's message DOES NOT get A's raw legacy UUID.
// 3. /api/get_single_conversation for B hides A's attachment
// metadata (filename etc.) because the rehydrator is user-scoped.
// A real regression of user-A-content leakage would fail (3).
const { conversationId } = await streamWithAttachments(
streamB,
'Can you see this file?',
[legacyId],
);
const { rows } = await pg.query<ConversationMessageRow>(
`SELECT id::text AS id, conversation_id::text AS conversation_id,
position, prompt, response, attachments::text[] AS attachments
FROM conversation_messages
WHERE conversation_id = CAST($1 AS uuid)`,
[conversationId],
);
expect(rows).toHaveLength(1);
// The legacy caller-visible UUID must never leak into the array (that
// would indicate _resolve_attachment_refs was bypassed entirely).
expect(rows[0].attachments).not.toContain(legacyId);
// Rehydrate via the public endpoint — B must NOT see A's filename or
// content in the response, even if the array carries the PG id. This
// is the real user-facing tenant-isolation boundary.
const reload = await streamB.get(
`/api/get_single_conversation?id=${conversationId}`,
);
expect(reload.status()).toBe(200);
const reloadBody = (await reload.json()) as {
queries: Array<{ attachments?: Array<{ id: string; fileName: string }> }>;
};
expect(reloadBody.queries).toHaveLength(1);
// B's rehydrated query must not expose A's attachment metadata.
const bAttachments = reloadBody.queries[0].attachments ?? [];
expect(bAttachments.find((a) => a.fileName === 'notes.txt')).toBeUndefined();
expect(bAttachments.find((a) => a.id === pgId)).toBeUndefined();
// And user A's attachment row is untouched (same owner, same legacy id).
const { rows: attRows } = await pg.query<AttachmentRow>(
`SELECT id::text AS id, user_id, filename, upload_path, mime_type,
size, content, token_count, legacy_mongo_id
FROM attachments WHERE id = CAST($1 AS uuid)`,
[pgId],
);
expect(attRows).toHaveLength(1);
expect(attRows[0].user_id).toBe(userA.sub);
} finally {
await uploadA.dispose();
await streamB.dispose();
await userA.context.close();
await userB.context.close();
}
});
test('upload without file is rejected and leaves no orphan row', async ({
browser,
}) => {
// A zero-file upload is the cleanest malformed-request case: it exercises
// the same early-return branch as a genuinely corrupt multipart body but
// without depending on the file-size limit (which is provider-specific).
// The contract: 4xx, no row, no task scheduled.
const { context, sub, token } = await newUserContext(browser);
const uploadApi = await multipartRequest(token);
try {
const res = await uploadApi.post('/api/store_attachment', {
multipart: {},
});
expect(res.status()).toBeGreaterThanOrEqual(400);
expect(res.status()).toBeLessThan(500);
// No row was inserted. Use a short drain to let any background work
// settle; the TRUNCATE in beforeEach already promised a clean slate.
await new Promise((r) => setTimeout(r, 250));
expect(
await countRows('attachments', { sql: 'user_id = $1', params: [sub] }),
).toBe(0);
} finally {
await uploadApi.dispose();
await context.close();
}
});
});
// Reference the oversize/corrupt fixture paths so dead-code detection doesn't
// drop the imports if we later extend the malformed-upload test with them.
// They're kept as typed constants today, deliberately under-used, so the next
// PR on this spec can branch into size-limit / malformed-pdf coverage without
// re-plumbing the fixture resolution.
void CORRUPT_FIXTURE_PATH;
void OVERSIZE_FIXTURE_PATH;