ZDDC/tests/cap.spec.js
ZDDC 7c0b66590c feat(server,shared): tell denied users who can — subtly, before wasted effort
When a user lacks permission, the app should (a) not let them do data entry it
will reject and (b) subtly say who can. General mechanism + the key gates.

Server — compute & expose "who can <verb> here":
- zddc.WhoCan(chain, verb) → Authority{Roles, People}: the acl.permissions
  grantees holding the verb across the cascade (roles + their members) plus the
  admins (who bypass). New whocan.go + whocan_test.go.
- AccessView gains path_who_can (profilehandler.go), populated only for verbs the
  caller LACKS and only when they can read the path (mirrors .zddc readability),
  so one cap.at() answers "can I?" and "if not, who?".
- writeForbiddenWho enriches the 403 body with who_can for the missing verb
  (errors.go); authorizeAction uses it (fileapi.go) as the safety net for denials
  that weren't pre-checked.

Shared — shared/cap.js:
- cap.whoCan(view, verb) + cap.denyHint(view, verb) → {text, title}, role-first
  ("Only the document controller can create here") with the people in the tooltip.
- handleForbidden appends the hint (from the 403 body, else the cached view), so
  every tool that already routes 403s through it (form save, tables save, browse)
  now explains who can — for free.

Key gates:
- Browse party-create (the reported bug): pre-check create authority on ssr/ and
  the slot BEFORE opening the picker — if the user can do neither, show the hint
  instead of the form; if only existing parties are usable, disable "+ New party"
  with the who-can hint. The post-hoc 403 catch now names who can too.
- Tables +Add row disabled state shows the who-can hint.

Plus: subtle /_apps/{browse,archive,classifier}.html links in the landing footer.

Tests: Go WhoCan unit test (role/person split, admin bypass, dedupe); cap.spec.js
(denyHint role-first/people/fallback, whoCan, handleForbidden enrichment) — 5
green; Go handler+zddc+policy suites green. (Pre-existing stale browse toolbar
test browse.spec.js:274 unaffected.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:58:20 -05:00

66 lines
3.4 KiB
JavaScript

import { test, expect } from '@playwright/test';
import * as path from 'path';
// shared/cap.js — the "who can?" helpers (denyHint / whoCan) + handleForbidden
// enrichment. cap.js is bundled into every server-mode tool; tables.html is a
// convenient host. Pure helpers run fine on a file:// page (cap.at short-circuits
// offline, but denyHint/whoCan/handleForbidden don't need the network).
const HOST = 'file://' + path.resolve('tables/dist/tables.html');
async function load(page) {
await page.goto(HOST, { waitUntil: 'load' });
await page.waitForFunction(() => window.zddc && window.zddc.cap && window.zddc.cap.denyHint);
}
test.describe('cap.js — who-can hints', () => {
test('denyHint is role-first with people as the tooltip detail', async ({ page }) => {
await load(page);
const h = await page.evaluate(() => {
const view = { path_who_can: { c: { roles: ['document_controller'], people: ['alice@example.com', 'bob@example.com'] } } };
return window.zddc.cap.denyHint(view, 'c');
});
expect(h.text).toBe('Only the document controller can create here.'); // role-first, humanized
expect(h.title).toBe('alice@example.com, bob@example.com'); // people in the tooltip
});
test('denyHint names people when no role grants the verb', async ({ page }) => {
await load(page);
const h = await page.evaluate(() =>
window.zddc.cap.denyHint({ path_who_can: { w: { people: ['sam@example.com'] } } }, 'w'));
expect(h.text).toBe('Ask sam@example.com to write here.');
});
test('denyHint falls back to "an administrator" when nobody is named', async ({ page }) => {
await load(page);
const h = await page.evaluate(() => window.zddc.cap.denyHint({ path_who_can: {} }, 'd'));
expect(h.text).toBe('Ask an administrator to delete here.');
});
test('whoCan reads either a path view or a 403 body', async ({ page }) => {
await load(page);
const r = await page.evaluate(() => {
const fromView = window.zddc.cap.whoCan({ path_who_can: { c: { roles: ['r1'] } } }, 'c');
const fromBody = window.zddc.cap.whoCan({ missing_verb: 'c', who_can: { roles: ['r2'] } }, 'c');
const miss = window.zddc.cap.whoCan({ path_who_can: { w: {} } }, 'c');
return { fromView: fromView && fromView.roles[0], fromBody: fromBody && fromBody.roles[0], miss };
});
expect(r.fromView).toBe('r1');
expect(r.fromBody).toBe('r2');
expect(r.miss).toBeNull();
});
test('handleForbidden appends the who-can hint from the 403 body', async ({ page }) => {
await load(page);
const msg = await page.evaluate(async () => {
let captured = '';
window.zddc.toast = (m) => { captured = m; return document.createElement('div'); };
const body = JSON.stringify({ error: 'Forbidden', missing_verb: 'c', who_can: { roles: ['document_controller'], people: ['alice@example.com'] } });
const resp = new Response(body, { status: 403, headers: { 'Content-Type': 'application/json' } });
await window.zddc.cap.handleForbidden(resp, { context: 'Create' });
return captured;
});
expect(msg).toContain('You do not have create access here.');
expect(msg).toContain('Only the document controller can create here.'); // who-can appended
});
});