Files
qbit-authentik/scripts/Verify-Approval-UI.cjs
james.maesandGPT-6 2e356b8117
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow f1d46718955078331817c6206b822a94bc7d769f] (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 38s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 38s
qbit release candidate / cut-rc (pull_request) Failing after 18s
Release candidate / release-rc (pull_request) Failing after 18s
qbit release candidate / tag-and-release (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Failing after 15m39s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Failing after 24s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Failing after 1h14m34s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 10s
Shared Java Maven CI with Docker / verify (push) Failing after 17m19s
Shared Java Maven CI with Docker / gate (push) Failing after 24s
qrun-release / release (push) Failing after 1h16m30s
qrun-release / postpublish (push) Skipped
feat(authentik): checkpoint application access and identity synchronization RC
Add user and group QQQ application-role access editing, bounded catalogs,
webhook inbox and SQL mirrors, refreshed session permissions, and deferred
invitation assignments. Include the isolated local review host, browser
acceptance scripts, documentation and the token-widget column mapping fix.

User approved this RC checkpoint. Full 1.0 gates remain documented, including
the schema-drift matcher and PostgreSQL integration errors from the full gate.
Companion UI: QRun-IO/qqq-frontend-next#205.

Co-Authored-By: GPT-6 <noreply@openai.com>
2026-11-05 05:29:54 -06:00

109 lines
6.0 KiB
JavaScript

/* Copyright 2026 Kingsrook, LLC. Licensed under the Apache License, Version 2.0. */
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const { chromium, expect } = createRequire(path.join(process.env.USERPROFILE, '.cache', 'qqq-authentik-access-ui', 'package.json'))('@playwright/test');
const baseURL = 'http://127.0.0.1:18300';
const reason = 'Two-person UI acceptance ' + Date.now();
/** Independently read the synthetic role's actual provider permissions. */
async function role()
{
const root = fs.readFileSync('target/review-server.log', 'utf8').match(/REVIEW AUTHENTIK (http:\/\/127\.0\.0\.1:\d+)/)?.[1];
if(!root) throw new Error('Review host not ready');
const source = fs.readFileSync('src/test/java/com/kingsrook/qbits/authentik/contract/AuthentikTestStack.java', 'utf8');
const token = source.match(/BOOTSTRAP_TOKEN\s*=\s*"([^"]+)"/)[1];
const response = await fetch(root + '/api/v3/core/groups/?name=Review%20Editor', { headers: { Authorization: 'Bearer ' + token } });
if(!response.ok) throw new Error('Provider read failed');
const groups = (await response.json()).results;
expect(groups).toHaveLength(1);
return groups[0];
}
/** Read local records without trusting the process success message alone. */
async function records(page, table)
{
const response = await page.request.post(baseURL + '/qqq/v1/table/' + table + '/query?limit=100', { data: {} });
expect(response.ok()).toBeTruthy();
return (await response.json()).records.map(row => row.values);
}
/** Open a record action using its rendered menu. */
async function action(page, url, name, secondOperator = false)
{
await page.goto(url);
await page.getByRole('button', { name: 'Record actions menu', exact: true }).waitFor();
if(secondOperator)
{
// Anonymous UI boot creates a fresh session. Select the review-only
// second identity after boot and before invoking any write process.
await page.context().addCookies(['sessionId', 'sessionUUID'].map(cookie =>
({ name: cookie, value: 'qbit-authentik-review-second', url: baseURL })));
}
await page.getByRole('button', { name: 'Record actions menu', exact: true }).click();
await page.getByRole('menuitem', { name, exact: true }).click();
}
/** Execute the three-step process, keeping review and result visible. */
async function submit(page, hasDetails = true)
{
if(hasDetails) await page.getByRole('button', { name: 'Next', exact: true }).last().click();
await page.getByRole('form', { name: 'Review', exact: true }).waitFor();
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await page.getByRole('form', { name: 'Result', exact: true }).waitFor();
}
/** Request as one operator, refuse self-approval, apply as the other, restore. */
async function main()
{
const initial = await role();
expect(initial.attributes['qqq.io/permissions'].some(value => value.startsWith('authentik.'))).toBe(false);
const browser = await chromium.launch({ headless: true });
try
{
const first = await browser.newContext();
const second = await browser.newContext();
const requester = await first.newPage();
const approver = await second.newPage();
for(const page of [requester, approver])
{
page.setDefaultTimeout(20000);
page.setDefaultNavigationTimeout(20000);
}
const groupURL = baseURL + '/app/authentik_group/' + initial.pk;
await action(requester, groupURL, 'Edit Permissions And App Access');
await requester.getByRole('checkbox', { name: 'authentik.users.read.hasAccess', exact: true }).check();
await requester.getByRole('textbox', { name: 'Reason', exact: true }).fill(reason);
await submit(requester);
await expect(requester.getByRole('form', { name: 'Result', exact: true })).toContainText('sent for approval');
const requests = (await records(requester, 'authentik_approval_request')).filter(row => row.reason === reason);
expect(requests).toHaveLength(1);
expect(requests[0].requester).toBe('review.operator@example.test');
expect(requests[0].status).toBe('PENDING');
expect((await role()).attributes).toEqual(initial.attributes);
const approvalURL = baseURL + '/app/authentik_approval_request/' + requests[0].id;
await action(requester, approvalURL, 'Approve And Apply');
await submit(requester, false);
await expect(requester.getByRole('form', { name: 'Result', exact: true })).toContainText('cannot approve your own request');
expect((await records(requester, 'authentik_approval_request')).find(row => row.id === requests[0].id).status).toBe('PENDING');
expect((await role()).attributes).toEqual(initial.attributes);
await action(approver, approvalURL, 'Approve And Apply', true);
await submit(approver, false);
const decided = (await records(approver, 'authentik_approval_request')).find(row => row.id === requests[0].id);
expect(decided.status).toBe('APPROVED');
expect(decided.approver).toBe('review.second@example.test');
expect((await role()).attributes['qqq.io/permissions']).toContain('authentik.users.read.hasAccess');
await action(requester, groupURL, 'Edit Permissions And App Access');
await requester.getByRole('checkbox', { name: 'authentik.users.read.hasAccess', exact: true }).uncheck();
await requester.getByRole('textbox', { name: 'Reason', exact: true }).fill(reason + ' restore');
await submit(requester);
expect((await role()).attributes).toEqual(initial.attributes);
const audit = await records(requester, 'authentik_admin_action');
expect(audit.some(row => row.reason === reason && row.status === 'SUCCEEDED' && row.approver === 'review.second@example.test')).toBe(true);
console.log('PASS: UI approval request, provider unchanged while pending, self-approval refused, distinct live second operator approval, provider grant and SQL audit, role restored.');
}
finally { await browser.close(); }
}
main().catch(error => { console.error(error.message); process.exitCode = 1; });