Files
jobtrackingapp/job-tracker-ui/e2e/phase7-documents-contract.spec.ts

91 lines
18 KiB
TypeScript

import { expect, test as base, type APIRequestContext, type APIResponse } from '@playwright/test';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
const privateBody = 'DO_NOT_EXPOSE_PRIVATE_DOCUMENT_BODY — æøå\n<script>plain text fixture</script>';
type Owner = { id: string; headers: Record<string, string> };
type Job = { id: number; companyId: number; jobTitle: string };
type Draft = { id: string; jobApplicationId: number; provider: string; to: string; subject: string; bodyText: string; threadId: string | null; clientRequestId: string; revision: number; createdAtUtc: string; updatedAtUtc: string };
type Cv = { id: number; name: string; version: number; jobApplicationId: number | null; isPublic: boolean; settings: Record<string, unknown> };
type Item = { id: string; resourceId: string; type: string; name: string; state: string; visibility: string | null; jobApplicationId: number | null; jobTitle: string | null; companyName: string | null; jobIsDeleted: boolean | null; recordedAt: string | null; dateKind: string; version: number | null; historyCount: number | null; attachmentCount: number | null; cvVariantId: number | null; cvVariantName: string | null; cvVariantVersion: number | null; fileType: string | null; fileSize: number | null; purpose: string | null; useForAi: boolean | null; provider: string | null; sourceHref: string; downloadHref: string | null; publicHref: string | null; hasCurrentContent: boolean | null };
type Documents = { items: Item[]; page: number; pageSize: number; total: number; totalPages: number };
type ReadReceipt = { actor: string; query: Record<string, string | number>; status: number; metadata: Documents | null };
type Session = { a: Owner; b: Owner; apiA: APIRequestContext; apiB: APIRequestContext; anonymous: APIRequestContext; reads: ReadReceipt[] };
const fields = ['id', 'resourceId', 'type', 'name', 'state', 'visibility', 'jobApplicationId', 'jobTitle', 'companyName', 'jobIsDeleted', 'recordedAt', 'dateKind', 'version', 'historyCount', 'attachmentCount', 'cvVariantId', 'cvVariantName', 'cvVariantVersion', 'fileType', 'fileSize', 'purpose', 'useForAi', 'provider', 'sourceHref', 'downloadHref', 'publicHref', 'hasCurrentContent'].sort();
const boundary = 'Three API-only native Playwright cases run the actual Program, disposable SQLite, real local Identity login/session cookies and CSRF-protected source writes at localhost5302. Each case creates independent free A/B accounts through the existing admin/users API. The prebuilt UI server on3300 is only the existing launch fixture; no Documents UI, browser crawl, render, PDF, download, storage relocation, provider grant, send, Gmail operation or model request is executed. Metadata response receipts retain only the exact27 public keys and fixture metadata; no authentication/session/CSRF headers or source bodies. Repeated GET and independent source readback prove observable metadata/persistence, not internal service-call or SQL-command counts. No-generation dependencies, bounded database LIMIT/count and all seven resource kinds remain separately qualified by backend contract tests. These cookie-authentication cases supplement those tests, which use a controlled test identity. No schema/raw SQL/production/deployment action. Browser network guards block external origins and delivery/model endpoints if accidentally requested; all API request destinations are the fixed local API. API-only evidence does not establish migrated UI, responsive, visual, axe, human reader/device, production ingress or release readiness.';
async function json<T>(response: APIResponse, status = 200): Promise<T> { expect(response.status(), response.url()).toBe(status); return response.json(); }
async function login(api: APIRequestContext, email: string): Promise<Owner> {
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await json<{ id: string; sessionContext: string }>(await api.get(`${apiUrl}/auth/me`));
const token = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN'); expect(token).toBeDefined();
return { id: me.id, headers: { 'X-CSRF-TOKEN': token!.value, 'X-Session-Context': me.sessionContext } };
}
async function account(admin: APIRequestContext, owner: Owner, actor: string) {
const email = `p707-documents-${actor}-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: owner.headers, data: { email, password, roles: [] } })).status()).toBe(200); return email;
}
const test = base.extend<{ session: Session }>({
session: async ({ page, context, playwright }, provide, info) => {
expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true'); expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String));
const admin = await playwright.request.newContext(); const apiB = await playwright.request.newContext(); const anonymous = await playwright.request.newContext(); const reads: ReadReceipt[] = []; const blocked: string[] = []; const browserRequests: string[] = [];
page.on('request', request => browserRequests.push(`${request.method()} ${new URL(request.url()).origin}${new URL(request.url()).pathname}`));
await context.route('**/*', route => { const url = new URL(route.request().url()); if (/^\/api\/(?:email\/send|gmail|ai)(?:\/|$)/.test(url.pathname) || !['localhost', '127.0.0.1'].includes(url.hostname) && !['blob:', 'data:', 'about:'].includes(url.protocol)) { blocked.push(url.origin + url.pathname); return route.abort(); } return route.continue(); });
try {
const administrator = await login(admin, 'e2e@example.test'); const a = await login(page.request, await account(admin, administrator, 'a')); const b = await login(apiB, await account(admin, administrator, 'b'));
for (const api of [page.request, apiB]) expect((await json<{ entitlements: { ai: boolean } }>(await api.get(`${apiUrl}/auth/me`))).entitlements.ai).toBe(false);
expect(a.id).not.toBe(b.id); await provide({ a, b, apiA: page.request, apiB, anonymous, reads }); expect(browserRequests).toEqual([]); expect(blocked).toEqual([]);
} finally { await info.attach('documents-real-cookie-contract-boundary', { body: JSON.stringify({ boundary, reads, browserRequests, blockedExternalOrDeliveryRequests: blocked }), contentType: 'application/json' }); await admin.dispose(); await apiB.dispose(); await anonymous.dispose(); }
},
});
async function documents(session: Session, query: Record<string, string | number> = {}, actor: 'a' | 'b' | 'anonymous' = 'a', expectedStatus = 200) {
const api = actor === 'a' ? session.apiA : actor === 'b' ? session.apiB : session.anonymous; const response = await api.get(`${apiUrl}/documents`, { params: query }); const status = response.status(); const data = status === 200 ? await response.json() as Documents : null;
session.reads.push({ actor, query, status, metadata: data }); expect(status, response.url()).toBe(expectedStatus); if (!data) return null;
expect(Object.keys(data).sort()).toEqual(['items', 'page', 'pageSize', 'total', 'totalPages'].sort()); expect(data.items.length).toBeLessThanOrEqual(data.pageSize); expect(data.totalPages).toBe(Math.ceil(data.total / data.pageSize));
for (const item of data.items) { expect(Object.keys(item).sort()).toEqual(fields); expect(item.id).toBe(`${item.type}:${item.resourceId}`); expect(item.sourceHref).toMatch(/^\//); }
expect(JSON.stringify(data)).not.toContain('DO_NOT_EXPOSE_PRIVATE_DOCUMENT_BODY'); expect(JSON.stringify(data)).not.toContain('private-recipient@example.test'); expect(JSON.stringify(data)).not.toContain('private-thread-fixture'); return data;
}
async function job(api: APIRequestContext, owner: Owner, title: string, ownedCompanyId?: number) {
const companyId = ownedCompanyId ?? (await json<{ id: number }>(await api.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Recorded metadata company' } }), 201)).id;
return json<Job>(await api.post(`${apiUrl}/jobapplications`, { headers: owner.headers, data: { companyId, jobTitle: title, status: 'Saved', notes: privateBody } }), 201);
}
async function email(api: APIRequestContext, owner: Owner, parent: Job, subject: string) {
return json<Draft>(await api.post(`${apiUrl}/email/drafts`, { headers: owner.headers, data: { jobApplicationId: parent.id, provider: 'gmail', to: 'private-recipient@example.test', subject, bodyText: privateBody, threadId: 'private-thread-fixture' } }), 201);
}
async function cv(api: APIRequestContext, owner: Owner, name: string, jobApplicationId: number | null = null) {
return json<Cv>(await api.post(`${apiUrl}/cv/variants`, { headers: owner.headers, data: { name, jobApplicationId, settings: { themeId: 'minimal', language: 'nb-NO', showPhoto: false, showPageNumbers: false, showIcons: true, sections: [], overrides: {}, customSections: [] } } }));
}
test('[P7-07 documents contract] actual owner metadata has exact27 keys, literal Nordic search, intersecting filters and stable paginated rereads', async ({ session }) => {
test.setTimeout(120_000); const empty = await documents(session); expect(empty).toEqual({ items: [], page: 1, pageSize: 25, total: 0, totalPages: 0 });
const firstJob = await job(session.apiA, session.a, 'Recorded document role'); const secondJob = await job(session.apiA, session.a, 'Second document role', firstJob.companyId);
const literal = await email(session.apiA, session.a, firstJob, 'Literal 100%_! ÆØÅ Nordisk'); const wildcardControl = await email(session.apiA, session.a, firstJob, 'Literal 100ABX Nordisk'); const other = await email(session.apiA, session.a, secondJob, 'Other application notes Åse'); const variant = await cv(session.apiA, session.a, 'Metadata CV Åse');
const all = await documents(session, { sort: 'name-asc' }); expect(all).toMatchObject({ page: 1, pageSize: 25, total: 4, totalPages: 1 }); expect(all!.items.map(item => item.id)).toEqual([`email-draft:${literal.id}`, `email-draft:${wildcardControl.id}`, `cv:${variant.id}`, `email-draft:${other.id}`]);
const projected = all!.items.find(item => item.id === `email-draft:${literal.id}`)!; expect(projected).toMatchObject({ resourceId: literal.id, name: literal.subject, type: 'email-draft', state: 'draft', visibility: null, jobApplicationId: firstJob.id, jobTitle: firstJob.jobTitle, companyName: 'Recorded metadata company', provider: 'gmail', version: 1, dateKind: 'updated-utc', sourceHref: '/correspondence', downloadHref: null, publicHref: null, hasCurrentContent: null }); expect(projected.recordedAt).toMatch(/Z$/); expect(JSON.stringify(all)).not.toContain(literal.clientRequestId);
for (const q of ['%_!', '%', '_', '!', 'æøå']) { const matched = await documents(session, { q, sort: 'name-asc' }); expect(matched!.total).toBe(1); expect(matched!.items.map(item => item.id)).toEqual([projected.id]); }
expect((await documents(session, { q: 'DO_NOT_EXPOSE_PRIVATE_DOCUMENT_BODY' }))!.total).toBe(0);
const first = await documents(session, { sort: 'name-asc', pageSize: 2 }); const second = await documents(session, { sort: 'name-asc', pageSize: 2, page: 2 }); expect(first).toMatchObject({ page: 1, pageSize: 2, total: 4, totalPages: 2 }); expect(second).toMatchObject({ page: 2, pageSize: 2, total: 4, totalPages: 2 }); expect([...first!.items, ...second!.items]).toEqual(all!.items); expect(await documents(session, { sort: 'name-asc', pageSize: 2 })).toEqual(first);
const filtered = await documents(session, { type: 'email-draft', state: 'draft', jobApplicationId: firstJob.id }); expect(filtered!.total).toBe(2); expect(filtered!.items.map(item => item.id).sort()).toEqual([`email-draft:${literal.id}`, `email-draft:${wildcardControl.id}`].sort());
const cvs = await documents(session, { type: 'cv', state: 'current', visibility: 'private' }); expect(cvs!.items.map(item => item.id)).toEqual([`cv:${variant.id}`]); expect((await documents(session, { visibility: 'public' }))!.total).toBe(0); expect((await documents(session, { type: 'submitted-package' }))!.total).toBe(0);
expect(await json<Draft>(await session.apiA.get(`${apiUrl}/email/drafts/${literal.id}`))).toEqual(literal); expect(await json<Cv>(await session.apiA.get(`${apiUrl}/cv/variants/${variant.id}`))).toEqual(variant);
});
test('[P7-07 documents contract] actual cookies require authentication and isolate owner metadata despite forged owner or foreign-parent filters', async ({ session }) => {
test.setTimeout(120_000); const jobA = await job(session.apiA, session.a, 'Private owner A role'); const jobB = await job(session.apiB, session.b, 'Private owner B role'); const draftA = await email(session.apiA, session.a, jobA, 'Private owner A subject'); const draftB = await email(session.apiB, session.b, jobB, 'Private owner B subject');
await documents(session, {}, 'anonymous', 401); const a = await documents(session); const b = await documents(session, {}, 'b'); expect(a!.items.map(item => item.id)).toEqual([`email-draft:${draftA.id}`]); expect(b!.items.map(item => item.id)).toEqual([`email-draft:${draftB.id}`]);
expect(await documents(session, { ownerUserId: session.b.id })).toEqual(a); expect(await documents(session, { ownerUserId: session.a.id }, 'b')).toEqual(b); const foreign = await documents(session, { jobApplicationId: jobA.id }, 'b'); expect(foreign).toMatchObject({ items: [], total: 0, totalPages: 0 }); expect((await documents(session, { jobApplicationId: jobB.id }))!.items).toEqual([]);
expect((await session.apiB.get(`${apiUrl}/email/drafts/${draftA.id}`)).status()).toBe(404); expect((await session.apiA.get(`${apiUrl}/email/drafts/${draftB.id}`)).status()).toBe(404); expect((await session.anonymous.get(`${apiUrl}/email/drafts/${draftA.id}`)).status()).toBe(401);
expect((await session.apiB.post(`${apiUrl}/email/drafts`, { headers: session.b.headers, data: { jobApplicationId: jobA.id, provider: 'gmail', to: '', subject: 'Forbidden association', bodyText: '' } })).status()).toBe(404); expect(await documents(session)).toEqual(a); expect(await documents(session, {}, 'b')).toEqual(b); expect(await json<Draft>(await session.apiA.get(`${apiUrl}/email/drafts/${draftA.id}`))).toEqual(draftA);
});
test('[P7-07 documents contract] existing CV and email revisions detach and deletes preserve projection identities without metadata-list writes', async ({ session }) => {
test.setTimeout(150_000); const parent = await job(session.apiA, session.a, 'Document source mutations'); const originalCv = await cv(session.apiA, session.a, 'Original metadata CV', parent.id); const originalEmail = await email(session.apiA, session.a, parent, 'Original metadata email'); const initial = await documents(session, { sort: 'name-asc' }); expect(initial!.items.map(item => item.id).sort()).toEqual([`cv:${originalCv.id}`, `email-draft:${originalEmail.id}`].sort());
const invalid: Record<string, string | number>[] = [{ page: 0 }, { pageSize: 101 }, { type: 'unknown' }, { state: 'sent' }, { visibility: 'shared' }, { sort: 'date-desc;DROP TABLE CvVariants' }, { q: 'a'.repeat(201) }, { jobApplicationId: 0 }]; for (const query of invalid) await documents(session, query, 'a', 400);
expect(await json<Cv>(await session.apiA.get(`${apiUrl}/cv/variants/${originalCv.id}`))).toEqual(originalCv); expect(await json<Draft>(await session.apiA.get(`${apiUrl}/email/drafts/${originalEmail.id}`))).toEqual(originalEmail); expect(await documents(session, { sort: 'name-asc' })).toEqual(initial);
const revisedCv = await json<Cv>(await session.apiA.put(`${apiUrl}/cv/variants/${originalCv.id}`, { headers: session.a.headers, data: { name: 'Revised metadata CV ÆØÅ', settings: originalCv.settings, expectedVersion: originalCv.version, source: 'manual' } })); expect(revisedCv).toMatchObject({ id: originalCv.id, name: 'Revised metadata CV ÆØÅ', version: 2, jobApplicationId: parent.id });
const revisedEmail = await json<Draft>(await session.apiA.put(`${apiUrl}/email/drafts/${originalEmail.id}`, { headers: session.a.headers, data: { revision: originalEmail.revision, to: originalEmail.to, subject: 'Revised metadata email ÆØÅ', bodyText: `${privateBody}\nNew owner-only body` } })); expect(revisedEmail).toMatchObject({ id: originalEmail.id, clientRequestId: originalEmail.clientRequestId, revision: 2 });
const revised = await documents(session, { sort: 'name-asc' }); expect(revised!.total).toBe(2); expect(revised!.items.map(item => item.id).sort()).toEqual(initial!.items.map(item => item.id).sort()); expect(revised!.items.find(item => item.type === 'cv')).toMatchObject({ name: revisedCv.name, version: 2, jobApplicationId: parent.id }); expect(revised!.items.find(item => item.type === 'email-draft')).toMatchObject({ name: revisedEmail.subject, version: 2, jobApplicationId: parent.id }); expect(JSON.stringify(revised)).not.toContain(revisedEmail.bodyText); expect(JSON.stringify(revised)).not.toContain(revisedEmail.clientRequestId);
await json(await session.apiA.put(`${apiUrl}/jobapplications/${parent.id}/cv`, { headers: session.a.headers, data: { variantId: null } })); const detached = await documents(session); expect(detached!.items.find(item => item.type === 'cv')).toMatchObject({ id: `cv:${originalCv.id}`, resourceId: String(originalCv.id), name: revisedCv.name, version: 2, jobApplicationId: null, jobTitle: null, companyName: null }); expect((await documents(session, { jobApplicationId: parent.id }))!.items.map(item => item.id)).toEqual([`email-draft:${originalEmail.id}`]);
expect((await session.apiA.delete(`${apiUrl}/email/drafts/${originalEmail.id}`, { headers: session.a.headers, params: { revision: revisedEmail.revision } })).status()).toBe(204); const afterEmailDelete = await documents(session); expect(afterEmailDelete!.total).toBe(1); expect(afterEmailDelete!.items.map(item => item.id)).toEqual([`cv:${originalCv.id}`]); expect((await session.apiA.get(`${apiUrl}/email/drafts/${originalEmail.id}`)).status()).toBe(404);
expect((await session.apiA.delete(`${apiUrl}/cv/variants/${originalCv.id}`, { headers: session.a.headers })).status()).toBe(204); expect(await documents(session)).toEqual({ items: [], page: 1, pageSize: 25, total: 0, totalPages: 0 }); expect((await session.apiA.get(`${apiUrl}/cv/variants/${originalCv.id}`)).status()).toBe(404);
});