@pplethai/cli 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +171 -22
- package/package.json +3 -2
- package/templates/golden-path/.dev.vars.example +19 -1
- package/templates/golden-path/CLAUDE.md +51 -13
- package/templates/golden-path/README.md +27 -2
- package/templates/golden-path/gitignore +3 -0
- package/templates/golden-path/overlay-mtime.mjs +30 -0
- package/templates/golden-path/package-lock.json +19 -1
- package/templates/golden-path/package.json +3 -2
- package/templates/golden-path/scripts/assert-dev-compiled-out.mjs +42 -0
- package/templates/golden-path/shared/api.ts +25 -2
- package/templates/golden-path/src/auth.ts +72 -7
- package/templates/golden-path/src/call-service.ts +140 -0
- package/templates/golden-path/src/dataset-query.ts +470 -0
- package/templates/golden-path/src/dataset-schema.ts +398 -0
- package/templates/golden-path/src/dev-call-service.ts +391 -0
- package/templates/golden-path/src/dev-personas.ts +49 -15
- package/templates/golden-path/src/dev-read-dataset.ts +429 -0
- package/templates/golden-path/src/dev-registry.ts +276 -0
- package/templates/golden-path/src/env.ts +13 -2
- package/templates/golden-path/src/identity-header.ts +17 -0
- package/templates/golden-path/src/index.ts +32 -1
- package/templates/golden-path/src/legal-identity.ts +47 -28
- package/templates/golden-path/src/levels.ts +132 -0
- package/templates/golden-path/src/read-dataset.ts +376 -0
- package/templates/golden-path/src/registry.ts +122 -0
- package/templates/golden-path/src/vite-env.d.ts +13 -0
- package/templates/golden-path/test/call-service.test.ts +178 -0
- package/templates/golden-path/test/dev-call-service.test.ts +279 -0
- package/templates/golden-path/test/dev-read-dataset.test.ts +270 -0
- package/templates/golden-path/test/dev-registry.test.ts +375 -0
- package/templates/golden-path/test/effective-sub.test.ts +101 -0
- package/templates/golden-path/test/fixtures/overlay/directory/branches/entry.md +85 -0
- package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/audience.json +3 -0
- package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/named.json +3 -0
- package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/offices.json +6 -0
- package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/roster.json +3 -0
- package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/summary.json +1 -0
- package/templates/golden-path/test/fixtures/overlay/platform/legal-identity/entry.md +8 -0
- package/templates/golden-path/test/fixtures/overlay/platform/legal-identity/mocks/responses.json +13 -0
- package/templates/golden-path/test/fixtures/overlay/shop/catalog/entry.md +8 -0
- package/templates/golden-path/test/fixtures/overlay/shop/catalog/mocks/responses.json +7 -0
- package/templates/golden-path/test/fixtures/overlay/shop/orders/entry.md +12 -0
- package/templates/golden-path/test/fixtures/overlay/shop/orders/mocks/responses.json +36 -0
- package/templates/golden-path/test/harness.ts +101 -16
- package/templates/golden-path/test/legal-identity.test.ts +91 -24
- package/templates/golden-path/test/membership.test.ts +105 -0
- package/templates/golden-path/test/read-dataset.test.ts +214 -0
- package/templates/golden-path/test/service-call.test.ts +166 -0
- package/templates/golden-path/vite.config.ts +2 -1
- package/templates/golden-path/vitest.config.ts +2 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// Offline Provider development (ADR-0026). What the handler observes:
|
|
2
|
+
// serviceCall, the tail path, and the status. The dev wrapper is the Worker
|
|
3
|
+
// entry under vitest, where import.meta.env.DEV is true.
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { DEV_PERSONAS } from "../src/dev-personas";
|
|
7
|
+
import { devWorkerFetch } from "../src/dev-registry";
|
|
8
|
+
import worker from "../src/index";
|
|
9
|
+
import { serviceCall } from "../src/registry";
|
|
10
|
+
import { makeHarness, testExecutionContext } from "./harness";
|
|
11
|
+
|
|
12
|
+
const ctx = testExecutionContext();
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
const registry = worker.registry ?? {};
|
|
16
|
+
for (const key of Object.keys(registry)) delete registry[key];
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function install(name: string): { request?: Request } {
|
|
20
|
+
const seen: { request?: Request } = {};
|
|
21
|
+
if (!worker.registry) throw new Error("worker has no registry");
|
|
22
|
+
worker.registry[name] = (request) => {
|
|
23
|
+
seen.request = request;
|
|
24
|
+
const url = new URL(request.url);
|
|
25
|
+
return Response.json({
|
|
26
|
+
call: serviceCall(request),
|
|
27
|
+
path: url.pathname,
|
|
28
|
+
search: url.search,
|
|
29
|
+
method: request.method,
|
|
30
|
+
authorization: request.headers.get("authorization"),
|
|
31
|
+
secret: request.headers.get("x-pple-cron-secret"),
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
return seen;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("the emulated registry path", () => {
|
|
38
|
+
it("delivers the tail to the handler with the default Persona and dev-consumer", async () => {
|
|
39
|
+
install("branch-list");
|
|
40
|
+
const h = makeHarness();
|
|
41
|
+
|
|
42
|
+
const res = await worker.fetch(
|
|
43
|
+
new Request("https://localhost/.pple/registry/branch-list/offices?q=1"),
|
|
44
|
+
h.testEnv,
|
|
45
|
+
ctx,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
expect(res.status).toBe(200);
|
|
49
|
+
expect(await res.json()).toEqual({
|
|
50
|
+
call: {
|
|
51
|
+
consumer: "dev-consumer",
|
|
52
|
+
entry: "dev-provider/branch-list",
|
|
53
|
+
identity: DEV_PERSONAS["hq-staff"],
|
|
54
|
+
},
|
|
55
|
+
path: "/offices",
|
|
56
|
+
search: "?q=1",
|
|
57
|
+
method: "GET",
|
|
58
|
+
authorization: null,
|
|
59
|
+
secret: null,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("uses DEV_PERSONA and DEV_CONSUMER, and ignores forged platform headers", async () => {
|
|
64
|
+
install("branch-list");
|
|
65
|
+
const h = makeHarness({}, { DEV_PERSONA: "province-coordinator", DEV_CONSUMER: "brave-purple-lion" });
|
|
66
|
+
|
|
67
|
+
const res = await worker.fetch(
|
|
68
|
+
new Request("https://localhost/.pple/registry/branch-list", {
|
|
69
|
+
headers: {
|
|
70
|
+
"x-pple-consumer": "forged",
|
|
71
|
+
"x-pple-entry": "forged/nope",
|
|
72
|
+
"x-pple-identity": "forged",
|
|
73
|
+
authorization: "Bearer raw-token",
|
|
74
|
+
"x-pple-cron-secret": "s3cret",
|
|
75
|
+
},
|
|
76
|
+
}),
|
|
77
|
+
h.testEnv,
|
|
78
|
+
ctx,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(await res.json()).toMatchObject({
|
|
82
|
+
call: {
|
|
83
|
+
consumer: "brave-purple-lion",
|
|
84
|
+
entry: "dev-provider/branch-list",
|
|
85
|
+
identity: DEV_PERSONAS["province-coordinator"],
|
|
86
|
+
},
|
|
87
|
+
path: "/",
|
|
88
|
+
authorization: null,
|
|
89
|
+
secret: null,
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("sends no Identity when DEV_PERSONA is none", async () => {
|
|
94
|
+
install("branch-list");
|
|
95
|
+
const h = makeHarness({}, { DEV_PERSONA: "none" });
|
|
96
|
+
|
|
97
|
+
const res = await worker.fetch(
|
|
98
|
+
new Request("https://localhost/.pple/registry/branch-list/offices"),
|
|
99
|
+
h.testEnv,
|
|
100
|
+
ctx,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
expect(await res.json()).toMatchObject({
|
|
104
|
+
call: { consumer: "dev-consumer", entry: "dev-provider/branch-list", identity: null },
|
|
105
|
+
path: "/offices",
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("sends no Identity when the Persona name does not exist", async () => {
|
|
110
|
+
install("branch-list");
|
|
111
|
+
const h = makeHarness({}, { DEV_PERSONA: "not-a-persona" });
|
|
112
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
113
|
+
try {
|
|
114
|
+
const res = await worker.fetch(
|
|
115
|
+
new Request("https://localhost/.pple/registry/branch-list/"),
|
|
116
|
+
h.testEnv,
|
|
117
|
+
ctx,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
expect(await res.json()).toMatchObject({
|
|
121
|
+
call: { consumer: "dev-consumer", identity: null },
|
|
122
|
+
path: "/",
|
|
123
|
+
});
|
|
124
|
+
expect(warn).toHaveBeenCalled();
|
|
125
|
+
} finally {
|
|
126
|
+
warn.mockRestore();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("passes the method and body through, and a non-fetch value throws", async () => {
|
|
131
|
+
const seen: { body?: string; type?: string | null; connection?: string | null } = {};
|
|
132
|
+
if (!worker.registry) throw new Error("worker has no registry");
|
|
133
|
+
worker.registry["branch-list"] = async (request) => {
|
|
134
|
+
seen.body = await request.text();
|
|
135
|
+
seen.type = request.headers.get("content-type");
|
|
136
|
+
seen.connection = request.headers.get("connection");
|
|
137
|
+
return new Response("created", { status: 201, headers: { "x-pple-extra": "kept-here" } });
|
|
138
|
+
};
|
|
139
|
+
const h = makeHarness();
|
|
140
|
+
|
|
141
|
+
const res = await worker.fetch(
|
|
142
|
+
new Request("https://localhost/.pple/registry/branch-list/items", {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: { "content-type": "text/plain", connection: "close" },
|
|
145
|
+
body: "เนื้อหา",
|
|
146
|
+
}),
|
|
147
|
+
h.testEnv,
|
|
148
|
+
ctx,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
expect(res.status).toBe(201);
|
|
152
|
+
expect(await res.text()).toBe("created");
|
|
153
|
+
expect(res.headers.get("x-pple-extra")).toBe("kept-here");
|
|
154
|
+
expect(seen).toEqual({ body: "เนื้อหา", type: "text/plain", connection: null });
|
|
155
|
+
|
|
156
|
+
Object.assign(worker.registry, { "branch-list": { pages: () => ({}) } });
|
|
157
|
+
await expect(
|
|
158
|
+
worker.fetch(new Request("https://localhost/.pple/registry/branch-list"), h.testEnv, ctx),
|
|
159
|
+
).rejects.toThrow("registry handler missing");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("calls a Dataset page function with the cursor and answers with the page", async () => {
|
|
163
|
+
if (!worker.registry) throw new Error("worker has no registry");
|
|
164
|
+
let cursor: string | null = "unset";
|
|
165
|
+
worker.registry["branch-list"] = {
|
|
166
|
+
offices: (input) => {
|
|
167
|
+
cursor = input.cursor;
|
|
168
|
+
return { objects: [{ id: "a", name: "สาขา" }], next: null };
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
const h = makeHarness();
|
|
172
|
+
|
|
173
|
+
const res = await worker.fetch(
|
|
174
|
+
new Request("https://localhost/.pple/registry/branch-list/offices", {
|
|
175
|
+
method: "POST",
|
|
176
|
+
headers: { "content-type": "application/json" },
|
|
177
|
+
body: JSON.stringify({ cursor: "a" }),
|
|
178
|
+
}),
|
|
179
|
+
h.testEnv,
|
|
180
|
+
ctx,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
expect(res.status).toBe(200);
|
|
184
|
+
expect(await res.json()).toEqual({ objects: [{ id: "a", name: "สาขา" }], next: null });
|
|
185
|
+
expect(cursor).toBe("a");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("calls a fetch-shaped object with itself as this", async () => {
|
|
189
|
+
const app = {
|
|
190
|
+
marker: "hono",
|
|
191
|
+
fetch(this: { marker: string }, request: Request) {
|
|
192
|
+
return Response.json({ self: this.marker, path: new URL(request.url).pathname });
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
if (!worker.registry) throw new Error("worker has no registry");
|
|
196
|
+
worker.registry["branch-list"] = app;
|
|
197
|
+
const h = makeHarness();
|
|
198
|
+
|
|
199
|
+
const res = await worker.fetch(
|
|
200
|
+
new Request("https://localhost/.pple/registry/branch-list/offices"),
|
|
201
|
+
h.testEnv,
|
|
202
|
+
ctx,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
expect(await res.json()).toEqual({ self: "hono", path: "/offices" });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("answers 404 when the registry path names no entry", async () => {
|
|
209
|
+
const h = makeHarness();
|
|
210
|
+
const res = await worker.fetch(new Request("https://localhost/.pple/registry/"), h.testEnv, ctx);
|
|
211
|
+
expect(res.status).toBe(404);
|
|
212
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("public traffic under dev", () => {
|
|
217
|
+
it("strips x-pple-* before app code and still serves the route", async () => {
|
|
218
|
+
let ran = false;
|
|
219
|
+
if (!worker.registry) throw new Error("worker has no registry");
|
|
220
|
+
worker.registry["branch-list"] = () => {
|
|
221
|
+
ran = true;
|
|
222
|
+
return new Response("no");
|
|
223
|
+
};
|
|
224
|
+
const h = makeHarness();
|
|
225
|
+
|
|
226
|
+
const res = await worker.fetch(
|
|
227
|
+
new Request("https://localhost/api/health", {
|
|
228
|
+
headers: {
|
|
229
|
+
"x-pple-consumer": "forged",
|
|
230
|
+
"x-pple-entry": "forged/branch-list",
|
|
231
|
+
"x-pple-identity": "forged",
|
|
232
|
+
},
|
|
233
|
+
}),
|
|
234
|
+
h.testEnv,
|
|
235
|
+
ctx,
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
expect(res.status).toBe(200);
|
|
239
|
+
expect(await res.json()).toEqual({ ok: true });
|
|
240
|
+
expect(ran).toBe(false);
|
|
241
|
+
|
|
242
|
+
let seen: Request | undefined;
|
|
243
|
+
const stripped = await devWorkerFetch(
|
|
244
|
+
{
|
|
245
|
+
fetch(request) {
|
|
246
|
+
seen = request;
|
|
247
|
+
return new Response("app");
|
|
248
|
+
},
|
|
249
|
+
registry: {},
|
|
250
|
+
},
|
|
251
|
+
new Request("https://localhost/api/notes", {
|
|
252
|
+
headers: {
|
|
253
|
+
authorization: "Bearer keep",
|
|
254
|
+
cookie: "session=keep",
|
|
255
|
+
"x-request-id": "keep",
|
|
256
|
+
"x-pple-consumer": "forged",
|
|
257
|
+
"x-pple-identity": "forged",
|
|
258
|
+
"X-Pple-User-Token": "raw-token",
|
|
259
|
+
},
|
|
260
|
+
}),
|
|
261
|
+
h.testEnv,
|
|
262
|
+
ctx,
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
expect(stripped.status).toBe(200);
|
|
266
|
+
if (!seen) throw new Error("public request did not reach app code");
|
|
267
|
+
expect(serviceCall(seen)).toBeNull();
|
|
268
|
+
expect(seen.headers.get("authorization")).toBe("Bearer keep");
|
|
269
|
+
expect(seen.headers.get("cookie")).toBe("session=keep");
|
|
270
|
+
expect(seen.headers.get("x-request-id")).toBe("keep");
|
|
271
|
+
expect(seen.headers.get("x-pple-consumer")).toBeNull();
|
|
272
|
+
expect(seen.headers.get("x-pple-user-token")).toBeNull();
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe("callRegistry", () => {
|
|
277
|
+
it("calls the handler with the Persona and Consumer overrides", async () => {
|
|
278
|
+
const seen: { request?: Request } = {};
|
|
279
|
+
if (!worker.registry) throw new Error("worker has no registry");
|
|
280
|
+
worker.registry["branch-list"] = (request) => {
|
|
281
|
+
seen.request = request;
|
|
282
|
+
return Response.json(serviceCall(request));
|
|
283
|
+
};
|
|
284
|
+
const h = makeHarness({}, { DEV_PERSONA: "delegate", DEV_CONSUMER: "from-env" });
|
|
285
|
+
|
|
286
|
+
const res = await h.callRegistry("branch-list", "/offices?q=1", {
|
|
287
|
+
persona: "province-coordinator",
|
|
288
|
+
consumer: "brave-purple-lion",
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
expect(res.status).toBe(200);
|
|
292
|
+
expect(await res.json()).toEqual({
|
|
293
|
+
consumer: "brave-purple-lion",
|
|
294
|
+
entry: "dev-provider/branch-list",
|
|
295
|
+
identity: DEV_PERSONAS["province-coordinator"],
|
|
296
|
+
});
|
|
297
|
+
if (!seen.request) throw new Error("handler did not run");
|
|
298
|
+
const url = new URL(seen.request.url);
|
|
299
|
+
expect(url.pathname).toBe("/offices");
|
|
300
|
+
expect(url.search).toBe("?q=1");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("preserves the method and body, and drops a forged platform header", async () => {
|
|
304
|
+
if (!worker.registry) throw new Error("worker has no registry");
|
|
305
|
+
worker.registry["branch-list"] = async (request) =>
|
|
306
|
+
Response.json({
|
|
307
|
+
call: serviceCall(request),
|
|
308
|
+
method: request.method,
|
|
309
|
+
type: request.headers.get("content-type"),
|
|
310
|
+
authorization: request.headers.get("authorization"),
|
|
311
|
+
body: await request.text(),
|
|
312
|
+
});
|
|
313
|
+
const h = makeHarness();
|
|
314
|
+
|
|
315
|
+
const res = await h.callRegistry("branch-list", "/items", {
|
|
316
|
+
persona: "none",
|
|
317
|
+
consumer: "brave-purple-lion",
|
|
318
|
+
init: {
|
|
319
|
+
method: "POST",
|
|
320
|
+
headers: {
|
|
321
|
+
"content-type": "text/plain",
|
|
322
|
+
"x-pple-consumer": "forged",
|
|
323
|
+
authorization: "Bearer raw-token",
|
|
324
|
+
},
|
|
325
|
+
body: "เนื้อหา",
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
expect(await res.json()).toEqual({
|
|
330
|
+
call: {
|
|
331
|
+
consumer: "brave-purple-lion",
|
|
332
|
+
entry: "dev-provider/branch-list",
|
|
333
|
+
identity: null,
|
|
334
|
+
},
|
|
335
|
+
method: "POST",
|
|
336
|
+
type: "text/plain",
|
|
337
|
+
authorization: null,
|
|
338
|
+
body: "เนื้อหา",
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("uses the harness Persona and Consumer when no override is passed", async () => {
|
|
343
|
+
if (!worker.registry) throw new Error("worker has no registry");
|
|
344
|
+
let path = "";
|
|
345
|
+
worker.registry["branch-list"] = (request) => {
|
|
346
|
+
path = new URL(request.url).pathname;
|
|
347
|
+
return Response.json(serviceCall(request));
|
|
348
|
+
};
|
|
349
|
+
const h = makeHarness({}, { DEV_PERSONA: "delegate", DEV_CONSUMER: "from-env" });
|
|
350
|
+
|
|
351
|
+
const res = await h.callRegistry("branch-list", "items");
|
|
352
|
+
|
|
353
|
+
expect(await res.json()).toEqual({
|
|
354
|
+
consumer: "from-env",
|
|
355
|
+
entry: "dev-provider/branch-list",
|
|
356
|
+
identity: DEV_PERSONAS.delegate,
|
|
357
|
+
});
|
|
358
|
+
expect(path).toBe("/items");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("sends no Identity for persona none, and throws when the entry has no handler", async () => {
|
|
362
|
+
if (!worker.registry) throw new Error("worker has no registry");
|
|
363
|
+
worker.registry["branch-list"] = (request) => Response.json(serviceCall(request));
|
|
364
|
+
const h = makeHarness({}, { DEV_PERSONA: "hq-staff" });
|
|
365
|
+
|
|
366
|
+
const res = await h.callRegistry("branch-list", "/", { persona: "none" });
|
|
367
|
+
expect(await res.json()).toMatchObject({ consumer: "dev-consumer", identity: null });
|
|
368
|
+
|
|
369
|
+
delete worker.registry["branch-list"];
|
|
370
|
+
// The platform wrapper throws this. pple-services answers provider_unavailable
|
|
371
|
+
// (502) to the Consumer. callRegistry throws the same error and does not
|
|
372
|
+
// invent that response.
|
|
373
|
+
await expect(h.callRegistry("branch-list", "/offices")).rejects.toThrow("registry handler missing");
|
|
374
|
+
});
|
|
375
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// effective.sub (ADR-0028): userinfo onto /api/me, then the same passesLevel
|
|
2
|
+
// a Dataset read will use. The read API does not exist yet.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { passesLevel, type LevelIdentity } from "../src/levels";
|
|
6
|
+
import { makeHarness } from "./harness";
|
|
7
|
+
|
|
8
|
+
const SUPERVISOR = "347513243977122153";
|
|
9
|
+
const LEGACY = "11111111-2222-3333-4444-555555555555";
|
|
10
|
+
|
|
11
|
+
function signedIn(ad: Record<string, unknown>, sub = "u-actor") {
|
|
12
|
+
return makeHarness(
|
|
13
|
+
{
|
|
14
|
+
sso: () => Response.json({ sub, name: "Nok", ad }),
|
|
15
|
+
},
|
|
16
|
+
{ DEV_PERSONA: "none" },
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function me(ad: Record<string, unknown>, sub = "u-actor"): Promise<LevelIdentity> {
|
|
21
|
+
const res = await signedIn(ad, sub).request("/api/me", {}, "valid-token");
|
|
22
|
+
expect(res.status).toBe(200);
|
|
23
|
+
return res.json<LevelIdentity>();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("effective.sub", () => {
|
|
27
|
+
it("is the actor sub when nobody is delegating, even if ad.user carries a Zitadel id", async () => {
|
|
28
|
+
const identity = await me(
|
|
29
|
+
{
|
|
30
|
+
user: { id: 7, role: "hq", zitadel_user_id: SUPERVISOR, sso_user_id: LEGACY },
|
|
31
|
+
actualPpleUser: { id: 7, role: "hq" },
|
|
32
|
+
},
|
|
33
|
+
"u-42",
|
|
34
|
+
);
|
|
35
|
+
expect(identity.effective.isDelegated).toBe(false);
|
|
36
|
+
expect(identity.effective.sub).toBe("u-42");
|
|
37
|
+
expect(JSON.stringify(identity)).not.toContain(SUPERVISOR);
|
|
38
|
+
expect(JSON.stringify(identity)).not.toContain(LEGACY);
|
|
39
|
+
expect(passesLevel(identity, { subs: ["u-42"] })).toBe(true);
|
|
40
|
+
expect(passesLevel(identity, { subs: [SUPERVISOR] })).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("is the supervisor's zitadel id under delegation, and that passes their subs level", async () => {
|
|
44
|
+
const identity = await me({
|
|
45
|
+
user: { id: 9, role: "province", zitadel_user_id: SUPERVISOR, sso_user_id: LEGACY },
|
|
46
|
+
actualPpleUser: { id: 14, role: "delegate" },
|
|
47
|
+
activeRole: "province",
|
|
48
|
+
});
|
|
49
|
+
expect(identity.effective.isDelegated).toBe(true);
|
|
50
|
+
expect(identity.actor.sub).toBe("u-actor");
|
|
51
|
+
expect(identity.effective.sub).toBe(SUPERVISOR);
|
|
52
|
+
expect(JSON.stringify(identity)).not.toContain(LEGACY);
|
|
53
|
+
expect(passesLevel(identity, { subs: [SUPERVISOR] })).toBe(true);
|
|
54
|
+
expect(passesLevel(identity, { subs: ["u-actor"] })).toBe(true);
|
|
55
|
+
expect(passesLevel(identity, { subs: [LEGACY] })).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it.each([
|
|
59
|
+
["null", { zitadel_user_id: null }],
|
|
60
|
+
["only sso_user_id", {}],
|
|
61
|
+
["a uuid", { zitadel_user_id: LEGACY }],
|
|
62
|
+
["a number", { zitadel_user_id: 347513243977122153 }],
|
|
63
|
+
] as const)(
|
|
64
|
+
"is null when the supervisor zitadel id is %s, and that passes no named subs level",
|
|
65
|
+
async (_label, extra) => {
|
|
66
|
+
const identity = await me({
|
|
67
|
+
user: { id: 9, role: "province", sso_user_id: LEGACY, ...extra },
|
|
68
|
+
actualPpleUser: { id: 14, role: "delegate" },
|
|
69
|
+
});
|
|
70
|
+
expect(identity.effective.sub).toBeNull();
|
|
71
|
+
expect(JSON.stringify(identity)).not.toContain(LEGACY);
|
|
72
|
+
expect(passesLevel(identity, { subs: [SUPERVISOR] })).toBe(false);
|
|
73
|
+
expect(passesLevel(identity, { subs: [LEGACY] })).toBe(false);
|
|
74
|
+
expect(passesLevel(identity, { subs: ["u-actor"] })).toBe(true);
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
it("does not treat ad.user as a supervisor unless the logged-in role is delegate and the rows differ", async () => {
|
|
79
|
+
const samePerson = await me(
|
|
80
|
+
{
|
|
81
|
+
user: { id: 14, role: "delegate", zitadel_user_id: SUPERVISOR },
|
|
82
|
+
actualPpleUser: { id: 14, role: "delegate" },
|
|
83
|
+
},
|
|
84
|
+
"u-actor",
|
|
85
|
+
);
|
|
86
|
+
expect(samePerson.effective.isDelegated).toBe(false);
|
|
87
|
+
expect(samePerson.effective.sub).toBe("u-actor");
|
|
88
|
+
|
|
89
|
+
const notADelegate = await me(
|
|
90
|
+
{
|
|
91
|
+
user: { id: 9, role: "province", zitadel_user_id: SUPERVISOR },
|
|
92
|
+
actualPpleUser: { id: 14, role: "hq" },
|
|
93
|
+
},
|
|
94
|
+
"u-actor",
|
|
95
|
+
);
|
|
96
|
+
// Delegated rows, but not a recognised delegate: never the actor's own sub.
|
|
97
|
+
expect(notADelegate.effective.isDelegated).toBe(true);
|
|
98
|
+
expect(notADelegate.effective.sub).toBeNull();
|
|
99
|
+
expect(passesLevel(notADelegate, { subs: [SUPERVISOR] })).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
---
|
|
2
|
+
kind: dataset
|
|
3
|
+
visibility: private
|
|
4
|
+
description: สาขาสำหรับทดสอบ
|
|
5
|
+
level: public
|
|
6
|
+
objectTypes:
|
|
7
|
+
offices:
|
|
8
|
+
shape: collection
|
|
9
|
+
cadence: 1d
|
|
10
|
+
id: id
|
|
11
|
+
index:
|
|
12
|
+
- province
|
|
13
|
+
- seats
|
|
14
|
+
level: member
|
|
15
|
+
schema:
|
|
16
|
+
type: object
|
|
17
|
+
properties:
|
|
18
|
+
id:
|
|
19
|
+
type: string
|
|
20
|
+
province:
|
|
21
|
+
type: string
|
|
22
|
+
seats:
|
|
23
|
+
type: integer
|
|
24
|
+
minimum: 0
|
|
25
|
+
required:
|
|
26
|
+
- id
|
|
27
|
+
roster:
|
|
28
|
+
shape: collection
|
|
29
|
+
cadence: 1d
|
|
30
|
+
id: id
|
|
31
|
+
index:
|
|
32
|
+
- province
|
|
33
|
+
level: internal
|
|
34
|
+
schema:
|
|
35
|
+
type: object
|
|
36
|
+
properties:
|
|
37
|
+
id:
|
|
38
|
+
type: string
|
|
39
|
+
province:
|
|
40
|
+
type: string
|
|
41
|
+
summary:
|
|
42
|
+
shape: singleton
|
|
43
|
+
cadence: 1d
|
|
44
|
+
schema:
|
|
45
|
+
type: object
|
|
46
|
+
properties:
|
|
47
|
+
title:
|
|
48
|
+
type: string
|
|
49
|
+
audience:
|
|
50
|
+
shape: collection
|
|
51
|
+
cadence: 1d
|
|
52
|
+
id: id
|
|
53
|
+
index: []
|
|
54
|
+
level:
|
|
55
|
+
subs: swept
|
|
56
|
+
schema:
|
|
57
|
+
type: object
|
|
58
|
+
properties:
|
|
59
|
+
id:
|
|
60
|
+
type: string
|
|
61
|
+
named:
|
|
62
|
+
shape: collection
|
|
63
|
+
cadence: 1d
|
|
64
|
+
id: id
|
|
65
|
+
index: []
|
|
66
|
+
level: {}
|
|
67
|
+
schema:
|
|
68
|
+
type: object
|
|
69
|
+
properties:
|
|
70
|
+
id:
|
|
71
|
+
type: string
|
|
72
|
+
gap:
|
|
73
|
+
shape: collection
|
|
74
|
+
cadence: 1d
|
|
75
|
+
id: id
|
|
76
|
+
index: []
|
|
77
|
+
level: public
|
|
78
|
+
schema:
|
|
79
|
+
type: object
|
|
80
|
+
properties:
|
|
81
|
+
id:
|
|
82
|
+
type: string
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
สาขาสำหรับทดสอบออฟไลน์
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "title": "สรุปสาขา" }
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"method": "GET",
|
|
4
|
+
"path": "/items/:id",
|
|
5
|
+
"persona": "hq-staff",
|
|
6
|
+
"status": 200,
|
|
7
|
+
"headers": { "x-shop": "hq", "x-pple-error": "forged" },
|
|
8
|
+
"body": { "item": "hq" }
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"method": "GET",
|
|
12
|
+
"path": "/items/:id",
|
|
13
|
+
"persona": ["delegate", "platform-admin"],
|
|
14
|
+
"body": { "item": "listed" }
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"method": "GET",
|
|
18
|
+
"path": "/items/:id",
|
|
19
|
+
"body": { "item": "open" }
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"method": "get",
|
|
23
|
+
"path": "/items/:id",
|
|
24
|
+
"body": { "item": "later" }
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"method": "POST",
|
|
28
|
+
"path": "/",
|
|
29
|
+
"status": 201,
|
|
30
|
+
"body": { "created": true }
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"path": "/files/*",
|
|
34
|
+
"body": "listed"
|
|
35
|
+
}
|
|
36
|
+
]
|