Answer stability round 1: 1 🔴, 3 🟡 (0 misread); scope the visual selector, check label and cover the gaps

This commit is contained in:
2026-11-08 19:25:57 +01:00
parent 6767a5d7bd
commit 523e8b3724
6 changed files with 11 additions and 6 deletions
+1 -1
View File
@@ -312,7 +312,7 @@ test("the reference plugin: public Overview is open to all, My shifts takes any
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
await expect(page.locator('.sidebar a[href="/scheduling"]')).toHaveCount(1); // public Overview shown await expect(page.locator('.sidebar a[href="/scheduling"]')).toHaveCount(1); // public Overview shown
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out
await expect(page.locator('.sidebar a[href="/scheduling/mine"]')).toHaveCount(1); // session gate: a session is enough await expect(page.locator('.sidebar nav a[href="/scheduling/mine"]')).toHaveCount(1); // session gate: a session is enough
// No shifts upstream on this stack, so this also pins the degraded page: the reason, never a 500 // No shifts upstream on this stack, so this also pins the degraded page: the reason, never a 500
// and never a claim about what is assigned. // and never a claim about what is assigned.
+4
View File
@@ -77,6 +77,10 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
{ name: "a profile-menu entry naming no gate", files: { "nogateprofile/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ href: "/x", label: "X" }] };` }, match: /nogateprofile.*profile-menu entry "X".*names no gate/s }, { name: "a profile-menu entry naming no gate", files: { "nogateprofile/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ href: "/x", label: "X" }] };` }, match: /nogateprofile.*profile-menu entry "X".*names no gate/s },
{ name: "a profile-menu entry gating on a bare word", files: { "bareprofile/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ href: "/x", label: "X", permission: "admin" }] };` }, match: /bareprofile.*admin.*<resource>:<action>/s }, { name: "a profile-menu entry gating on a bare word", files: { "bareprofile/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ href: "/x", label: "X", permission: "admin" }] };` }, match: /bareprofile.*admin.*<resource>:<action>/s },
{ name: "a profile-menu entry without an href", files: { "nohrefprofile/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ label: "X", session: true }] };` }, match: /nohrefprofile.*profile-menu entry "X".*href/s }, { name: "a profile-menu entry without an href", files: { "nohrefprofile/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ label: "X", session: true }] };` }, match: /nohrefprofile.*profile-menu entry "X".*href/s },
{ name: "a profile-menu entry with an empty href", files: { "emptyhref/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ href: "", label: "X", session: true }] };` }, match: /emptyhref.*needs an href/s },
{ name: "a profile-menu entry without a label", files: { "nolabel/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ href: "/x", session: true }] };` }, match: /nolabel.*needs a label/s },
{ name: "an ungated profile-menu entry after a valid one", files: { "secondbad/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ href: "/a", label: "A", session: true }, { href: "/b", label: "B" }] };` }, match: /secondbad.*"B".*names no gate/s },
{ name: "a null profile-menu entry", files: { "nullentry/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [null] };` }, match: /nullentry.*needs an href/s },
{ name: "profileMenu that is not an array", files: { "objprofile/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: {} };` }, match: /objprofile.*"profileMenu" must be an array/s }, { name: "profileMenu that is not an array", files: { "objprofile/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: {} };` }, match: /objprofile.*"profileMenu" must be an array/s },
{ name: "a nav node gating on a bare word", files: { "barenav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", permission: "admin" }] };` }, match: /barenav.*admin.*<resource>:<action>/s }, { name: "a nav node gating on a bare word", files: { "barenav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", permission: "admin" }] };` }, match: /barenav.*admin.*<resource>:<action>/s },
{ name: "a declared permission that is a bare word", files: { "baredecl/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", permissions: [{ name: "admin" }] };` }, match: /baredecl.*admin.*<resource>:<action>/s }, { name: "a declared permission that is a bare word", files: { "baredecl/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", permissions: [{ name: "admin" }] };` }, match: /baredecl.*admin.*<resource>:<action>/s },
+1
View File
@@ -195,6 +195,7 @@ function findProfileMenuError(entries: PluginManifest["profileMenu"]): string |
for (const entry of Array.isArray(entries) ? entries : []) { for (const entry of Array.isArray(entries) ? entries : []) {
const what = `profile-menu entry "${entry?.label ?? "?"}"`; const what = `profile-menu entry "${entry?.label ?? "?"}"`;
if (typeof entry?.href !== "string" || entry.href === "") return `${what} needs an href`; if (typeof entry?.href !== "string" || entry.href === "") return `${what} needs an href`;
if (typeof entry.label !== "string" || entry.label === "") return `${what} needs a label`;
const err = gateError(what, entry); const err = gateError(what, entry);
if (err) return err; if (err) return err;
} }
+1 -1
View File
@@ -14,7 +14,7 @@ export type { RequestContext, User } from "../http/context.ts";
export type { PageChrome, ProfileMenuEntry } from "../ui/chrome.ts"; export type { PageChrome, ProfileMenuEntry } from "../ui/chrome.ts";
export type { NavNode } from "../ui/nav.ts"; export type { NavNode } from "../ui/nav.ts";
export { can, check, GuardError, requireSession } from "../auth/guards.ts"; export { can, check, GuardError, requireSession } from "../auth/guards.ts";
// The three coarse gates a route or nav node may declare — `Route` and `NavNode` both extend it. // The three coarse gates a route, nav node or profile-menu entry may declare — `Route`, `NavNode` and `ProfileMenuEntry` all extend it.
export type { Gate } from "../auth/gate.ts"; export type { Gate } from "../auth/gate.ts";
// Translation: `ctx.t` and the view-level `t(...)` do the work at runtime — these are for // Translation: `ctx.t` and the view-level `t(...)` do the work at runtime — these are for
// authoring a plugin's own catalogs (plugins/<id>/i18n/<locale>.ts) and for building a translator // authoring a plugin's own catalogs (plugins/<id>/i18n/<locale>.ts) and for building a translator
+1 -1
View File
@@ -138,7 +138,7 @@ test("findConflicts: a route, nav node or profile entry gating on a permission n
{ handler: noopHandler, method: "GET", path: "/y", permission: "both:read" }, { handler: noopHandler, method: "GET", path: "/y", permission: "both:read" },
{ handler: noopHandler, method: "POST", path: "/y", permission: "both:read" }, { handler: noopHandler, method: "POST", path: "/y", permission: "both:read" },
{ handler: noopHandler, method: "GET", path: "/z", permission: "declared:write" }, { handler: noopHandler, method: "GET", path: "/z", permission: "declared:write" },
], permissions: [], profileMenu: [{ href: "/p", label: "P", permission: "profileonly:read" }] }), ], permissions: [], profileMenu: [{ href: "/p", label: "P", permission: "profileonly:read" }, { href: "/q", label: "Q", session: true }, { href: "/r", label: "R", public: true }] }),
p({ id: "b", nav: [{ id: "b:y", label: "Y", permission: "both:read" }], permissions: [{ name: "declared:write" }] }), p({ id: "b", nav: [{ id: "b:y", label: "Y", permission: "both:read" }], permissions: [{ name: "declared:write" }] }),
]); ]);
assert.ok(gated.every((c) => c.kind === "permission" && c.level === "warn")); assert.ok(gated.every((c) => c.kind === "permission" && c.level === "warn"));
+3 -3
View File
@@ -96,18 +96,18 @@ const account: Plugin = {
{ href: "/account/help", label: "Help", public: true }, { href: "/account/help", label: "Help", public: true },
], ],
}; };
const entries = (opts: { permissions?: string[]; signedIn?: boolean }): { href: string; label: string }[] => const entries = (opts: { permissions?: string[]; signedIn?: boolean }): { href: string; icon?: string; label: string }[] =>
buildPluginChrome({ buildPluginChrome({
localeHref: (href) => `${href}?locale=sv-SE`, localeHref: (href) => `${href}?locale=sv-SE`,
menu: DEFAULT_MENU, menu: DEFAULT_MENU,
plugins: [account], plugins: [account],
translatorFor: () => (key) => (key === "account.settings" ? "Inställningar" : key), translatorFor: () => (key) => (key === "account.settings" ? "Inställningar" : key),
...(opts.signedIn === false ? {} : { user: { email: "ada@x.io", id: "u1", permissions: opts.permissions ?? [] } }), ...(opts.signedIn === false ? {} : { user: { email: "ada@x.io", id: "u1", permissions: opts.permissions ?? [] } }),
}).user.menu!.map(({ href, label }) => ({ href, label })); }).user.menu!;
test("profile menu entries are gated like nav nodes, translated by their plugin and carry the locale", () => { test("profile menu entries are gated like nav nodes, translated by their plugin and carry the locale", () => {
assert.deepEqual(entries({}), [ assert.deepEqual(entries({}), [
{ href: "/account/settings?locale=sv-SE", label: "Inställningar" }, { href: "/account/settings?locale=sv-SE", icon: "i-user", label: "Inställningar" },
{ href: "/account/help?locale=sv-SE", label: "Help" }, { href: "/account/help?locale=sv-SE", label: "Help" },
]); ]);
assert.deepEqual(entries({ permissions: ["billing:read"] }).map((e) => e.label), ["Inställningar", "account.billing", "Help"]); assert.deepEqual(entries({ permissions: ["billing:read"] }).map((e) => e.label), ["Inställningar", "account.billing", "Help"]);