@pplethai/cli 0.6.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.
Files changed (51) hide show
  1. package/dist/bin.js +171 -22
  2. package/package.json +3 -2
  3. package/templates/golden-path/.dev.vars.example +19 -1
  4. package/templates/golden-path/CLAUDE.md +74 -7
  5. package/templates/golden-path/README.md +27 -2
  6. package/templates/golden-path/gitignore +3 -0
  7. package/templates/golden-path/overlay-mtime.mjs +30 -0
  8. package/templates/golden-path/package-lock.json +19 -1
  9. package/templates/golden-path/package.json +3 -2
  10. package/templates/golden-path/scripts/assert-dev-compiled-out.mjs +42 -0
  11. package/templates/golden-path/shared/api.ts +42 -2
  12. package/templates/golden-path/src/auth.ts +99 -7
  13. package/templates/golden-path/src/call-service.ts +140 -0
  14. package/templates/golden-path/src/dataset-query.ts +470 -0
  15. package/templates/golden-path/src/dataset-schema.ts +398 -0
  16. package/templates/golden-path/src/dev-call-service.ts +391 -0
  17. package/templates/golden-path/src/dev-personas.ts +105 -7
  18. package/templates/golden-path/src/dev-read-dataset.ts +429 -0
  19. package/templates/golden-path/src/dev-registry.ts +276 -0
  20. package/templates/golden-path/src/env.ts +17 -0
  21. package/templates/golden-path/src/identity-header.ts +17 -0
  22. package/templates/golden-path/src/index.ts +32 -1
  23. package/templates/golden-path/src/legal-identity.ts +180 -0
  24. package/templates/golden-path/src/levels.ts +132 -0
  25. package/templates/golden-path/src/read-dataset.ts +376 -0
  26. package/templates/golden-path/src/registry.ts +122 -0
  27. package/templates/golden-path/src/vite-env.d.ts +13 -0
  28. package/templates/golden-path/test/call-service.test.ts +178 -0
  29. package/templates/golden-path/test/dev-call-service.test.ts +279 -0
  30. package/templates/golden-path/test/dev-read-dataset.test.ts +270 -0
  31. package/templates/golden-path/test/dev-registry.test.ts +375 -0
  32. package/templates/golden-path/test/effective-sub.test.ts +101 -0
  33. package/templates/golden-path/test/fixtures/overlay/directory/branches/entry.md +85 -0
  34. package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/audience.json +3 -0
  35. package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/named.json +3 -0
  36. package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/offices.json +6 -0
  37. package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/roster.json +3 -0
  38. package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/summary.json +1 -0
  39. package/templates/golden-path/test/fixtures/overlay/platform/legal-identity/entry.md +8 -0
  40. package/templates/golden-path/test/fixtures/overlay/platform/legal-identity/mocks/responses.json +13 -0
  41. package/templates/golden-path/test/fixtures/overlay/shop/catalog/entry.md +8 -0
  42. package/templates/golden-path/test/fixtures/overlay/shop/catalog/mocks/responses.json +7 -0
  43. package/templates/golden-path/test/fixtures/overlay/shop/orders/entry.md +12 -0
  44. package/templates/golden-path/test/fixtures/overlay/shop/orders/mocks/responses.json +36 -0
  45. package/templates/golden-path/test/harness.ts +101 -16
  46. package/templates/golden-path/test/legal-identity.test.ts +323 -0
  47. package/templates/golden-path/test/membership.test.ts +105 -0
  48. package/templates/golden-path/test/read-dataset.test.ts +214 -0
  49. package/templates/golden-path/test/service-call.test.ts +166 -0
  50. package/templates/golden-path/vite.config.ts +2 -1
  51. package/templates/golden-path/vitest.config.ts +2 -0
@@ -1,31 +1,98 @@
1
1
  import { env } from "cloudflare:test";
2
+ import { callRegistry as dispatchRegistry, type CallRegistryOptions } from "../src/dev-registry";
2
3
  import type { Env } from "../src/env";
3
- import { createApp } from "../src/index";
4
+ import worker, { createApp } from "../src/index";
4
5
 
5
6
  // A placeholder bearer. In tests `import.meta.env.DEV` is true, so the Worker
6
7
  // signs in as the default persona and ignores the token — the value only
7
8
  // matters on the real PPLE ID path, where tests pass an explicit one.
8
9
  export const DEV_TOKEN = "dev";
9
10
 
11
+ /** What `callService` sent. The App Key and the user token are not kept. */
12
+ export interface RecordedServiceCall {
13
+ /** Path and query, under the services origin. */
14
+ path: string;
15
+ method: string;
16
+ /** Headers other than `authorization` and `x-pple-user-token`. */
17
+ headers: Record<string, string>;
18
+ }
19
+
20
+ /**
21
+ * The Workers runtime builds this. Tests only need the methods a handler may
22
+ * call; the rest exists on the real context and nowhere a test can construct.
23
+ */
24
+ export function testExecutionContext(): ExecutionContext {
25
+ return {
26
+ waitUntil() {},
27
+ passThroughOnException() {},
28
+ props: {},
29
+ exports: {},
30
+ } as unknown as ExecutionContext;
31
+ }
32
+
10
33
  export interface Fakes {
11
34
  /** Handles GET {SSO_BASE_URL}/oidc/v1/userinfo */
12
35
  sso?: (req: Request) => Response | Promise<Response>;
13
36
  /** Handles POST {NOTIFY_API_BASE}/notifications */
14
37
  notify?: (req: Request) => Response | Promise<Response>;
38
+ /**
39
+ * Handles `{PPLE_SERVICES_URL}/<provider>/<name>/…` for `callService` and
40
+ * `readDataset`. Answer as the platform, or refuse with `serviceRefusal`
41
+ * and any `x-pple-error` code.
42
+ */
43
+ services?: (req: Request) => Response | Promise<Response>;
15
44
  }
16
45
 
46
+ const SECRET_HEADERS = new Set(["authorization", "x-pple-user-token"]);
47
+
17
48
  /**
18
- * A copy of the Worker wired to fake PPLE ID / notification services (via an
19
- * injected fetch) and the real local D1 + R2 from the test runtime. Records
20
- * the requests sent to the notification endpoint so tests can assert the wire
21
- * shape — above all that every send states its audience.
49
+ * A platform refusal the services fake can return. `code` is not checked:
50
+ * a test can refuse with any `x-pple-error` value.
51
+ */
52
+ export function serviceRefusal(code: string, status: number): Response {
53
+ return new Response(JSON.stringify({ error: code }), {
54
+ status,
55
+ headers: {
56
+ "content-type": "application/json",
57
+ "x-pple-error": code,
58
+ },
59
+ });
60
+ }
61
+
62
+ /**
63
+ * A copy of the Worker wired to fake PPLE ID, notifications, and Platform
64
+ * Services (via an injected fetch) and the real local D1 + R2 from the test
65
+ * runtime. Records the requests sent to the notification endpoint, and the
66
+ * path, method, and non-secret headers of each Service call.
22
67
  */
23
68
  export function makeHarness(fakes: Fakes = {}, envOverrides: Partial<Env> = {}) {
24
- const calls = { notify: [] as Request[] };
69
+ const calls = {
70
+ notify: [] as Request[],
71
+ services: [] as RecordedServiceCall[],
72
+ };
73
+
74
+ const testEnv: Env = {
75
+ ...env,
76
+ SSO_BASE_URL: "https://sso.test",
77
+ NOTIFY_API_BASE: "",
78
+ // No DEV_PERSONA ⇒ the default persona. Pass { DEV_PERSONA: "none" } to
79
+ // exercise the real PPLE ID path instead.
80
+ ...envOverrides,
81
+ };
82
+
83
+ const servicesBase = (testEnv.PPLE_SERVICES_URL ?? "").replace(/\/+$/, "");
25
84
 
26
85
  const fetchImpl: typeof fetch = async (input, init) => {
27
86
  const req = new Request(input as RequestInfo, init);
28
87
  const url = new URL(req.url);
88
+ if (servicesBase && isServicesUrl(req.url, servicesBase)) {
89
+ calls.services.push({
90
+ path: url.pathname + url.search,
91
+ method: req.method,
92
+ headers: publicHeaders(req.headers),
93
+ });
94
+ return fakes.services ? fakes.services(req) : new Response(null, { status: 502 });
95
+ }
29
96
  if (url.pathname.endsWith("/oidc/v1/userinfo")) {
30
97
  return fakes.sso ? fakes.sso(req) : new Response(null, { status: 502 });
31
98
  }
@@ -38,15 +105,6 @@ export function makeHarness(fakes: Fakes = {}, envOverrides: Partial<Env> = {})
38
105
 
39
106
  const app = createApp({ fetchImpl });
40
107
 
41
- const testEnv: Env = {
42
- ...env,
43
- SSO_BASE_URL: "https://sso.test",
44
- NOTIFY_API_BASE: "",
45
- // No DEV_PERSONA ⇒ the default persona. Pass { DEV_PERSONA: "none" } to
46
- // exercise the real PPLE ID path instead.
47
- ...envOverrides,
48
- };
49
-
50
108
  const request = (
51
109
  path: string,
52
110
  init: RequestInit = {},
@@ -57,5 +115,32 @@ export function makeHarness(fakes: Fakes = {}, envOverrides: Partial<Env> = {})
57
115
  return app.request(path, { ...init, headers }, testEnv);
58
116
  };
59
117
 
60
- return { app, request, testEnv, calls };
118
+ const execution = testExecutionContext();
119
+
120
+ /**
121
+ * Call `registry[name]` the way the platform does: the tail path and the
122
+ * `x-pple-*` facts. `persona` and `consumer` override this harness's env.
123
+ * An entry with no fetch-shaped handler throws `registry handler missing`
124
+ * — the platform wrapper's error. pple-services turns that into
125
+ * `provider_unavailable` (502) for the Consumer; this does not, because
126
+ * that response is not what the Provider's handler sees.
127
+ */
128
+ const callRegistry = (name: string, path: string, options: CallRegistryOptions = {}) =>
129
+ dispatchRegistry(worker, testEnv, execution, name, path, options);
130
+
131
+ return { app, request, testEnv, calls, fetchImpl, callRegistry };
132
+ }
133
+
134
+ function isServicesUrl(url: string, base: string): boolean {
135
+ return url === base || url.startsWith(`${base}/`) || url.startsWith(`${base}?`);
136
+ }
137
+
138
+ function publicHeaders(headers: Headers): Record<string, string> {
139
+ const recorded: Record<string, string> = {};
140
+ headers.forEach((value, key) => {
141
+ const name = key.toLowerCase();
142
+ if (SECRET_HEADERS.has(name)) return;
143
+ recorded[name] = value;
144
+ });
145
+ return recorded;
61
146
  }
@@ -0,0 +1,323 @@
1
+ // KYC Status on the actor, and getLegalIdentity (issue #216). Every refusal
2
+ // the helper can return is named here. The offline path never yields a
3
+ // citizen ID that would pass the Thai checksum.
4
+
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { DEV_PERSONAS } from "../src/dev-personas";
7
+ import {
8
+ getLegalIdentity,
9
+ kycStartUrl,
10
+ OFFLINE_INVALID_ID,
11
+ OFFLINE_LEGAL_NAME,
12
+ thaiIdChecksumValid,
13
+ } from "../src/legal-identity";
14
+ import { makeHarness, serviceRefusal } from "./harness";
15
+
16
+ describe("actor.kyc", () => {
17
+ it("gives every Persona a KYC Status, and members are verified via membership", () => {
18
+ for (const persona of Object.values(DEV_PERSONAS)) {
19
+ expect(persona.actor.kyc).toEqual(
20
+ expect.objectContaining({
21
+ status: expect.any(String),
22
+ verified: expect.any(Boolean),
23
+ }),
24
+ );
25
+ expect("kyc" in persona.effective).toBe(false);
26
+ }
27
+ expect(DEV_PERSONAS["hq-staff"]!.actor.kyc).toMatchObject({
28
+ status: "verified",
29
+ verified: true,
30
+ source: "membership",
31
+ });
32
+ expect(DEV_PERSONAS.unverified!.actor.kyc.status).toBe("none");
33
+ expect(DEV_PERSONAS["kyc-pending"]!.actor.kyc.status).toBe("pending_review");
34
+ expect(DEV_PERSONAS["kyc-verified-nonmember"]!.actor.kyc).toMatchObject({
35
+ status: "verified",
36
+ source: "kyc",
37
+ });
38
+ });
39
+
40
+ it("keeps the delegate's KYC Status, not the supervisor's", async () => {
41
+ const delegate = makeHarness({}, { DEV_PERSONA: "delegate" });
42
+ const supervisor = makeHarness({}, { DEV_PERSONA: "province-coordinator" });
43
+ const delegateMe = await (await delegate.request("/api/me")).json<{
44
+ actor: { kyc: { verifiedAt: string; source: string } };
45
+ effective: Record<string, unknown>;
46
+ }>();
47
+ const supervisorMe = await (await supervisor.request("/api/me")).json<{
48
+ actor: { kyc: { verifiedAt: string } };
49
+ }>();
50
+ expect(delegateMe.actor.kyc.verifiedAt).toBe("2024-04-01T00:00:00.000Z");
51
+ expect(delegateMe.actor.kyc.verifiedAt).not.toBe(supervisorMe.actor.kyc.verifiedAt);
52
+ expect(delegateMe.actor.kyc.source).toBe("membership");
53
+ expect(delegateMe.effective.kyc).toBeUndefined();
54
+ });
55
+
56
+ it("parses userinfo kyc onto the actor only, including a delegated session", async () => {
57
+ const h = makeHarness(
58
+ {
59
+ sso: () =>
60
+ Response.json({
61
+ sub: "u-delegate",
62
+ name: "Nok",
63
+ kyc: {
64
+ status: "verified",
65
+ verified: true,
66
+ verifiedAt: "2024-06-01T00:00:00.000Z",
67
+ source: "kyc",
68
+ },
69
+ ad: {
70
+ user: { id: 9, role: "province", mobile: "0810000009" },
71
+ actualPpleUser: { id: 14, role: "delegate", mobile: "0810000014" },
72
+ activeRole: "province",
73
+ eligibleRoles: ["province"],
74
+ eligibleProvinces: ["เชียงใหม่"],
75
+ },
76
+ }),
77
+ },
78
+ { DEV_PERSONA: "none" },
79
+ );
80
+ const me = await (await h.request("/api/me", {}, "valid-token")).json<{
81
+ actor: { kyc: { source: string; verifiedAt: string } };
82
+ effective: Record<string, unknown>;
83
+ }>();
84
+ expect(me.actor.kyc).toEqual({
85
+ status: "verified",
86
+ verified: true,
87
+ verifiedAt: "2024-06-01T00:00:00.000Z",
88
+ source: "kyc",
89
+ });
90
+ expect(me.effective.kyc).toBeUndefined();
91
+ });
92
+
93
+ it("defaults a userinfo payload with no kyc to none", async () => {
94
+ const h = makeHarness(
95
+ {
96
+ sso: () =>
97
+ Response.json({
98
+ sub: "u-42",
99
+ name: "Somchai",
100
+ ad: {
101
+ user: { id: 7, role: "hq", mobile: "0810000007" },
102
+ actualPpleUser: { id: 7, role: "hq", mobile: "0810000007" },
103
+ activeRole: "hq",
104
+ eligibleRoles: ["hq"],
105
+ eligibleProvinces: ["all"],
106
+ },
107
+ }),
108
+ },
109
+ { DEV_PERSONA: "none" },
110
+ );
111
+ const me = await (await h.request("/api/me", {}, "valid-token")).json<{
112
+ actor: { kyc: { status: string; verified: boolean; source: null } };
113
+ }>();
114
+ expect(me.actor.kyc).toEqual({
115
+ status: "none",
116
+ verified: false,
117
+ verifiedAt: null,
118
+ source: null,
119
+ });
120
+ });
121
+ });
122
+
123
+ describe("getLegalIdentity offline", () => {
124
+ it("never returns a checksum-valid citizen ID", async () => {
125
+ expect(thaiIdChecksumValid(OFFLINE_INVALID_ID)).toBe(false);
126
+ // A known-valid check digit for the same first 12 digits is 8.
127
+ expect(thaiIdChecksumValid("1100000000008")).toBe(true);
128
+
129
+ for (const name of Object.keys(DEV_PERSONAS)) {
130
+ const result = await getLegalIdentity({ DEV_PERSONA: name }, "ignored");
131
+ if (!result.ok) {
132
+ expect(result.reason).toBe("not_verified");
133
+ continue;
134
+ }
135
+ expect(result.legalIdentity.name).toBe(OFFLINE_LEGAL_NAME);
136
+ if (result.legalIdentity.idCardNumber !== null) {
137
+ expect(thaiIdChecksumValid(result.legalIdentity.idCardNumber)).toBe(false);
138
+ }
139
+ }
140
+
141
+ const member = await getLegalIdentity({ DEV_PERSONA: "hq-staff" }, "t");
142
+ expect(member).toEqual({
143
+ ok: true,
144
+ legalIdentity: { source: "membership", name: OFFLINE_LEGAL_NAME, idCardNumber: null },
145
+ });
146
+ const card = await getLegalIdentity({ DEV_PERSONA: "kyc-verified-nonmember" }, "t");
147
+ expect(card).toEqual({
148
+ ok: true,
149
+ legalIdentity: { source: "kyc", name: OFFLINE_LEGAL_NAME, idCardNumber: OFFLINE_INVALID_ID },
150
+ });
151
+ expect(await getLegalIdentity({ DEV_PERSONA: "unverified" }, "t")).toEqual({
152
+ ok: false,
153
+ reason: "not_verified",
154
+ });
155
+ expect(await getLegalIdentity({ DEV_PERSONA: "kyc-pending" }, "t")).toEqual({
156
+ ok: false,
157
+ reason: "not_verified",
158
+ });
159
+ });
160
+ });
161
+
162
+ describe("getLegalIdentity against Platform Services", () => {
163
+ const env = {
164
+ DEV_PERSONA: "none",
165
+ PPLE_SERVICES_URL: "https://services.test",
166
+ PPLE_APP_KEY: "app-key",
167
+ };
168
+
169
+ it("is not_configured until both bindings exist, and does not call out", async () => {
170
+ const fetchImpl = vi.fn<typeof fetch>();
171
+ expect(await getLegalIdentity({ DEV_PERSONA: "none" }, "tok", fetchImpl)).toEqual({
172
+ ok: false,
173
+ reason: "not_configured",
174
+ });
175
+ expect(
176
+ await getLegalIdentity({ DEV_PERSONA: "none", PPLE_SERVICES_URL: "https://services.test" }, "tok", fetchImpl),
177
+ ).toEqual({ ok: false, reason: "not_configured" });
178
+ expect(fetchImpl).not.toHaveBeenCalled();
179
+ });
180
+
181
+ it("posts to platform/legal-identity with the App Key and the user token, and no body", async () => {
182
+ let seen: { authorization: string | null; userToken: string | null; body: string } | undefined;
183
+ const h = makeHarness(
184
+ {
185
+ services: async (req) => {
186
+ seen = {
187
+ authorization: req.headers.get("authorization"),
188
+ userToken: req.headers.get("x-pple-user-token"),
189
+ body: await req.text(),
190
+ };
191
+ return Response.json({ source: "kyc", name: "นาย ก", idCardNumber: "1100000000008" });
192
+ },
193
+ },
194
+ env,
195
+ );
196
+
197
+ expect(await getLegalIdentity(h.testEnv, "user-token", h.fetchImpl)).toEqual({
198
+ ok: true,
199
+ legalIdentity: { source: "kyc", name: "นาย ก", idCardNumber: "1100000000008" },
200
+ });
201
+ expect(seen).toEqual({
202
+ authorization: "Bearer app-key",
203
+ userToken: "user-token",
204
+ body: "",
205
+ });
206
+ expect(h.calls.services).toEqual([
207
+ { path: "/platform/legal-identity", method: "POST", headers: {} },
208
+ ]);
209
+ });
210
+
211
+ it("maps each platform code, and keeps needs_beta as its own reason", async () => {
212
+ type Mapped =
213
+ | "invalid_app_key"
214
+ | "not_granted"
215
+ | "needs_beta"
216
+ | "user_token_invalid"
217
+ | "upstream_unavailable";
218
+ const mapped: [string, number, Mapped][] = [
219
+ ["invalid_app_key", 401, "invalid_app_key"],
220
+ ["not_granted", 403, "not_granted"],
221
+ ["needs_beta", 403, "needs_beta"],
222
+ ["user_token_invalid", 401, "user_token_invalid"],
223
+ ["not_listed", 404, "upstream_unavailable"],
224
+ ["identity_unavailable", 503, "upstream_unavailable"],
225
+ ["provider_unavailable", 502, "upstream_unavailable"],
226
+ ["provider_timeout", 504, "upstream_unavailable"],
227
+ ];
228
+ let next = serviceRefusal("invalid_app_key", 401);
229
+ const h = makeHarness({ services: () => next }, env);
230
+ for (const [code, status, reason] of mapped) {
231
+ next = serviceRefusal(code, status);
232
+ expect(await getLegalIdentity(h.testEnv, "user-token", h.fetchImpl)).toEqual({ ok: false, reason });
233
+ }
234
+
235
+ // A code this helper does not name is still a platform refusal.
236
+ next = serviceRefusal("identity_not_enabled", 403);
237
+ expect(await getLegalIdentity(h.testEnv, "user-token", h.fetchImpl)).toEqual({
238
+ ok: false,
239
+ reason: "upstream_unavailable",
240
+ });
241
+ });
242
+
243
+ it("maps this Service's own answers, which carry no x-pple-error", async () => {
244
+ type Answer = "not_verified" | "user_token_invalid" | "upstream_unavailable";
245
+ const answers: [string, number, Answer][] = [
246
+ ["not_verified", 404, "not_verified"],
247
+ ["user_token_invalid", 401, "user_token_invalid"],
248
+ ["upstream_unavailable", 502, "upstream_unavailable"],
249
+ ];
250
+ let next = Response.json({ error: "not_verified" }, { status: 404 });
251
+ const h = makeHarness(
252
+ {
253
+ services: (req) => {
254
+ expect(req.headers.get("x-pple-error")).toBeNull();
255
+ return next;
256
+ },
257
+ },
258
+ env,
259
+ );
260
+ for (const [error, status, reason] of answers) {
261
+ next = Response.json({ error }, { status });
262
+ expect(await getLegalIdentity(h.testEnv, "user-token", h.fetchImpl)).toEqual({ ok: false, reason });
263
+ }
264
+
265
+ // The old reason is not read off a Provider body.
266
+ next = Response.json({ error: "identity_not_enabled" }, { status: 403 });
267
+ expect(await getLegalIdentity(h.testEnv, "user-token", h.fetchImpl)).toEqual({
268
+ ok: false,
269
+ reason: "upstream_unavailable",
270
+ });
271
+ // needs_beta is a platform code. Without the header it is not that reason.
272
+ next = Response.json({ error: "needs_beta" }, { status: 403 });
273
+ expect(await getLegalIdentity(h.testEnv, "user-token", h.fetchImpl)).toEqual({
274
+ ok: false,
275
+ reason: "upstream_unavailable",
276
+ });
277
+ });
278
+
279
+ it("treats a transport failure and a body-less error as upstream_unavailable", async () => {
280
+ const down = vi.fn<typeof fetch>(async () => {
281
+ throw new Error("network");
282
+ });
283
+ expect(await getLegalIdentity(env, "user-token", down)).toEqual({
284
+ ok: false,
285
+ reason: "upstream_unavailable",
286
+ });
287
+
288
+ const odd = vi.fn<typeof fetch>(async () => new Response("nope", { status: 502 }));
289
+ expect(await getLegalIdentity(env, "user-token", odd)).toEqual({
290
+ ok: false,
291
+ reason: "upstream_unavailable",
292
+ });
293
+ });
294
+ });
295
+
296
+ describe("local KYC redirect", () => {
297
+ it("sends a local click straight back with ?kyc=cancelled", () => {
298
+ const back = kycStartUrl({
299
+ ssoBaseUrl: "https://id.example",
300
+ clientId: "zitadel-app",
301
+ returnTo: "https://app.example/checkout?step=1#pay",
302
+ dev: true,
303
+ });
304
+ const url = new URL(back);
305
+ expect(url.origin + url.pathname).toBe("https://app.example/checkout");
306
+ expect(url.searchParams.get("step")).toBe("1");
307
+ expect(url.searchParams.get("kyc")).toBe("cancelled");
308
+ expect(url.hash).toBe("#pay");
309
+ });
310
+
311
+ it("points production at PPLE ID /kyc", () => {
312
+ expect(
313
+ kycStartUrl({
314
+ ssoBaseUrl: "https://id.example",
315
+ clientId: "zitadel-app",
316
+ returnTo: "https://app.example/checkout",
317
+ dev: false,
318
+ }),
319
+ ).toBe(
320
+ "https://id.example/kyc?client_id=zitadel-app&return_to=https%3A%2F%2Fapp.example%2Fcheckout",
321
+ );
322
+ });
323
+ });
@@ -0,0 +1,105 @@
1
+ // Actor membership (ADR-0028): userinfo onto the Persona path, and the
2
+ // member-only Persona that makes member and internal differ offline.
3
+
4
+ import { describe, expect, it } from "vitest";
5
+ import { DEV_PERSONAS } from "../src/dev-personas";
6
+ import { passesLevel } from "../src/levels";
7
+ import { makeHarness } from "./harness";
8
+
9
+ const AD = {
10
+ user: { id: 7, role: "hq", mobile: "0810000007" },
11
+ actualPpleUser: { id: 7, role: "hq", mobile: "0810000007" },
12
+ activeRole: "hq",
13
+ eligibleRoles: ["hq"],
14
+ eligibleProvinces: ["all"],
15
+ };
16
+
17
+ function signedIn(membership: unknown) {
18
+ return makeHarness(
19
+ {
20
+ sso: () =>
21
+ Response.json({
22
+ sub: "u-42",
23
+ name: "Somchai",
24
+ ad: AD,
25
+ ...(membership === undefined ? {} : { membership }),
26
+ }),
27
+ },
28
+ { DEV_PERSONA: "none" },
29
+ );
30
+ }
31
+
32
+ describe("actor.membership", () => {
33
+ it("keeps memberSince from userinfo and drops the address", async () => {
34
+ const h = signedIn({
35
+ type: "yearly",
36
+ memberSince: "2020-05-01",
37
+ registeredAddress: { province: "เชียงใหม่", district: "เมืองเชียงใหม่" },
38
+ });
39
+ const me = await (await h.request("/api/me", {}, "valid-token")).json<{
40
+ actor: { membership: { memberSince: string } | null };
41
+ effective: Record<string, unknown>;
42
+ }>();
43
+ expect(me.actor.membership).toEqual({ memberSince: "2020-05-01" });
44
+ expect(JSON.stringify(me)).not.toContain("registeredAddress");
45
+ expect(me.effective.membership).toBeUndefined();
46
+ });
47
+
48
+ it.each([
49
+ ["missing", undefined],
50
+ ["null", null],
51
+ ["an empty object", {}],
52
+ ["a datetime", { memberSince: "2020-05-01T00:00:00.000Z" }],
53
+ ] as const)("is null when userinfo membership is %s", async (_label, membership) => {
54
+ const h = signedIn(membership);
55
+ const me = await (await h.request("/api/me", {}, "valid-token")).json<{
56
+ actor: { membership: unknown };
57
+ }>();
58
+ expect(me.actor.membership).toBeNull();
59
+ });
60
+
61
+ it("gives every Persona a membership, and member has no Role", () => {
62
+ for (const persona of Object.values(DEV_PERSONAS)) {
63
+ expect(persona.actor.membership === null || typeof persona.actor.membership?.memberSince === "string").toBe(
64
+ true,
65
+ );
66
+ expect("membership" in persona.effective).toBe(false);
67
+ expect(typeof persona.effective.sub).toBe("string");
68
+ if (persona.effective.isDelegated) {
69
+ expect(persona.effective.sub).not.toBe(persona.actor.sub);
70
+ } else {
71
+ expect(persona.effective.sub).toBe(persona.actor.sub);
72
+ }
73
+ }
74
+ expect(DEV_PERSONAS.member).toMatchObject({
75
+ actor: { sub: "dev-member", membership: { memberSince: "2024-06-01" } },
76
+ effective: { sub: "dev-member", activeRole: "", isDelegated: false, extraRoles: [] },
77
+ });
78
+ expect(DEV_PERSONAS["hq-staff"]?.actor.membership).toEqual({ memberSince: "2024-01-01" });
79
+ expect(DEV_PERSONAS.unverified?.actor.membership).toBeNull();
80
+ expect(DEV_PERSONAS["kyc-pending"]?.actor.membership).toBeNull();
81
+ expect(DEV_PERSONAS["kyc-verified-nonmember"]?.actor.membership).toBeNull();
82
+ });
83
+
84
+ it("lets a member pass member and not internal, and staff pass both", () => {
85
+ const persona = DEV_PERSONAS.member;
86
+ if (!persona) throw new Error("member persona missing");
87
+ expect(passesLevel(null, "public")).toBe(true);
88
+ expect(passesLevel(null, "member")).toBe(false);
89
+ expect(passesLevel(persona, "public")).toBe(true);
90
+ expect(passesLevel(persona, "member")).toBe(true);
91
+ expect(passesLevel(persona, "internal")).toBe(false);
92
+ expect(passesLevel(persona, { roles: ["pple-ad:hq"] })).toBe(false);
93
+ expect(passesLevel(DEV_PERSONAS["hq-staff"]!, "internal")).toBe(true);
94
+ expect(passesLevel(DEV_PERSONAS["hq-staff"]!, "member")).toBe(true);
95
+ expect(passesLevel(DEV_PERSONAS.unverified!, "member")).toBe(false);
96
+ expect(passesLevel(DEV_PERSONAS["kyc-verified-nonmember"]!, "member")).toBe(false);
97
+ expect(passesLevel(DEV_PERSONAS.delegate!, "member")).toBe(true);
98
+ expect(passesLevel(DEV_PERSONAS.delegate!, { subs: ["dev-delegate"] })).toBe(true);
99
+ expect(passesLevel(DEV_PERSONAS.delegate!, { subs: ["dev-province-coordinator"] })).toBe(true);
100
+ expect(DEV_PERSONAS.delegate?.effective.sub).toBe("dev-province-coordinator");
101
+ expect(passesLevel(DEV_PERSONAS["hq-staff"]!, { subs: ["dev-province-coordinator"] })).toBe(false);
102
+ expect(passesLevel(DEV_PERSONAS["platform-admin"]!, { roles: ["pple-ad:mp"] })).toBe(false);
103
+ expect(passesLevel(DEV_PERSONAS["platform-admin"]!, { roles: ["pple-ad:platform:admin"] })).toBe(true);
104
+ });
105
+ });