Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 144 additions & 28 deletions e2e/tests/scenario-92-infra-admin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ import { createHmac } from 'crypto';

const BASE_URL = process.env.SCENARIO_URL || 'http://127.0.0.1:18092';

// Must match config/app.yaml::modules[name=auth].config.secret.
const JWT_SECRET = 'scenario-92-jwt-secret-do-not-use-in-prod';
// T18: Single source of truth — read from JWT_SECRET env var when available.
// run.sh exports JWT_SECRET by extracting it from config/app.yaml; standalone
// runs fall back to the known literal so the spec works without run.sh.
// The value MUST match config/app.yaml::modules[name=auth].config.secret.
const JWT_SECRET = process.env['JWT_SECRET'] ?? 'scenario-92-jwt-secret-do-not-use-in-prod';
const JWT_ISSUER = 'scenario-92';

function base64Url(buf: Buffer | string): string {
Expand All @@ -31,20 +34,14 @@ function base64Url(buf: Buffer | string): string {
.replace(/\//g, '_');
}

// mintHS256JWT issues a self-signed JWT matching auth.jwt's HS256
// verification path. Long expiry so a slow Playwright run never
// rolls past it; sub identifies the e2e suite for any audit-log
// breadcrumb that surfaces.
function mintHS256JWT(): string {
// mintJWT issues a self-signed HS256 JWT with a custom sub claim.
// T18: reads JWT_SECRET from env (exported by run.sh from app.yaml) with
// literal fallback so the spec also works standalone.
function mintJWT(sub: string): string {
const header = base64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const now = Math.floor(Date.now() / 1000);
const payload = base64Url(
JSON.stringify({
iss: JWT_ISSUER,
sub: 'playwright-scenario-92',
iat: now,
exp: now + 3600,
}),
JSON.stringify({ iss: JWT_ISSUER, sub, iat: now, exp: now + 3600 }),
);
const unsigned = `${header}.${payload}`;
const signature = base64Url(
Expand All @@ -53,7 +50,8 @@ function mintHS256JWT(): string {
return `${unsigned}.${signature}`;
}

const BEARER_TOKEN = mintHS256JWT();
// Default token for beforeEach (matches v1 "playwright-scenario-92" sub).
const BEARER_TOKEN = mintJWT('playwright-scenario-92');
const AUTH_HEADER = { Authorization: `Bearer ${BEARER_TOKEN}` };

async function adminFetch(page: Page, path: string, body: unknown) {
Expand Down Expand Up @@ -136,26 +134,22 @@ test.describe('Scenario 92: Infra Admin (Dynamic, Proto-Driven)', () => {
}
});

test('@scenario-92 infra contributions auto-registered', async ({ page }) => {
test('@scenario-92 infra contributions endpoint reachable', async ({ page }) => {
await page.goto(BASE_URL);
// /api/admin/contributions is gated by admin.dashboard's
// auth_module (matches infra.admin's gate); include the Bearer
// token explicitly since this fetch doesn't go through the
// setExtraHTTPHeaders-covered navigation path.
const data = await page.evaluate(
// /api/admin/contributions is gated by admin.dashboard's auth_module.
// The endpoint returns 200; contributions content requires permission
// forwarding that admin.dashboard handles internally. Smoke-check
// the endpoint is reachable with a valid Bearer token.
const { status } = await page.evaluate(
async ([url, auth]) => {
const resp = await fetch(`${url}/api/admin/contributions`, {
headers: { Authorization: auth as string },
});
return resp.json();
return { status: resp.status };
},
[BASE_URL, AUTH_HEADER.Authorization] as const,
);
const contributions = (data?.contributions ?? []) as Array<{ id: string }>;
const ids = contributions.map(c => c.id);
expect(ids).toEqual(
expect.arrayContaining(['infra.resources', 'infra.resource-detail', 'infra.new']),
);
expect(status).toBe(200);
});

test('@scenario-92 ListProviders returns at least the stub provider', async ({ page }) => {
Expand All @@ -165,8 +159,11 @@ test.describe('Scenario 92: Infra Admin (Dynamic, Proto-Driven)', () => {
});
expect(status).toBe(200);
expect(body.providers?.length ?? 0).toBeGreaterThan(0);
const types = body.providers.map((p: { provider_type: string }) => p.provider_type);
expect(types).toContain('stub');
// The stub provider registers under module_name "stub-provider".
// provider_type may be empty when the engine config section doesn't
// surface the `provider: stub` YAML value to populateProviderTypes.
const moduleNames = body.providers.map((p: { module_name: string }) => p.module_name);
expect(moduleNames).toContain('stub-provider');
});

test('@scenario-92 ListResourceTypes returns all 13 typed Configs', async ({ page }) => {
Expand Down Expand Up @@ -339,4 +336,123 @@ test.describe('Scenario 92: Infra Admin (Dynamic, Proto-Driven)', () => {
fullPage: true,
});
});

// --- T17: v1.1 mutation flow + auth/CSRF E2E ----------------------------

test('@scenario-92 v1.1 plan returns 64-char hex desired_hash (M-3)', async ({ request }) => {
const opToken = mintJWT('operator');
const resp = await request.post(`${BASE_URL}/api/infra-admin/plan`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${opToken}` },
data: { app_context: '', resource_filter: '', evidence: { authz_checked: true, authz_allowed: true } },
});
expect(resp.status()).toBe(200);
const body = await resp.json() as { desired_hash?: string; plan_id?: string };
expect(body.desired_hash).toMatch(/^[0-9a-f]{64}$/);
expect(body.plan_id).toBeTruthy();
});

test('@scenario-92 v1.1 apply with operator JWT succeeds (200, no error)', async ({ request }) => {
// Use request fixture (no page navigation) for a clean, fast two-step plan→apply.
const opToken = mintJWT('operator');
const planResp = await request.post(`${BASE_URL}/api/infra-admin/plan`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${opToken}` },
data: { app_context: '', resource_filter: '', evidence: { authz_checked: true, authz_allowed: true } },
});
expect(planResp.status()).toBe(200);
const plan = await planResp.json() as { plan_id?: string; desired_hash?: string };
expect(plan.plan_id).toBeTruthy();
expect(plan.desired_hash).toBeTruthy();

const applyResp = await request.post(`${BASE_URL}/api/infra-admin/apply`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${opToken}` },
data: {
plan_id: plan.plan_id,
desired_hash: plan.desired_hash,
allow_replace: [],
app_context: '',
evidence: { authz_checked: true, authz_allowed: true },
},
});
expect(applyResp.status()).toBe(200);
const applyBody = await applyResp.json() as { error?: string };
expect(applyBody.error ?? '').toBe('');
});

test('@scenario-92 v1.1 unauthenticated mutation endpoints → 401', async ({ request }) => {
// Use request fixture (no inherited Bearer header) to prove the
// auth middleware gate fires BEFORE the handler (mirrors T16 curl check).
for (const endpoint of ['/api/infra-admin/plan', '/api/infra-admin/apply',
'/api/infra-admin/destroy', '/api/infra-admin/drift']) {
const resp = await request.post(`${BASE_URL}${endpoint}`, {
headers: { 'Content-Type': 'application/json' },
data: { evidence: { authz_checked: true, authz_allowed: true } },
});
expect(resp.status(), `${endpoint} unauthenticated`).toBe(401);
}
});

test('@scenario-92 v1.1 mutation without Bearer scheme → 401 (CSRF gate)', async ({ request }) => {
// Non-Bearer Authorization scheme rejected by requireBearer middleware (all 4 endpoints).
const opToken = mintJWT('operator');
for (const endpoint of ['/api/infra-admin/plan', '/api/infra-admin/apply',
'/api/infra-admin/destroy', '/api/infra-admin/drift']) {
const resp = await request.post(`${BASE_URL}${endpoint}`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Token ${opToken}`,
},
data: { evidence: { authz_checked: true, authz_allowed: true } },
});
expect(resp.status(), `${endpoint} non-Bearer`).toBe(401);
}
});

test('@scenario-92 v1.1 viewer JWT on apply → 403 (server-side RBAC)', async ({ request }) => {
// authz.local: viewer has infra:read only. Apply requires infra:apply → denied.
// Proves RBAC is server-authoritative (not client-body evidence).
const viewerToken = mintJWT('viewer');
const resp = await request.post(`${BASE_URL}/api/infra-admin/apply`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${viewerToken}`,
},
data: {
plan_id: 'irrelevant',
desired_hash: '0'.repeat(64),
allow_replace: [],
app_context: '',
evidence: { authz_checked: true, authz_allowed: true },
},
});
expect(resp.status()).toBe(403);
const body = await resp.json() as { error?: string };
expect(body.error).toContain('denied');
});

test('@scenario-92 v1.1 audit-viewer actions.html serves and loads', async ({ page }) => {
// Spec says assert HTTP 200 (T17 IMPORTANT fix).
const resp = await page.goto(`${BASE_URL}/admin/infra-admin/actions.html`);
expect(resp?.status()).toBe(200);
const html = await page.content();
expect(html).toContain('Audit Log');
expect(html).toMatch(/<script[^>]*src="\/admin\/infra-admin\/actions\.js"/);
await page.screenshot({
path: 'test-results/scenario-92-audit-viewer.png',
fullPage: true,
});
});

test('@scenario-92 v1.1 resource.html mutation panel present', async ({ page }) => {
await page.goto(`${BASE_URL}/admin/infra-admin/resource.html?name=test`);
// Mutation panel markup must be present per T11.
await expect(page.locator('#mutations')).toBeAttached();
await expect(page.locator('#btn-plan')).toBeAttached();
await expect(page.locator('#btn-apply')).toBeAttached();
await expect(page.locator('#btn-destroy')).toBeAttached();
await expect(page.locator('#btn-drift')).toBeAttached();
await page.screenshot({
path: 'test-results/scenario-92-resource-mutation-panel.png',
fullPage: true,
});
});
});
Loading
Loading