@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
|
@@ -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
|
|
19
|
-
*
|
|
20
|
-
|
|
21
|
-
|
|
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 = {
|
|
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
|
-
|
|
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
|
}
|
|
@@ -10,9 +10,8 @@ import {
|
|
|
10
10
|
OFFLINE_INVALID_ID,
|
|
11
11
|
OFFLINE_LEGAL_NAME,
|
|
12
12
|
thaiIdChecksumValid,
|
|
13
|
-
type LegalIdentityReason,
|
|
14
13
|
} from "../src/legal-identity";
|
|
15
|
-
import { makeHarness } from "./harness";
|
|
14
|
+
import { makeHarness, serviceRefusal } from "./harness";
|
|
16
15
|
|
|
17
16
|
describe("actor.kyc", () => {
|
|
18
17
|
it("gives every Persona a KYC Status, and members are verified via membership", () => {
|
|
@@ -179,33 +178,101 @@ describe("getLegalIdentity against Platform Services", () => {
|
|
|
179
178
|
expect(fetchImpl).not.toHaveBeenCalled();
|
|
180
179
|
});
|
|
181
180
|
|
|
182
|
-
it("
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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"],
|
|
190
227
|
];
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
expect(url).toBe("https://services.test/legal-identity");
|
|
197
|
-
expect(new Headers(init?.headers).get("authorization")).toBe("Bearer app-key");
|
|
198
|
-
expect(JSON.parse(String(init?.body))).toEqual({ token: "user-token" });
|
|
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 });
|
|
199
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
|
+
});
|
|
200
241
|
});
|
|
201
242
|
|
|
202
|
-
it("
|
|
203
|
-
|
|
204
|
-
|
|
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,
|
|
205
259
|
);
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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",
|
|
209
276
|
});
|
|
210
277
|
});
|
|
211
278
|
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// readDataset (issue #270). The harness is the platform: tests see the URL,
|
|
2
|
+
// the headers, the page, and the refusal a Builder would observe.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { platformError } from "../src/call-service";
|
|
6
|
+
import { readDataset } from "../src/read-dataset";
|
|
7
|
+
import { makeHarness, serviceRefusal } from "./harness";
|
|
8
|
+
|
|
9
|
+
const env = {
|
|
10
|
+
DEV_PERSONA: "none",
|
|
11
|
+
PPLE_SERVICES_URL: "https://services.test",
|
|
12
|
+
PPLE_APP_KEY: "app-key",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const SWEPT = "2026-11-01T00:00:00.000Z";
|
|
16
|
+
|
|
17
|
+
describe("readDataset", () => {
|
|
18
|
+
it("sends the App Key, the user token and the query, and returns one page", async () => {
|
|
19
|
+
let seen: { authorization: string | null; userToken: string | null } | undefined;
|
|
20
|
+
const h = makeHarness(
|
|
21
|
+
{
|
|
22
|
+
services: (req) => {
|
|
23
|
+
seen = {
|
|
24
|
+
authorization: req.headers.get("authorization"),
|
|
25
|
+
userToken: req.headers.get("x-pple-user-token"),
|
|
26
|
+
};
|
|
27
|
+
return Response.json(
|
|
28
|
+
{
|
|
29
|
+
objects: [{ id: "branch-a", province: "เชียงใหม่" }],
|
|
30
|
+
next: "cursor-1",
|
|
31
|
+
sweptAt: SWEPT,
|
|
32
|
+
snapshot: 3,
|
|
33
|
+
},
|
|
34
|
+
{ headers: { etag: '"3-abc"' } },
|
|
35
|
+
);
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
env,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const result = await readDataset(h.testEnv, "directory/branches", "offices", {
|
|
42
|
+
userToken: "user-token",
|
|
43
|
+
filter: { province: "เชียงใหม่", seats: { gte: 4, lt: 9 }, status: ["open", "closed"] },
|
|
44
|
+
sort: "-seats",
|
|
45
|
+
limit: 2,
|
|
46
|
+
fetchImpl: h.fetchImpl,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(result).toEqual({
|
|
50
|
+
ok: true,
|
|
51
|
+
status: 200,
|
|
52
|
+
etag: '"3-abc"',
|
|
53
|
+
data: {
|
|
54
|
+
objects: [{ id: "branch-a", province: "เชียงใหม่" }],
|
|
55
|
+
next: "cursor-1",
|
|
56
|
+
sweptAt: SWEPT,
|
|
57
|
+
snapshot: 3,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
expect(seen).toEqual({ authorization: "Bearer app-key", userToken: "user-token" });
|
|
61
|
+
const url = new URL(`https://services.test${h.calls.services[0]?.path ?? ""}`);
|
|
62
|
+
expect(url.pathname).toBe("/directory/branches/offices");
|
|
63
|
+
expect(url.searchParams.get("province")).toBe("เชียงใหม่");
|
|
64
|
+
expect(url.searchParams.get("seats[gte]")).toBe("4");
|
|
65
|
+
expect(url.searchParams.get("seats[lt]")).toBe("9");
|
|
66
|
+
expect(url.searchParams.getAll("status")).toEqual(["open", "closed"]);
|
|
67
|
+
expect(url.searchParams.get("sort")).toBe("-seats");
|
|
68
|
+
expect(url.searchParams.get("limit")).toBe("2");
|
|
69
|
+
expect(h.calls.services[0]?.method).toBe("GET");
|
|
70
|
+
expect(h.calls.services[0]?.headers).not.toHaveProperty("authorization");
|
|
71
|
+
expect(h.calls.services[0]?.headers).not.toHaveProperty("x-pple-user-token");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("omits the user token unless one was given, and looks an object up by id", async () => {
|
|
75
|
+
let userToken: string | null = "unset";
|
|
76
|
+
const h = makeHarness(
|
|
77
|
+
{
|
|
78
|
+
services: (req) => {
|
|
79
|
+
userToken = req.headers.get("x-pple-user-token");
|
|
80
|
+
return Response.json({
|
|
81
|
+
object: { id: "branch-b", province: "กรุงเทพ" },
|
|
82
|
+
sweptAt: SWEPT,
|
|
83
|
+
snapshot: 3,
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
env,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const result = await readDataset(h.testEnv, "directory/branches", "offices", {
|
|
91
|
+
id: "branch-b",
|
|
92
|
+
fetchImpl: h.fetchImpl,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(userToken).toBeNull();
|
|
96
|
+
expect(result.ok).toBe(true);
|
|
97
|
+
if (!result.ok) return;
|
|
98
|
+
expect(result.data).toEqual({
|
|
99
|
+
object: { id: "branch-b", province: "กรุงเทพ" },
|
|
100
|
+
sweptAt: SWEPT,
|
|
101
|
+
snapshot: 3,
|
|
102
|
+
});
|
|
103
|
+
expect(h.calls.services[0]?.path).toBe("/directory/branches/offices/branch-b");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("reads a singleton", async () => {
|
|
107
|
+
const h = makeHarness(
|
|
108
|
+
{
|
|
109
|
+
services: () => Response.json({ object: { title: "สรุปสาขา" }, sweptAt: SWEPT, snapshot: 1 }),
|
|
110
|
+
},
|
|
111
|
+
env,
|
|
112
|
+
);
|
|
113
|
+
const result = await readDataset(h.testEnv, "directory/branches", "summary", {
|
|
114
|
+
shape: "singleton",
|
|
115
|
+
fetchImpl: h.fetchImpl,
|
|
116
|
+
});
|
|
117
|
+
expect(result).toMatchObject({
|
|
118
|
+
ok: true,
|
|
119
|
+
data: { object: { title: "สรุปสาขา" }, sweptAt: SWEPT, snapshot: 1 },
|
|
120
|
+
});
|
|
121
|
+
expect(h.calls.services[0]?.path).toBe("/directory/branches/summary");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("reads platform refusals from x-pple-error", async () => {
|
|
125
|
+
const codes = [
|
|
126
|
+
["not_found", 404],
|
|
127
|
+
["invalid_query", 400],
|
|
128
|
+
["level_not_met", 403],
|
|
129
|
+
["not_granted", 403],
|
|
130
|
+
["user_token_invalid", 401],
|
|
131
|
+
["no_mock", 404],
|
|
132
|
+
] as const;
|
|
133
|
+
let next = serviceRefusal("not_found", 404);
|
|
134
|
+
const h = makeHarness({ services: () => next }, env);
|
|
135
|
+
|
|
136
|
+
for (const [code, status] of codes) {
|
|
137
|
+
next = serviceRefusal(code, status);
|
|
138
|
+
const result = await readDataset(h.testEnv, "directory/branches", "offices", {
|
|
139
|
+
userToken: "t",
|
|
140
|
+
fetchImpl: h.fetchImpl,
|
|
141
|
+
});
|
|
142
|
+
expect(result).toEqual({ ok: false, status, error: code });
|
|
143
|
+
const header = serviceRefusal(code, status);
|
|
144
|
+
if (code === "no_mock") expect(platformError(header)).toBeNull();
|
|
145
|
+
else expect(platformError(header)).toBe(code);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("follows next and restarts when the snapshot changes", async () => {
|
|
150
|
+
const pages: { path: string }[] = [];
|
|
151
|
+
const h = makeHarness(
|
|
152
|
+
{
|
|
153
|
+
services: (req) => {
|
|
154
|
+
const url = new URL(req.url);
|
|
155
|
+
pages.push({ path: url.pathname + url.search });
|
|
156
|
+
const cursor = url.searchParams.get("next");
|
|
157
|
+
if (cursor === null && pages.length === 1) {
|
|
158
|
+
return Response.json({ objects: [{ id: "a" }], next: "old", sweptAt: SWEPT, snapshot: 1 });
|
|
159
|
+
}
|
|
160
|
+
if (cursor === "old") {
|
|
161
|
+
return Response.json({ objects: [{ id: "b" }], next: null, sweptAt: SWEPT, snapshot: 2 });
|
|
162
|
+
}
|
|
163
|
+
if (cursor === null) {
|
|
164
|
+
return Response.json({ objects: [{ id: "a2" }], next: "new", sweptAt: SWEPT, snapshot: 2 });
|
|
165
|
+
}
|
|
166
|
+
return Response.json({ objects: [{ id: "b2" }], next: null, sweptAt: SWEPT, snapshot: 2 });
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
env,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const result = await readDataset(h.testEnv, "directory/branches", "offices", {
|
|
173
|
+
limit: 1,
|
|
174
|
+
follow: true,
|
|
175
|
+
fetchImpl: h.fetchImpl,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(result).toMatchObject({
|
|
179
|
+
ok: true,
|
|
180
|
+
data: {
|
|
181
|
+
objects: [{ id: "a2" }, { id: "b2" }],
|
|
182
|
+
next: null,
|
|
183
|
+
snapshot: 2,
|
|
184
|
+
sweptAt: SWEPT,
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
expect(pages.map((page) => new URL(`https://services.test${page.path}`).searchParams.get("next"))).toEqual([
|
|
188
|
+
null,
|
|
189
|
+
"old",
|
|
190
|
+
null,
|
|
191
|
+
"new",
|
|
192
|
+
]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("uses the injected fetch under vitest, including when a persona is set", async () => {
|
|
196
|
+
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
|
197
|
+
Response.json({ objects: [], next: null, sweptAt: null, snapshot: null }),
|
|
198
|
+
);
|
|
199
|
+
const result = await readDataset(
|
|
200
|
+
{ ...env, DEV_PERSONA: "hq-staff", DEV_REGISTRY_ERROR: "not_granted" },
|
|
201
|
+
"directory/branches",
|
|
202
|
+
"offices",
|
|
203
|
+
{ fetchImpl },
|
|
204
|
+
);
|
|
205
|
+
expect(result).toMatchObject({ ok: true, data: { objects: [], next: null, sweptAt: null } });
|
|
206
|
+
expect(fetchImpl).toHaveBeenCalledOnce();
|
|
207
|
+
expect(String(fetchImpl.mock.calls[0]?.[0])).toBe("https://services.test/directory/branches/offices");
|
|
208
|
+
|
|
209
|
+
await expect(readDataset(env, "directory/branches", "offices")).rejects.toThrow(/injected fetch/);
|
|
210
|
+
await expect(
|
|
211
|
+
readDataset({ DEV_PERSONA: "none" }, "directory/branches", "offices", { fetchImpl }),
|
|
212
|
+
).rejects.toThrow(/PPLE_SERVICES_URL/);
|
|
213
|
+
});
|
|
214
|
+
});
|