CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 51s
CI / migration-smoke (pull_request) Successful in 2m29s
CI / image-build (pull_request) Successful in 1m4s
CI / deploy-smoke (same-origin) (pull_request) Successful in 54s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m56s
The Settings foundation (ADR 0061):
- URL model. /settings is a real landing page. The platform sections
are /settings/general, /branding, /displays, and /system. Module
lifecycle is Discover: /settings/discover/browse, /installed, and
/updates. /settings/discover is respelled in place to Browse's one
canonical address. A module's settings are /settings/<module-id>,
never nested under Discover. Deeper Settings addresses are
reserved. "Modules" stays the user-facing word: "Discover modules",
"Installed modules", "Module updates".
- Collisions. Core's one reserved list gains the Settings section
names (branding, discover, displays, general, system) as a second
documented group, so no module id can shadow a section; `modules` is
an ordinary id. apps/api tests hold it equal to the shell's sections.
- Navigation. One Settings item in the same sidebar expands in place
into Platform and Modules (and Discover's three views) while in
Settings, and the phone drawer has the same hierarchy. The rest of
the navigation stays.
- Permissions. GET /api/v1/dashboard/settings evaluates, through
Core's evaluator, which destinations the viewer may see:
- Displays: core.devices.manage
- Discover: core.modules.lifecycle.manage
- a module's settings: core.modules.configuration.manage, for each
module that declares configuration (ADR 0029)
- General, Branding, System: the platform owner, until each has its
own permission
These are presentation hints; every data route keeps its own
enforcement. With nothing visible, there is no Settings link.
- Pages are honest placeholders with Back to Settings. A disabled
module's settings say it is turned off; a section or module the
person cannot see says so without detail; an unknown name is not
found; a failed load withholds the link and offers Retry.
- The deploy smoke opens Settings by deep link, refreshes a section,
enters Discover, opens the module's settings, uses the phone
drawer, and returns to the Dashboard.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
109 lines
3.7 KiB
TypeScript
109 lines
3.7 KiB
TypeScript
// @vitest-environment node
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { dashboardBack } from "../src/browser/dashboard-back.js";
|
|
import { createMemoryHostHistory } from "../src/browser/host-history.js";
|
|
import { parseReturnTarget } from "../src/return-target.js";
|
|
|
|
describe("parseReturnTarget", () => {
|
|
it("accepts a Dashboard page on this origin, as its canonical destination", () => {
|
|
expect(parseReturnTarget("/")).toEqual({ kind: "overview" });
|
|
expect(parseReturnTarget("/signage")).toEqual({ kind: "module", moduleId: "signage" });
|
|
expect(parseReturnTarget("/signage/")).toEqual({ kind: "module", moduleId: "signage" });
|
|
// Settings pages are Dashboard pages too (ADR 0061).
|
|
expect(parseReturnTarget("/settings")).toEqual({ kind: "settings" });
|
|
expect(parseReturnTarget("/settings/signage")).toEqual({
|
|
kind: "module-settings",
|
|
moduleId: "signage",
|
|
});
|
|
});
|
|
|
|
it("refuses anything that is not a string, or is empty or enormous", () => {
|
|
for (const value of [undefined, null, 42, {}, ["/signage"], "", `/${"a".repeat(3000)}`]) {
|
|
expect(parseReturnTarget(value), JSON.stringify(value)).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("refuses every way of naming another origin", () => {
|
|
for (const value of [
|
|
"https://evil.example/signage",
|
|
"http://localhost/signage",
|
|
"//evil.example/signage",
|
|
"/\\evil.example",
|
|
"\\\\evil.example",
|
|
"javascript:alert(1)",
|
|
"data:text/html,hi",
|
|
" /signage",
|
|
"/signage\n",
|
|
"/sign\tage",
|
|
"signage",
|
|
]) {
|
|
expect(parseReturnTarget(value), value).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("refuses traversal and encoded separators that would normalize into something else", () => {
|
|
for (const value of [
|
|
"/signage/../settings",
|
|
"/./signage",
|
|
"/signage/%2e%2e/api",
|
|
"/%2F%2Fevil.example",
|
|
"/%5Cevil.example",
|
|
"/signage%2Fx",
|
|
]) {
|
|
expect(parseReturnTarget(value), value).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("refuses a query or fragment, and any path the Dashboard has no page for", () => {
|
|
for (const value of [
|
|
"/signage?x=1",
|
|
"/signage#top",
|
|
"/api/v1/principal",
|
|
"/display/signage",
|
|
"/settings/displays/add",
|
|
"/signage/timelines",
|
|
]) {
|
|
expect(parseReturnTarget(value), value).toBeUndefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("dashboardBack", () => {
|
|
it("goes back to a validated origin when one was supplied", () => {
|
|
const history = createMemoryHostHistory("/calendar");
|
|
const back = dashboardBack(history, {
|
|
fallback: { kind: "overview" },
|
|
label: (destination) => (destination.kind === "module" ? "Back to Signage" : "Back"),
|
|
returnTarget: "/signage",
|
|
});
|
|
|
|
expect(back.href).toBe("/signage");
|
|
expect(back.label).toBe("Back to Signage");
|
|
back.onBack();
|
|
expect(history.entries).toEqual(["/calendar", "/signage"]);
|
|
});
|
|
|
|
it("falls back to the page's own parent when the target is missing or unsafe", () => {
|
|
for (const returnTarget of [undefined, "https://evil.example/", "//evil.example", "/nope/x"]) {
|
|
const history = createMemoryHostHistory("/calendar");
|
|
const back = dashboardBack(history, {
|
|
fallback: { kind: "overview" },
|
|
returnTarget,
|
|
});
|
|
expect(back.href, String(returnTarget)).toBe("/");
|
|
expect(back.label, String(returnTarget)).toBe("Back");
|
|
back.onBack();
|
|
expect(history.entries, String(returnTarget)).toEqual(["/calendar", "/"]);
|
|
}
|
|
});
|
|
|
|
it("never leaves the Dashboard through the browser's own history", () => {
|
|
const history = createMemoryHostHistory("/calendar");
|
|
const go = vi.spyOn(history, "go");
|
|
|
|
dashboardBack(history, { fallback: { kind: "overview" } }).onBack();
|
|
|
|
expect(go).not.toHaveBeenCalled();
|
|
});
|
|
});
|