98 lines
26 KiB
TypeScript
98 lines
26 KiB
TypeScript
import { expect, test as base, type APIRequestContext, type APIResponse, type BrowserContext, type Locator, type Page, type Response } from '@playwright/test';
|
|
import { translations } from '../src/i18n/translations';
|
|
|
|
const apiUrl = 'http://localhost:5302/api';
|
|
const uiOrigin = 'http://localhost:3300';
|
|
const password = 'E2ePassword123!';
|
|
type Provider = 'gmail' | 'microsoft-graph';
|
|
type Owner = { id: string; email: string; headers: Record<string, string> };
|
|
type RecordedRequest = { method: string; path: string; query: string };
|
|
type Session = { owner: Owner; requests: RecordedRequest[] };
|
|
type PopupRecord = { url: string; name: string; features: string; proxy: Window | null; blocked: boolean };
|
|
type MessageRecord = { origin: string; source: unknown; status: unknown; message: unknown; sourceOpenName: string | null; sourceClosed: boolean | null; sourceIsSelf: boolean };
|
|
type ClientProof = { opens: PopupRecord[]; messages: MessageRecord[]; blockNext: boolean };
|
|
type InstrumentedWindow = Window & typeof globalThis & { __clientGuardProof: ClientProof };
|
|
const boundary = 'CLIENT-GUARD fixture only. Actual Program/local Identity cookies/CSRF/free owners, actual owned jobs and actual disconnected status endpoints are used. Authenticated connect-url GETs are explicitly projected to local authorization HTML carrying an exact redirect_uri callback path on UI3300. Local child pages are fulfilled through browser routing, not new backend/app routes; their real browser WindowProxy sends actual postMessage events and closes immediately. No synthetic MessageEvent is dispatched. Controlled candidate GET returns an empty UI contract after actual owned-job authorization checks; no Gmail/Graph provider request or mailbox connection is performed. Success callback payloads qualify sender correlation only and never imply an actual grant: server status is asserted disconnected throughout. Browser UI3300/API5302/canonical configured AppPublicBaseUrl3000 interoperability, real provider callbacks/state exchange and backend qualification remain separate. All nonlocal requests are aborted; only exact GoogleFonts HTTPS GET css2 stylesheets are separately recorded as aborted resources. No provider/model/SMTP, Docker, deployment, main or Phase8 work. The blocked-popup branch is an explicitly controlled null return from window.open; browser popup-blocker/device/AT qualification is not claimed.';
|
|
function copy(key: string, language: 'en' | 'nb' = 'en', params: Record<string, string | number> = {}) { return ((translations[language] as Record<string, string>)[key] ?? key).replace(/\{(\w+)\}/g, (_, name: string) => String(params[name] ?? `{${name}}`)); }
|
|
function hold() { let release!: () => void; const pending = new Promise<void>(resolve => { release = resolve; }); return { pending, release }; }
|
|
async function json<T>(response: APIResponse | Response, status = 200): Promise<T> { expect(response.status(), response.url()).toBe(status); return response.json(); }
|
|
function count(session: Session, path: string) { return session.requests.filter(request => request.path === `/api${path}`); }
|
|
function response(page: Page, method: string, path: string) { return page.waitForResponse(value => value.request().method() === method && new URL(value.url()).pathname === `/api${path}`); }
|
|
async function keyboard(page: Page, control: Locator) { await expect(control).toBeVisible(); await expect(control).toBeEnabled(); await control.focus(); await expect(control).toBeFocused(); await page.keyboard.press('Enter'); }
|
|
async function settle(page: Page) { await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); }
|
|
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, email, headers: { 'X-CSRF-TOKEN': token!.value, 'X-Session-Context': me.sessionContext } }; }
|
|
async function disconnected(api: APIRequestContext) { for (const provider of ['gmail', 'microsoft-graph', 'imap']) expect(await json<{ connected: boolean }>(await api.get(`${apiUrl}/${provider}/status`))).toMatchObject({ connected: false }); }
|
|
async function proof(page: Page) { return page.evaluate(() => { const value = (window as InstrumentedWindow).__clientGuardProof; return { opens: value.opens.map(({ proxy, ...entry }) => ({ ...entry, closed: proxy?.closed ?? null })), messages: value.messages }; }); }
|
|
async function received(page: Page, marker: string) { await expect.poll(async () => (await proof(page)).messages.filter(message => message.message === marker).length).toBeGreaterThan(0); await settle(page); }
|
|
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 requests: RecordedRequest[] = []; const forbidden: string[] = []; const fonts: { url: string; resourceType: string }[] = [];
|
|
page.on('request', request => { const url = new URL(request.url()); if (url.pathname.startsWith('/api/')) requests.push({ method: request.method(), path: url.pathname, query: url.search }); });
|
|
await context.route('**/*', route => { const request = route.request(); const url = new URL(request.url()); if (url.protocol === 'https:' && url.hostname === 'fonts.googleapis.com' && url.port === '' && url.pathname === '/css2' && request.method() === 'GET' && request.resourceType() === 'stylesheet') { fonts.push({ url: url.href, resourceType: request.resourceType() }); return route.abort(); } const provider = /^\/api\/(gmail|microsoft-graph|imap)(?:\/|$)/.test(url.pathname); const allowedProviderRead = request.method() === 'GET' && /^\/api\/(gmail|microsoft-graph|imap)\/(status|connect-url)$/.test(url.pathname); if (provider && !allowedProviderRead || /^\/api\/(?:ai|email\/send)(?:\/|$)/.test(url.pathname) || !['blob:', 'data:', 'about:'].includes(url.protocol) && (!['localhost', '127.0.0.1'].includes(url.hostname) || !['3300', '5302'].includes(url.port))) { forbidden.push(`${request.method()} ${url.origin}${url.pathname}`); return route.abort(); } return route.continue(); });
|
|
await context.addInitScript(() => {
|
|
const browser = window as InstrumentedWindow; const originalOpen = window.open; const data: ClientProof = { opens: [], messages: [], blockNext: false }; browser.__clientGuardProof = data;
|
|
window.open = (url, target, features) => { const blocked = data.blockNext; data.blockNext = false; const proxy = blocked ? null : originalOpen.call(window, url, target, features); data.opens.push({ url: String(url), name: target ?? '', features: features ?? '', proxy, blocked }); return proxy; };
|
|
window.addEventListener('message', event => { const payload = event.data as { source?: unknown; status?: unknown; message?: unknown }; if (!payload || typeof payload.source !== 'string' || !payload.source.startsWith('jobtracker-')) return; data.messages.push({ origin: event.origin, source: payload.source, status: payload.status, message: payload.message, sourceOpenName: data.opens.find(entry => entry.proxy === event.source)?.name ?? null, sourceClosed: event.source ? (event.source as Window).closed : null, sourceIsSelf: event.source === window }); });
|
|
});
|
|
try { const administrator = await login(admin, 'e2e@example.test'); const email = `p709-client-guard-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; expect((await admin.post(`${apiUrl}/users`, { headers: administrator.headers, data: { email, password, roles: [] } })).status()).toBe(200); const owner = await login(page.request, email); expect((await json<{ entitlements: { ai: boolean } }>(await page.request.get(`${apiUrl}/auth/me`))).entitlements.ai).toBe(false); await disconnected(page.request); await provide({ owner, requests }); expect(forbidden).toEqual([]); }
|
|
finally { await info.attach('oauth-sender-client-guard-boundary', { body: JSON.stringify({ boundary, requests, forbiddenExternalProviderModelRequests: forbidden, blockedKnownFontStylesheets: fonts, qualification: 'No external font or provider was fetched. Actual backend callback/state interoperability and production font acceptance remain pending.' }), contentType: 'application/json' }); for (const child of context.pages().filter(candidate => candidate !== page)) await child.close(); await admin.dispose(); }
|
|
},
|
|
});
|
|
function discriminator(provider: Provider) { return provider === 'gmail' ? 'jobtracker-gmail-oauth' : 'jobtracker-microsoft-oauth'; }
|
|
function callbackPath(provider: Provider) { return `/api/${provider}/oauth/callback`; }
|
|
function childUrl(provider: Provider, mode: string, marker: string, origin = uiOrigin) { const url = new URL(callbackPath(provider), origin); url.searchParams.set('clientGuard', '1'); url.searchParams.set('mode', mode); url.searchParams.set('marker', marker); return url.href; }
|
|
async function childRoutes(context: BrowserContext) {
|
|
await context.route(url => url.pathname.startsWith('/__p709_client_guard__/') || url.searchParams.get('clientGuard') === '1' && ['/api/gmail/oauth/callback', '/api/microsoft-graph/oauth/callback'].includes(url.pathname), route => {
|
|
const url = new URL(route.request().url()); const provider: Provider = url.pathname.includes('microsoft-graph') ? 'microsoft-graph' : 'gmail'; const mode = url.searchParams.get('mode'); const marker = url.searchParams.get('marker') ?? 'CLIENT-GUARD waiting'; const payload = { source: discriminator(provider), status: 'connected', message: marker }; const send = `window.opener.postMessage(${JSON.stringify(payload)},${JSON.stringify(uiOrigin)});`; const script = mode === 'valid' ? `${send}${send}window.close();` : mode === 'forged' ? `${send}window.close();` : mode === 'wrong-origin' ? send : '';
|
|
return route.fulfill({ contentType: 'text/html', body: `<!doctype html><html><head><meta charset="utf-8"><title>CLIENT-GUARD fixture</title></head><body><h1>CLIENT-GUARD local sender fixture; no provider grant</h1><script>${script}</script></body></html>` });
|
|
});
|
|
}
|
|
async function projectConnect(page: Page, owner: Owner, provider: Provider, gate?: ReturnType<typeof hold>) {
|
|
const callback = new URL(callbackPath(provider), uiOrigin); const authorize = new URL('/__p709_client_guard__/authorize', uiOrigin); authorize.searchParams.set('redirect_uri', callback.href); authorize.searchParams.set('provider', provider);
|
|
await page.route(`${apiUrl}/${provider}/connect-url`, async route => { expect((await json<{ id: string }>(await route.fetch({ url: `${apiUrl}/auth/me` }))).id).toBe(owner.id); if (gate) await gate.pending; return route.fulfill({ status: 200, json: { url: authorize.href } }); }); return { authorize: authorize.href, callbackOrigin: callback.origin, callbackPath: callback.pathname };
|
|
}
|
|
async function enter(page: Page, target: string) { await page.addInitScript(() => { localStorage.setItem('uiLanguage', 'en'); localStorage.setItem('jobtracker.themeMode', 'light'); }); await page.setViewportSize({ width: 1440, height: 900 }); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto(target); await expect(page.locator('header').getByRole('button', { name: copy('notifications'), exact: true })).toBeVisible(); }
|
|
function connectRow(page: Page, provider: Provider) { return page.locator('main').getByText(provider === 'gmail' ? 'Gmail' : 'Outlook / Microsoft 365', { exact: true }).locator('..').locator('..'); }
|
|
async function openChild(page: Page, button: Locator) { const opened = page.waitForEvent('popup'); await keyboard(page, button); const child = await opened; await child.waitForLoadState('domcontentloaded'); return child; }
|
|
async function finish(child: Page, target: string) { const closed = child.waitForEvent('close'); await child.evaluate(url => { window.setTimeout(() => { location.href = url; }, 0); }, target); await closed; expect(child.isClosed()).toBe(true); }
|
|
async function ownedJob(api: APIRequestContext, owner: Owner, title: string) { const company = await json<{ id: number; name: string }>(await api.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: `CLIENT-GUARD company ${title}` } }), 201); const job = await json<{ id: number; jobTitle: string }>(await api.post(`${apiUrl}/jobapplications`, { headers: owner.headers, data: { companyId: company.id, jobTitle: title, status: 'Saved' } }), 201); return { ...job, companyName: company.name }; }
|
|
async function importDialog(page: Page) { await keyboard(page, page.getByRole('button', { name: copy('correspondenceImportEmail'), exact: true })); const dialog = page.getByRole('dialog', { name: copy('correspondenceImportTitle'), exact: true }); await expect(dialog).toBeVisible(); await keyboard(page, dialog.getByRole('tab', { name: copy('google'), exact: true })); return dialog; }
|
|
|
|
test('[P7-09 OAuth sender] actual local child proxy and callback origin bind one terminal Connections refresh while forged and duplicate messages are ignored', async ({ page, context, session }, info) => {
|
|
test.setTimeout(150_000); await childRoutes(context); const gmailRead = response(page, 'GET', '/gmail/status'); const microsoftRead = response(page, 'GET', '/microsoft-graph/status'); await enter(page, '/settings/connected-accounts'); expect((await gmailRead).status()).toBe(200); expect((await microsoftRead).status()).toBe(200);
|
|
const initialGmail = count(session, '/gmail/status').length; const initialMicrosoft = count(session, '/microsoft-graph/status').length;
|
|
await page.evaluate(() => window.postMessage({ source: 'jobtracker-gmail-oauth', status: 'connected', message: 'CLIENT-GUARD unsolicited-self' }, location.origin)); await received(page, 'CLIENT-GUARD unsolicited-self'); expect(count(session, '/gmail/status')).toHaveLength(initialGmail); expect(count(session, '/microsoft-graph/status')).toHaveLength(initialMicrosoft);
|
|
const fixtures = [];
|
|
for (const provider of ['gmail', 'microsoft-graph'] as const) {
|
|
const projection = await projectConnect(page, session.owner, provider); fixtures.push({ provider, ...projection }); const path = `/${provider}/status`; const otherPath = provider === 'gmail' ? '/microsoft-graph/status' : '/gmail/status'; const before = count(session, path).length; const otherBefore = count(session, otherPath).length;
|
|
const child = await openChild(page, connectRow(page, provider).getByRole('button', { name: copy('emailConnectionsConnect'), exact: true })); const opened = (await proof(page)).opens.at(-1)!; expect(opened).toMatchObject({ url: projection.authorize, name: provider === 'gmail' ? 'jobtracker-gmail-connect' : 'jobtracker-microsoft-connect', features: 'width=620,height=760,resizable=yes,scrollbars=yes', blocked: false }); expect(new URL(opened.url).searchParams.get('redirect_uri')).toBe(`${projection.callbackOrigin}${projection.callbackPath}`);
|
|
const rogueOpened = page.waitForEvent('popup'); const wrongWindowMarker = `CLIENT-GUARD wrong-window ${provider}`; await page.evaluate(url => { window.open(url, 'client-guard-wrong-window', 'width=360,height=240'); }, childUrl(provider, 'forged', wrongWindowMarker)); const rogue = await rogueOpened; await received(page, wrongWindowMarker); await expect.poll(() => rogue.isClosed()).toBe(true); expect((await proof(page)).messages.find(message => message.message === wrongWindowMarker)).toMatchObject({ origin: uiOrigin, sourceOpenName: 'client-guard-wrong-window', sourceClosed: true }); expect(count(session, path)).toHaveLength(before);
|
|
const wrongProvider = provider === 'gmail' ? 'microsoft-graph' : 'gmail'; const wrongProviderMarker = `CLIENT-GUARD wrong-provider ${provider}`; await child.evaluate(({ source, marker, origin }) => window.opener.postMessage({ source, status: 'connected', message: marker }, origin), { source: discriminator(wrongProvider), marker: wrongProviderMarker, origin: uiOrigin }); await received(page, wrongProviderMarker); expect((await proof(page)).messages.find(message => message.message === wrongProviderMarker)).toMatchObject({ origin: uiOrigin, sourceOpenName: provider === 'gmail' ? 'jobtracker-gmail-connect' : 'jobtracker-microsoft-connect', sourceClosed: false }); expect(count(session, path)).toHaveLength(before); expect(count(session, otherPath)).toHaveLength(otherBefore);
|
|
const wrongOriginMarker = `CLIENT-GUARD wrong-origin ${provider}`; await child.goto(childUrl(provider, 'wrong-origin', wrongOriginMarker, 'http://localhost:5302')); await received(page, wrongOriginMarker); expect((await proof(page)).messages.find(message => message.message === wrongOriginMarker)).toMatchObject({ origin: 'http://localhost:5302', sourceOpenName: provider === 'gmail' ? 'jobtracker-gmail-connect' : 'jobtracker-microsoft-connect', sourceClosed: false }); expect(count(session, path)).toHaveLength(before);
|
|
const validMarker = `CLIENT-GUARD valid-terminal ${provider}`; await finish(child, childUrl(provider, 'valid', validMarker)); await received(page, validMarker); await expect.poll(() => count(session, path).length).toBe(before + 1); expect(count(session, otherPath)).toHaveLength(otherBefore); const matched = (await proof(page)).messages.filter(message => message.message === validMarker); expect(matched).toHaveLength(2); for (const message of matched) expect(message).toMatchObject({ origin: projection.callbackOrigin, sourceOpenName: provider === 'gmail' ? 'jobtracker-gmail-connect' : 'jobtracker-microsoft-connect', sourceClosed: true, sourceIsSelf: false }); await disconnected(page.request); await expect(connectRow(page, provider).getByRole('button', { name: copy('emailConnectionsDisconnect'), exact: true })).toHaveCount(0);
|
|
}
|
|
expect(session.requests.filter(request => request.method !== 'GET')).toEqual([]); await info.attach('connections-client-guard-popup-proof', { body: JSON.stringify({ boundary, fixtures, actualWindowMessages: await proof(page), actualServerConnections: 'All three status endpoints remained connected=false before/after. No grant, token exchange or mailbox operation.' }), contentType: 'application/json' }); await info.attach('p709-client-guard-connections-disconnected', { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
|
|
});
|
|
|
|
test('[P7-09 OAuth sender] Timeline ignores forged senders and stale-job callbacks while one matched child starts only the canonical owned-job read flow', async ({ page, context, session }, info) => {
|
|
test.setTimeout(150_000); const first = await ownedJob(page.request, session.owner, 'CLIENT-GUARD first owned role'); const second = await ownedJob(page.request, session.owner, 'CLIENT-GUARD second owned role'); await childRoutes(context); await projectConnect(page, session.owner, 'gmail'); const candidateQueries: RecordedRequest[] = [];
|
|
await page.route(`${apiUrl}/gmail/job-candidates?**`, async route => { const url = new URL(route.request().url()); expect(route.request().method()).toBe('GET'); expect(url.searchParams.get('jobApplicationId')).toBe(String(first.id)); expect(url.searchParams.get('maxResultsPerQuery')).toBe('6'); expect(url.searchParams.get('queryOverride')).toBeNull(); expect((await json<{ id: number }>(await page.request.get(`${apiUrl}/jobapplications/${first.id}`))).id).toBe(first.id); candidateQueries.push({ method: 'GET', path: url.pathname, query: url.search }); return route.fulfill({ status: 200, json: { jobApplicationId: first.id, jobTitle: first.jobTitle, companyName: first.companyName, queries: [], threads: [] } }); });
|
|
await enter(page, `/jobs/${first.id}?section=timeline&sub=correspondence`); const dialog = await importDialog(page); const before = count(session, '/gmail/status').length;
|
|
await page.evaluate(() => window.postMessage({ source: 'jobtracker-gmail-oauth', status: 'connected', message: 'CLIENT-GUARD timeline unsolicited-self' }, location.origin)); await received(page, 'CLIENT-GUARD timeline unsolicited-self'); expect(candidateQueries).toEqual([]); expect(count(session, '/gmail/status')).toHaveLength(before);
|
|
const child = await openChild(page, dialog.getByRole('button', { name: copy('correspondenceConnectGmail'), exact: true })); await finish(child, childUrl('gmail', 'valid', 'CLIENT-GUARD timeline valid-terminal')); await received(page, 'CLIENT-GUARD timeline valid-terminal'); await expect.poll(() => candidateQueries.length).toBe(1); await expect.poll(() => count(session, '/gmail/status').length).toBe(before + 1); const matched = (await proof(page)).messages.filter(message => message.message === 'CLIENT-GUARD timeline valid-terminal'); expect(matched).toHaveLength(2); for (const message of matched) expect(message).toMatchObject({ origin: uiOrigin, sourceOpenName: 'jobtracker-gmail-connect', sourceClosed: true }); await disconnected(page.request);
|
|
const stale = await openChild(page, dialog.getByRole('button', { name: copy('correspondenceConnectGmail'), exact: true })); await page.keyboard.press('Escape'); await expect(dialog).toHaveCount(0); await page.keyboard.press('Control+k'); const palette = page.getByRole('dialog', { name: copy('quickSearch'), exact: true }); await palette.getByRole('textbox', { name: copy('quickSearch'), exact: true }).fill(second.jobTitle); await keyboard(page, palette.getByRole('button').filter({ hasText: second.jobTitle }).first()); await expect.poll(() => new URL(page.url()).pathname).toBe(`/jobs/${second.id}`); const afterNavigation = count(session, '/gmail/status').length;
|
|
await finish(stale, childUrl('gmail', 'valid', 'CLIENT-GUARD stale first-job terminal')); await received(page, 'CLIENT-GUARD stale first-job terminal'); expect(candidateQueries).toHaveLength(1); expect(count(session, '/gmail/status')).toHaveLength(afterNavigation); await disconnected(page.request); expect(session.requests.filter(request => request.method !== 'GET')).toEqual([]);
|
|
await info.attach('timeline-client-guard-popup-proof', { body: JSON.stringify({ boundary, firstJobId: first.id, secondJobId: second.id, candidateQueries, actualWindowMessages: await proof(page), qualification: 'Candidate GET is an explicit empty CLIENT-GUARD projection after actual owner authorization; server Gmail remains disconnected and no provider search occurs. Stale callback follows actual command-palette navigation/unmount.' }), contentType: 'application/json' });
|
|
});
|
|
|
|
test('[P7-09 OAuth sender] actual unconfigured Microsoft failure and anonymous401 stay separate from controlled popup-blocked current-language recovery and session disposal', async ({ page, context, session, playwright }, info) => {
|
|
test.setTimeout(150_000); await childRoutes(context); await enter(page, '/settings/connected-accounts'); const actualFailure = response(page, 'GET', '/microsoft-graph/connect-url'); await keyboard(page, connectRow(page, 'microsoft-graph').getByRole('button', { name: copy('emailConnectionsConnect'), exact: true })); const unconfigured = await actualFailure; expect(unconfigured.status()).toBe(500); expect((await proof(page)).opens).toEqual([]); await expect(page.getByRole('alert').filter({ hasText: 'An error occurred while' })).toBeVisible();
|
|
const gate = hold(); try {
|
|
await projectConnect(page, session.owner, 'gmail', gate); await page.evaluate(() => { (window as InstrumentedWindow).__clientGuardProof.blockNext = true; }); const connectRequest = page.waitForRequest(request => new URL(request.url()).pathname === '/api/gmail/connect-url'); await keyboard(page, connectRow(page, 'gmail').getByRole('button', { name: copy('emailConnectionsConnect'), exact: true })); await connectRequest;
|
|
const languageSave = response(page, 'PUT', '/auth/preferences/language'); await keyboard(page, page.locator('header').getByRole('button', { name: 'Norsk', exact: true })); expect((await languageSave).status()).toBe(204); gate.release(); await expect(page.getByRole('alert').filter({ hasText: copy('emailConnectionsPopupBlocked', 'nb') })).toBeVisible(); const blocked = (await proof(page)).opens.at(-1)!; expect(blocked).toMatchObject({ blocked: true, closed: null, name: 'jobtracker-gmail-connect' }); await disconnected(page.request);
|
|
} finally { gate.release(); }
|
|
const anonymous = await playwright.request.newContext(); try { for (const provider of ['gmail', 'microsoft-graph']) expect((await anonymous.get(`${apiUrl}/${provider}/connect-url`)).status()).toBe(401); } finally { await anonymous.dispose(); }
|
|
await info.attach('oauth-client-guard-startup-and-counted-block', { body: JSON.stringify({ boundary, actualMicrosoftConnectStatus: unconfigured.status(), configurationQualification: 'MicrosoftGraphOAuthService reads Microsoft:ClientId only; checked-in isolated development config lacks it and default exception handling returns500. No503/Gmail-unconfigured assumption.', actualAnonymousConnectStatuses: [401, 401], popupProofBeforeSessionDisposal: await proof(page), qualification: 'Popup blocking is a controlled null-return fixture. Current-language fallback is an actual UI language PUT204 during held authenticated controlled connect GET. Anonymous401 is real separate API evidence, not a fabricated browser response.' }), contentType: 'application/json' });
|
|
await page.unroute(`${apiUrl}/gmail/connect-url`); expect((await page.request.post(`${apiUrl}/auth/logout`, { headers: session.owner.headers })).status()).toBe(204); await expect(page).toHaveURL(/\/login(?:\?|$)/); await expect(page.locator('main').getByText('Outlook / Microsoft 365', { exact: true })).toHaveCount(0); await info.attach('oauth-client-guard-session-disposal', { body: JSON.stringify({ qualification: 'Actual logout clears shared cookies. The existing cookie-check session boundary disposes the earlier Connections UI; this is separate from the actual anonymous401 API checks above, not a fabricated browser401 response.', currentPath: new URL(page.url()).pathname }), contentType: 'application/json' });
|
|
});
|