Files
pad/web/e2e/fixtures.ts
T
xarmian 2bd0862af2 fix(auth): platform administration refuses an API token (BUG-3361) (#1742)
* fix(auth): platform administration refuses an API token (BUG-3361)

BREAKING for anyone driving /api/v1/admin/* or admin account creation with
a personal access token: those now answer 403 session_required. Use a
`pad auth login` CLI session, or on self-host set PAD_ADMIN_API_TOKENS=allow.

An admin's PAT carried full platform authority. codex reproduced the chain
during BUG-3336's review: register an account with a chosen password,
promote it to admin, sign in as it, and reset any account's password. A
leaked admin token therefore escalated to interactive sessions for any
account.

- refuseAPITokenForAdmin guards the whole /api/v1/admin route group, so an
  admin route added later is refused by construction.
  TestBUG3361_EveryAdminRouteRefusesAnAdminPAT walks the router to hold it.
- handleRegister refuses admin-create signup with an API token.
- PAD_ADMIN_API_TOKENS=allow is the self-host escape hatch. It is read at
  startup, logs a WARN, and is never honoured in cloud mode. Documented in
  docs/deployment.md, Security.
- A browser session and a CLI session (padsess_) are unaffected. So is the
  Pad Cloud sidecar, which authenticates with the cloud secret, never a
  PAT. /audit-log already refused a PAT (BUG-3334).

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* test: drive platform administration with the admin's session, not its API token (BUG-3361)

CI on the first push: 70 E2E specs and TestMCPCapability_UpgradeChangesNothingWhileOff
called /api/v1/admin/* or admin account creation with an admin PAT, exactly
the use this change refuses. Our own harness was the first operator script
the breaking change broke.

- E2E: global setup keeps the session token login already returns as
  fixture.adminSessionToken (padsess_ is not User-Agent bound when sent as
  a Bearer). The seven admin call sites use it: account creation in
  permission-walk, workspace-tabs, workspace-access-live, no-commerce and
  account-delete; the admin user search; and PUT /admin/mcp. Every other
  call keeps the PAT, including the MCP client config in plan-2310.
- mcp_capability_test: the admin drives /admin with a CLI session and keeps
  its PAT for /mcp, which takes one.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* test(e2e): the admin console specs use the admin's session (BUG-3361)

CI on c383e89c: task-2979 (admin tab wrap) and task-3121 (decision
provider settings) open /console/admin in a page authenticated with the
suite's PAT, so the console's own /api/v1/admin calls are now refused.
Those two specs, the only ones that open the admin console, set the page
context's credential to fixture.adminSessionToken, and task-3121's
direct admin calls use it too.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 19:57:23 -04:00

88 lines
3.1 KiB
TypeScript

import { test as base, type BrowserContext } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
/**
* Shared test fixtures.
*
* Specs import `test` from here (not `@playwright/test`) so every
* BrowserContext is pre-authenticated with the admin's API token and
* every test gets the resolved suite fixture (baseURL, workspace slug,
* derived admin username).
*
* We use a Bearer token rather than a session cookie because the Pad
* server binds sessions to the originating User-Agent (see
* internal/server/middleware_auth.go). A session minted in node.js and
* replayed from a headless Chromium context would be silently rejected;
* tokens have no such binding.
*/
export interface SuiteFixture {
baseURL: string;
workspaceSlug: string;
adminEmail: string;
adminUsername: string;
apiToken: string;
// The admin's own session token (padsess_), for platform-administration
// calls: /api/v1/admin/* and account creation refuse an API token
// (BUG-3361). Sent as a Bearer, it is not bound to a User-Agent.
adminSessionToken: string;
}
function loadSuiteFixture(): SuiteFixture {
const path = resolve(HERE, '.auth', 'fixture.json');
return JSON.parse(readFileSync(path, 'utf8')) as SuiteFixture;
}
async function applyTokenAuth(context: BrowserContext, fixture: SuiteFixture) {
await context.setExtraHTTPHeaders({
Authorization: `Bearer ${fixture.apiToken}`
});
}
/**
* BUG-2334: silence CROSS-ACTOR "X created: …" SSE toasts in a context.
* Specs share one workspace, so other specs' seeding otherwise stacks
* toasts over bottom-right UI and races unrelated clicks. Page-own toasts
* are unaffected (see quietExternalToasts in the toast store). Applied to
* every built-in `context`; specs that build their OWN workspace-visiting
* contexts (browser.newContext) must call this too —
* sse-toast-quiet.spec.ts's control leg is the one deliberate exception,
* pinning the flag-off product behavior.
*/
export async function quietCrossActorToasts(context: BrowserContext): Promise<void> {
await context.addInitScript(() => {
try {
localStorage.setItem('pad:e2e-quiet-external-toasts', '1');
} catch {
// storage unavailable — fall through to prod behavior
}
});
}
// Standalone helper for the occasional spec that wants to build its
// own isolated context (e.g. to assert unauthed behaviour).
export function suiteFixture(): SuiteFixture {
return loadSuiteFixture();
}
// Extended test — use `import { test } from './fixtures'` in specs.
// The `context` fixture override applies the Bearer header once per
// context so downstream `page` navigation is authenticated.
export const test = base.extend<{ fixture: SuiteFixture }>({
context: async ({ context }, run) => {
const fixture = loadSuiteFixture();
await applyTokenAuth(context, fixture);
await quietCrossActorToasts(context);
await run(context);
},
fixture: async ({}, run) => {
await run(loadSuiteFixture());
}
});
export { expect } from '@playwright/test';