Refuse an emailless identity where the session is minted, and rows the upstream should not have sent
CI / full-gate (push) Successful in 2m53s
CI / full-gate (push) Successful in 2m53s
This commit is contained in:
@@ -43,7 +43,7 @@ Your backend must expose two routes; the plugin treats any non-2xx as a recovera
|
||||
| Route | Request | Success | Response body |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /shifts` | `Accept: application/json`, optional `?assigneeId=<id>` | `200` | JSON array of `{ id, title, assignee, assigneeId, start, end }` (all strings; missing fields coerce to `""`). With `assigneeId`, only that person's rows |
|
||||
| `POST /shifts` | JSON body `{ title, assignee, assigneeId?, start, end }` | `2xx` | ignored (the plugin POST-redirect-GETs back to the list) |
|
||||
| `POST /shifts` | JSON body `{ title, assignee, start, end }` | `2xx` | ignored (the plugin POST-redirect-GETs back to the list) |
|
||||
|
||||
Domain rules (overlap, capacity, time ordering) live in your backend — reject with a 4xx and the
|
||||
form re-renders. The plugin only validates that `title` and `assignee` are non-empty.
|
||||
|
||||
@@ -191,3 +191,18 @@ test("my shifts scopes the upstream read by the visitor's id, and names them in
|
||||
// `session: true` guarantee the contract cannot state in the handler's type.
|
||||
await assert.rejects(async () => { await myShifts(fakeUpstream())(fakeCtx()); }, GuardError);
|
||||
});
|
||||
|
||||
test("my shifts degrades to the reason alone when the upstream is down, claiming nothing about what is assigned", async () => {
|
||||
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
|
||||
const down = fakeUpstream({ list: async () => { throw new UpstreamError("down", 503); } });
|
||||
const r = asView(await myShifts(down)(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
|
||||
assert.match(String(r.data["error"]), /scheduling service/i);
|
||||
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []); // mine.ejs drops the count + table while `error` is set
|
||||
});
|
||||
|
||||
test("my shifts drops a row the upstream returned that is not the visitor's", async () => {
|
||||
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
|
||||
const theirs: Shift = { assignee: "Avery Kline", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", end: "12:00", id: "9", start: "08:00", title: "Not mine" };
|
||||
const r = asView(await myShifts(fakeUpstream({ list: async () => [theirs] }))(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
|
||||
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []); // a backend ignoring the scope must not leak through this page
|
||||
});
|
||||
|
||||
@@ -198,8 +198,9 @@ export function myShifts(upstream: ShiftsUpstream): RouteHandler {
|
||||
let error: string | undefined;
|
||||
try {
|
||||
// Join on the id, never the email: an address is user-changeable and can be reassigned to
|
||||
// someone else, which would hand them the previous holder's rows.
|
||||
shifts = await upstream.list({ assigneeId: user.id });
|
||||
// someone else, which would hand them the previous holder's rows. The re-filter is
|
||||
// defence-in-depth: a backend that ignores an unknown query param would answer with everyone.
|
||||
shifts = (await upstream.list({ assigneeId: user.id })).filter((s) => s.assigneeId === user.id);
|
||||
} catch (err) {
|
||||
ctx.log.warn("scheduling upstream unreachable", { error: String(err) });
|
||||
error = ctx.t("scheduling.upstream.list");
|
||||
@@ -218,8 +219,7 @@ export function buildMineModel(opts: { chrome: PageChrome; email: string; error?
|
||||
table: {
|
||||
caption: t("scheduling.mine.title"),
|
||||
columns: [{ label: t("scheduling.table.shift") }, { label: t("scheduling.table.start") }, { label: t("scheduling.table.end") }],
|
||||
// Only when the upstream answered: a failed read knows nothing about what is assigned.
|
||||
...(opts.error === undefined ? { emptyText: t("scheduling.mine.empty", { email: opts.email }) } : {}),
|
||||
emptyText: t("scheduling.mine.empty", { email: opts.email }),
|
||||
rows: opts.shifts.map((s) => ({ cells: [{ rowHeader: { text: s.title } }, s.start, s.end], name: s.title })),
|
||||
},
|
||||
title: t("scheduling.mine.title"),
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : "";
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
body: '<div class="scheduling-page">' + alertHtml + '<p class="shift-count">' + count + '</p>' + tableHtml + '</div>',
|
||||
body: '<div class="scheduling-page">' + alertHtml + (locals.error ? '' : '<p class="shift-count">' + count + '</p>' + tableHtml) + '</div>',
|
||||
brand: chrome.brand,
|
||||
breadcrumbs,
|
||||
csrfToken: chrome.csrfToken,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// at your real service in production.
|
||||
//
|
||||
// GET /shifts → 200 [ { id, title, assigneeId, assignee, start, end }, … ] (?assigneeId=<id> → only theirs)
|
||||
// POST /shifts → 201 { id, … } (body: { title, assignee, assigneeId?, start, end })
|
||||
// POST /shifts → 201 { id, … } (body: { title, assignee, start, end })
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
@@ -42,7 +42,7 @@ createServer(async (req, res) => {
|
||||
}
|
||||
if (url.pathname === "/shifts" && req.method === "POST") {
|
||||
const b = await readBody(req);
|
||||
const shift = { id: randomUUID(), assignee: String(b.assignee ?? ""), assigneeId: String(b.assigneeId ?? ""), end: String(b.end ?? ""), start: String(b.start ?? ""), title: String(b.title ?? "") };
|
||||
const shift = { id: randomUUID(), assignee: String(b.assignee ?? ""), assigneeId: "", end: String(b.end ?? ""), start: String(b.start ?? ""), title: String(b.title ?? "") };
|
||||
shifts.push(shift);
|
||||
return json(res, 201, shift);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user