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
This commit is contained in:
xarmian
2026-10-02 22:17:24 +00:00
parent d2ee26cccd
commit c383e89c74
10 changed files with 41 additions and 24 deletions
+15 -9
View File
@@ -72,14 +72,15 @@ func capRoutes() []capRoute {
}
type capFixture struct {
store *store.Store
srv *server.Server
keyBytes []byte
admin *models.User
member *models.User
ws *models.Workspace
adminPAT string
memberPAT string
store *store.Store
srv *server.Server
keyBytes []byte
admin *models.User
member *models.User
ws *models.Workspace
adminPAT string // for /mcp, which takes a PAT
adminSession string // for /api/v1/admin, which refuses one (BUG-3361)
memberPAT string
// host is the Host header requests carry: the configured origin's,
// as a client using the configured URL (or a proxy forwarding it)
// sends, so the DR-6 allowlist admits them. "example.com" when no
@@ -126,6 +127,11 @@ func newCapFixture(t *testing.T, cfg config.Config, cloud bool, env *bool, setti
}
*u.dst = tok.Token
}
// The admin drives /api/v1/admin, which refuses an API token (BUG-3361):
// a CLI session, as `pad auth login` gives an operator.
if f.adminSession, err = s.CreateSession(f.admin.ID, "cli-browser-auth", "192.0.2.10", "", time.Hour); err != nil {
t.Fatalf("CreateSession admin: %v", err)
}
if setting != "" {
if err := s.SetPlatformSetting("mcp_enabled", setting); err != nil {
t.Fatalf("SetPlatformSetting: %v", err)
@@ -163,7 +169,7 @@ func (f *capFixture) do(t *testing.T, rt capRoute) *httptest.ResponseRecorder {
case "member":
req.Header.Set("Authorization", "Bearer "+f.memberPAT)
case "admin":
req.Header.Set("Authorization", "Bearer "+f.adminPAT)
req.Header.Set("Authorization", "Bearer "+f.adminSession)
}
rr := httptest.NewRecorder()
f.srv.ServeHTTP(rr, req)
+5 -5
View File
@@ -97,9 +97,9 @@ test.describe('Danger Zone: delete my account', () => {
});
test('password branch removes the account and redirects to /login', async ({ browser }) => {
const { baseURL, apiToken } = suiteFixture();
const { baseURL, adminSessionToken } = suiteFixture();
const identity = throwawayIdentity('pw');
await createThrowawayUser(baseURL, apiToken, identity, 'Delete PW');
await createThrowawayUser(baseURL, adminSessionToken, identity, 'Delete PW');
const context = await browser.newContext({ baseURL });
try {
@@ -129,7 +129,7 @@ test.describe('Danger Zone: delete my account', () => {
// (A GET on the generous API limiter, so it adds no /auth rate pressure.)
const admin = await playwrightRequest.newContext({
baseURL,
extraHTTPHeaders: { Authorization: `Bearer ${apiToken}` }
extraHTTPHeaders: { Authorization: `Bearer ${adminSessionToken}` }
});
try {
const resp = await admin.get(
@@ -145,9 +145,9 @@ test.describe('Danger Zone: delete my account', () => {
});
test('cloud OAuth-only typed-confirm branch redirects to /login', async ({ browser }) => {
const { baseURL, apiToken } = suiteFixture();
const { baseURL, adminSessionToken } = suiteFixture();
const identity = throwawayIdentity('cloud');
await createThrowawayUser(baseURL, apiToken, identity, 'Delete Cloud');
await createThrowawayUser(baseURL, adminSessionToken, identity, 'Delete Cloud');
const context = await browser.newContext({ baseURL });
try {
+4
View File
@@ -26,6 +26,10 @@ export interface SuiteFixture {
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 {
+6 -1
View File
@@ -60,6 +60,10 @@ export default async function globalSetup(config: FullConfig) {
if (!login.ok()) {
throw new Error(`login failed (${login.status()}): ${await login.text()}`);
}
const loginPayload = (await login.json()) as { token?: string };
if (!loginPayload.token) {
throw new Error('login succeeded but response missing the session token');
}
// State-changing endpoints require the CSRF double-submit header
// matching the pad_csrf cookie issued at login.
@@ -128,7 +132,8 @@ export default async function globalSetup(config: FullConfig) {
workspaceSlug: resolvedSlug,
adminEmail: ADMIN_EMAIL,
adminUsername: me.username,
apiToken: tokenPayload.token
apiToken: tokenPayload.token,
adminSessionToken: loginPayload.token
},
null,
2
+4 -2
View File
@@ -153,10 +153,12 @@ async function createItems(
/** Seed one walk world. Each call builds a fresh, independent one. */
export async function seedPermissionWalk(): Promise<PermissionWalk> {
const { baseURL, apiToken } = suiteFixture();
const { baseURL, adminSessionToken } = suiteFixture();
const tag = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const admin = await bearer(baseURL, apiToken);
// Account creation is platform administration: the admin's session, not
// its API token (BUG-3361).
const admin = await bearer(baseURL, adminSessionToken);
const accounts = {} as Record<AccountKey, WalkAccount>;
try {
for (const key of ACCOUNT_KEYS) {
+2 -2
View File
@@ -17,11 +17,11 @@ export const NAMES = [
export type Box = { x: number; y: number; width: number; height: number };
export async function asNewUser(page: Page): Promise<{ username: string; slugs: string[] }> {
const { baseURL, apiToken } = suiteFixture();
const { baseURL, adminSessionToken } = suiteFixture();
const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
const email = `e2e-tabs-${suffix}@example.com`;
const username = `e2etabs${suffix}`;
const api = await playwrightRequest.newContext({ baseURL, extraHTTPHeaders: { Authorization: `Bearer ${apiToken}` } });
const api = await playwrightRequest.newContext({ baseURL, extraHTTPHeaders: { Authorization: `Bearer ${adminSessionToken}` } });
try {
const r = await api.post('/api/v1/auth/register', { data: { email, username, name: 'Tabs', password: PASSWORD } });
if (!r.ok()) throw new Error(`register failed (${r.status()}): ${await r.text()}`);
+1 -1
View File
@@ -50,7 +50,7 @@ test('http self-host: the modal token config reaches /mcp and lists tools', asyn
const admin = await playwrightRequest.newContext({
baseURL: fixture.baseURL,
extraHTTPHeaders: { Authorization: `Bearer ${fixture.apiToken}` }
extraHTTPHeaders: { Authorization: `Bearer ${fixture.adminSessionToken}` }
});
try {
// Inside the try: a failed readiness assertion after the PUT
+2 -2
View File
@@ -92,8 +92,8 @@ for (const leg of [
]) {
test(`TASK-3293: ${leg.name}`, async ({ browser }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'UA is the variable, not viewport; one project keeps auth rate-limit pressure down.');
const { baseURL, apiToken } = suiteFixture();
const email = await register(baseURL, apiToken, leg.shell ? 's' : 'b');
const { baseURL, adminSessionToken } = suiteFixture();
const email = await register(baseURL, adminSessionToken, leg.shell ? 's' : 'b');
const context = await browser.newContext({ baseURL, userAgent: leg.ua });
try {
const page = await context.newPage();
+1 -1
View File
@@ -32,7 +32,7 @@ async function ok(resp: Awaited<ReturnType<APIRequestContext['get']>>, what: str
async function mintAccount(fixture: SuiteFixture, tag: string): Promise<Account> {
const admin = await request.newContext({
baseURL: fixture.baseURL,
extraHTTPHeaders: { Authorization: `Bearer ${fixture.apiToken}` }
extraHTTPHeaders: { Authorization: `Bearer ${fixture.adminSessionToken}` }
});
const session = await request.newContext({ baseURL: fixture.baseURL });
try {
+1 -1
View File
@@ -30,7 +30,7 @@ async function ok(resp: Awaited<ReturnType<APIRequestContext['get']>>, what: str
async function mintAccount(fixture: SuiteFixture, tag: string): Promise<Account> {
const admin = await request.newContext({
baseURL: fixture.baseURL,
extraHTTPHeaders: { Authorization: `Bearer ${fixture.apiToken}` }
extraHTTPHeaders: { Authorization: `Bearer ${fixture.adminSessionToken}` }
});
try {
const username = `tabs${tag}`;