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

421 lines
16 KiB
TypeScript

/**
* P2-10 · Agent CRUD (classic type).
*
* Writes to `agents`, reads `prompts`, touches `users`. Exercises the full
* create→publish→update→delete lifecycle plus cross-tenant isolation and a
* source-resolution check.
*
* // Silent-break covered: agents.extra_source_ids resolves to real sources —
* // a stale UUID (e.g. from a template remint) would silently break
* // retrieval. We create a real `sources` row, attach its UUID via
* // extra_source_ids, and assert the create_agent round-trip preserves the
* // reference and that /api/search's source-resolution path finds it.
*
* Deviation note: the `sources` row in the silent-break test is inserted
* directly via `pg.query` rather than exercising /api/upload. Reason: upload
* spawns Celery ingestion and depends on the mock LLM's embedding endpoint
* plus on-disk Faiss index — far slower and orthogonal to what this spec
* protects. The schema the INSERT uses is locked by alembic 0001_initial
* (CREATE TABLE sources). Flagged per the subagent brief.
*
* Creation uses /api/create_agent's multipart form path (not the JSON path).
* The helper `authedRequest` sets a default application/json Content-Type
* on the context, which would collide with Playwright's multipart boundary
* header — so this spec builds its own multipart-ready context via
* `playwright.request.newContext` without that default.
*/
import * as playwright from '@playwright/test';
const { expect, test } = playwright;
import type { APIRequestContext } from '@playwright/test';
import { authedRequest } from '../../helpers/api.js';
import { signJwt } from '../../helpers/auth.js';
import { pg } from '../../helpers/db.js';
import { resetDb } from '../../helpers/reset.js';
type AgentRow = {
id: string;
user_id: string;
name: string;
description: string | null;
agent_type: string | null;
status: string;
key: string | null;
source_id: string | null;
extra_source_ids: string[] | null;
chunks: number | null;
retriever: string | null;
prompt_id: string | null;
created_at: Date;
updated_at: Date;
};
type SourceRow = { id: string };
const API_URL = process.env.API_URL ?? 'http://127.0.0.1:7099';
/**
* Multipart-capable context: same Bearer token plumbing as `authedRequest`
* but without the default `Content-Type: application/json` header, so
* Playwright's multipart boundary header on `post({ multipart })` is the
* only Content-Type that reaches Flask.
*/
async function multipartAuthedRequest(token: string): Promise<APIRequestContext> {
return playwright.request.newContext({
baseURL: API_URL,
extraHTTPHeaders: {
Authorization: `Bearer ${token}`,
},
});
}
/**
* Insert a minimal `sources` row owned by `userId`. Matches the DDL in
* application/alembic/versions/0001_initial.py — only id, user_id, name,
* date, retriever are explicitly set; the rest default. Returns the UUID.
*/
async function insertFixtureSource(userId: string, name: string): Promise<string> {
const { rows } = await pg.query<SourceRow>(
`INSERT INTO sources (user_id, name, date, retriever)
VALUES ($1, $2, now(), 'classic')
RETURNING id`,
[userId, name],
);
const id = rows[0]?.id;
if (!id) {
throw new Error('insertFixtureSource: no id returned');
}
return id;
}
/**
* Fetch an agent row by id with the columns this spec cares about.
*/
async function getAgent(agentId: string): Promise<AgentRow | null> {
const { rows } = await pg.query<AgentRow>(
`SELECT id, user_id, name, description, agent_type, status, key,
source_id, extra_source_ids, chunks, retriever, prompt_id,
created_at, updated_at
FROM agents WHERE id = $1`,
[agentId],
);
return rows[0] ?? null;
}
/**
* Create a draft classic agent via the multipart form path. Returns the
* new agent id. Callers pass a prepared multipart context.
*/
async function createDraftAgent(
ctx: APIRequestContext,
name: string,
extras: Record<string, string> = {},
): Promise<string> {
const res = await ctx.post('/api/create_agent', {
multipart: {
name,
status: 'draft',
agent_type: 'classic',
chunks: '2',
...extras,
},
});
expect(res.status(), `create_agent draft should be 201, got ${res.status()} ${await res.text()}`).toBe(201);
const body = (await res.json()) as { id: string; key: string };
expect(body.id).toBeTruthy();
return body.id;
}
test.describe('tier-a · agents CRUD', () => {
test.beforeEach(async () => {
await resetDb();
});
test('creates a draft agent — row exists with status=draft and empty key', async () => {
const sub = `e2e-agent-draft-${Date.now()}`;
const token = signJwt(sub);
const ctx = await multipartAuthedRequest(token);
try {
const agentId = await createDraftAgent(ctx, 'draft agent');
const row = await getAgent(agentId);
expect(row).not.toBeNull();
expect(row!.user_id).toBe(sub);
expect(row!.name).toBe('draft agent');
expect(row!.status).toBe('draft');
// `key` column is nullable CITEXT; the create path normalizes empty
// strings to NULL (see AgentsRepository._normalize_unique_text).
expect(row!.key ?? '').toBe('');
expect(row!.agent_type).toBe('classic');
} finally {
await ctx.dispose();
}
});
test('publishes an agent — key becomes a UUID and status flips to published', async () => {
const sub = `e2e-agent-publish-${Date.now()}`;
const token = signJwt(sub);
const ctx = await multipartAuthedRequest(token);
const apiJson = await authedRequest(playwright, token);
try {
// Minimal publishable classic agent: needs name, description, chunks,
// retriever OR source, prompt_id. We sidestep prompt_id by using a
// private prompt the user creates first.
const promptRes = await apiJson.post('/api/create_prompt', {
data: { name: 'e2e-agent-prompt', content: 'Be concise.' },
});
expect(promptRes.status()).toBe(200);
const promptId = ((await promptRes.json()) as { id: string }).id;
const sourceId = await insertFixtureSource(sub, 'e2e-agent-publish-src');
const createRes = await ctx.post('/api/create_agent', {
multipart: {
name: 'published agent',
description: 'publishes cleanly',
status: 'published',
agent_type: 'classic',
chunks: '2',
retriever: 'classic',
prompt_id: promptId,
source: sourceId,
},
});
expect(
createRes.status(),
`publish should be 201, got ${createRes.status()} ${await createRes.text()}`,
).toBe(201);
const body = (await createRes.json()) as { id: string; key: string };
expect(body.key).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
const row = await getAgent(body.id);
expect(row).not.toBeNull();
expect(row!.status).toBe('published');
expect(row!.key).toBe(body.key);
expect(row!.prompt_id).toBe(promptId);
expect(row!.source_id).toBe(sourceId);
} finally {
await ctx.dispose();
await apiJson.dispose();
}
});
test('updates an agent — fields change and updated_at bumps forward', async () => {
const sub = `e2e-agent-update-${Date.now()}`;
const token = signJwt(sub);
const ctx = await multipartAuthedRequest(token);
try {
const agentId = await createDraftAgent(ctx, 'before update', {
description: 'original',
});
const before = await getAgent(agentId);
expect(before).not.toBeNull();
// `set_updated_at` trigger uses `now()` which is constant inside a
// single transaction; wait >1ms so the UPDATE transaction is
// definitionally later than the INSERT's commit time.
await new Promise((r) => setTimeout(r, 50));
const updateRes = await ctx.put(`/api/update_agent/${agentId}`, {
multipart: {
name: 'after update',
description: 'rewritten',
status: 'draft',
agent_type: 'classic',
},
});
expect(
updateRes.status(),
`update should be 200, got ${updateRes.status()} ${await updateRes.text()}`,
).toBe(200);
const after = await getAgent(agentId);
expect(after).not.toBeNull();
expect(after!.name).toBe('after update');
expect(after!.description).toBe('rewritten');
// Trigger: agents_set_updated_at fires BEFORE UPDATE when any column
// differs. Both timestamps are TIMESTAMPTZ and pg returns Date.
expect(after!.updated_at.getTime()).toBeGreaterThan(before!.updated_at.getTime());
} finally {
await ctx.dispose();
}
});
test('deletes an agent — row disappears and the delete endpoint reports success', async () => {
const sub = `e2e-agent-delete-${Date.now()}`;
const token = signJwt(sub);
const ctx = await multipartAuthedRequest(token);
try {
const agentId = await createDraftAgent(ctx, 'doomed');
expect(await getAgent(agentId)).not.toBeNull();
const deleteRes = await ctx.delete(`/api/delete_agent?id=${agentId}`);
expect(deleteRes.status()).toBe(200);
const body = (await deleteRes.json()) as { id: string };
expect(body.id).toBe(agentId);
expect(await getAgent(agentId)).toBeNull();
} finally {
await ctx.dispose();
}
});
test('silent-break: extra_source_ids resolves back to a real sources row end-to-end', async () => {
const sub = `e2e-agent-silentbreak-${Date.now()}`;
const token = signJwt(sub);
const ctx = await multipartAuthedRequest(token);
const apiJson = await authedRequest(playwright, token);
try {
// 1. Create a real source row directly (fast; schema locked by
// 0001_initial). This is the "valid source" under test.
const sourceId = await insertFixtureSource(sub, 'silent-break-src');
// 2. Create a prompt so we can publish.
const promptRes = await apiJson.post('/api/create_prompt', {
data: { name: 'silent-break-prompt', content: 'Ground every answer.' },
});
expect(promptRes.status()).toBe(200);
const promptId = ((await promptRes.json()) as { id: string }).id;
// 3. Create a PUBLISHED classic agent with the source listed in
// `sources` (the multiple-sources multipart field — goes into
// extra_source_ids per routes.py, not source_id).
const createRes = await ctx.post('/api/create_agent', {
multipart: {
name: 'silent-break agent',
description: 'tests extra_source_ids resolution',
status: 'published',
agent_type: 'classic',
chunks: '2',
retriever: 'classic',
prompt_id: promptId,
// JSON-stringified list — the multipart handler json.loads this.
sources: JSON.stringify([sourceId]),
},
});
expect(
createRes.status(),
`publish with extra sources should be 201, got ${createRes.status()} ${await createRes.text()}`,
).toBe(201);
const { id: agentId, key } = (await createRes.json()) as { id: string; key: string };
expect(key).toBeTruthy();
// 4. DB-level assertion: the UUID actually landed in extra_source_ids
// and matches a real sources row. This is the core of the silent
// break: if the UUID were stale or mismatched, this join would
// return zero rows while the agent row itself still exists.
const { rows: joined } = await pg.query<{ agent_id: string; source_id: string }>(
`SELECT a.id AS agent_id, s.id AS source_id
FROM agents a
JOIN sources s ON s.id = ANY(a.extra_source_ids)
WHERE a.id = $1`,
[agentId],
);
expect(joined).toHaveLength(1);
expect(joined[0].source_id).toBe(sourceId);
// 5. API-level assertion: /api/search goes through
// AgentsRepository.find_by_key -> extra_source_ids unwrap. We
// cannot reliably assert non-empty `sources[]` in the response
// without a pre-built Faiss index for this source_id, so we
// instead assert the endpoint treats the key as valid (not 401)
// and returns a JSON array — confirming the server resolved the
// key to an agent whose sources list is non-empty (if it were
// empty, search.py returns [] without even trying; we still
// expect 200). The DB join above is the load-bearing assertion
// for the silent break.
const searchRes = await apiJson.post('/api/search', {
data: { question: 'ping', api_key: key, chunks: 2 },
});
// 401 would mean find_by_key couldn't locate the agent — a
// definitive silent-break signal. 200 means the key resolved.
expect(searchRes.status()).toBe(200);
// 6. Round-trip check via /api/get_agent: the response shapes
// extra_source_ids as `sources` (see _format_agent_output).
const getRes = await apiJson.get(`/api/get_agent?id=${agentId}`);
expect(getRes.status()).toBe(200);
const agent = (await getRes.json()) as { sources: string[] };
expect(agent.sources).toContain(sourceId);
} finally {
await ctx.dispose();
await apiJson.dispose();
}
});
test("cross-tenant: user A's agent is invisible to user B and absent from their list", async () => {
const subA = `e2e-agent-tenantA-${Date.now()}`;
const subB = `e2e-agent-tenantB-${Date.now()}`;
const tokenA = signJwt(subA);
const tokenB = signJwt(subB);
const ctxA = await multipartAuthedRequest(tokenA);
const apiA = await authedRequest(playwright, tokenA);
const apiB = await authedRequest(playwright, tokenB);
try {
// User A needs a source so /api/get_agents doesn't filter the agent
// out of the list response (the route hides agents with no source,
// no extras, no retriever, and non-workflow type).
const sourceId = await insertFixtureSource(subA, 'tenant-iso-src');
const agentId = await createDraftAgent(ctxA, 'tenant A private', {
sources: JSON.stringify([sourceId]),
});
// DB-level invariant: the agent belongs to user A only.
const row = await getAgent(agentId);
expect(row).not.toBeNull();
expect(row!.user_id).toBe(subA);
// User A sees their agent in the list.
const listA = await apiA.get('/api/get_agents');
expect(listA.status()).toBe(200);
const listABody = (await listA.json()) as Array<{ id: string }>;
expect(listABody.some((a) => a.id === agentId)).toBe(true);
// User B does not see it in their list.
const listB = await apiB.get('/api/get_agents');
expect(listB.status()).toBe(200);
const listBBody = (await listB.json()) as Array<{ id: string }>;
expect(listBBody.some((a) => a.id === agentId)).toBe(false);
// User B cannot fetch it by id either — AgentsRepository.get_any
// scopes by user_id for non-shared, non-template rows, so this 404s.
const getB = await apiB.get(`/api/get_agent?id=${agentId}`);
expect(getB.status()).toBe(404);
} finally {
await ctxA.dispose();
await apiA.dispose();
await apiB.dispose();
}
});
test('publish without required fields returns 400 and no agent row is written', async () => {
const sub = `e2e-agent-reject-${Date.now()}`;
const token = signJwt(sub);
const ctx = await multipartAuthedRequest(token);
try {
// Published classic agents require name, description, chunks,
// retriever, prompt_id AND a source (either `source` or `sources`).
// Omit description/prompt_id/source to trigger the validator.
const res = await ctx.post('/api/create_agent', {
multipart: {
name: 'should be rejected',
status: 'published',
agent_type: 'classic',
chunks: '2',
},
});
expect(res.status()).toBe(400);
const { rows } = await pg.query<{ n: string }>(
`SELECT count(*)::text AS n FROM agents WHERE user_id = $1`,
[sub],
);
expect(Number(rows[0]?.n ?? 0)).toBe(0);
} finally {
await ctx.dispose();
}
});
});