@reventlessdev/reventless-local 3.0.0-alpha.217 → 3.0.0-alpha.219

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.
@@ -0,0 +1,203 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Nodefs from "node:fs";
4
+ import * as Nodeos from "node:os";
5
+ import * as Nodepath from "node:path";
6
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
7
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
8
+ import * as TestRunner$ReventlessLocal from "../src/test/TestRunner.res.mjs";
9
+ import * as ShellConfig$ReventlessLocal from "../src/ShellConfig.res.mjs";
10
+
11
+ TestRunner$ReventlessLocal.setup();
12
+
13
+ function manifest(key) {
14
+ return {
15
+ components: [{
16
+ plugin: "Catalog",
17
+ views: ["Products"],
18
+ commands: []
19
+ }],
20
+ key: key
21
+ };
22
+ }
23
+
24
+ function str(d, k) {
25
+ return Stdlib_Option.flatMap(d[k], Stdlib_JSON.Decode.string);
26
+ }
27
+
28
+ function threw(f) {
29
+ try {
30
+ f();
31
+ return false;
32
+ } catch (exn) {
33
+ return true;
34
+ }
35
+ }
36
+
37
+ globalThis.describe("ShellConfig.overlay", () => {
38
+ globalThis.test("is empty when the deployment declares nothing", () => {
39
+ let out = ShellConfig$ReventlessLocal.overlay(undefined, undefined);
40
+ globalThis.expect(Object.keys(out)).toEqual([]);
41
+ });
42
+ globalThis.test("points manifestUrl at the bake's default key", () => {
43
+ let out = ShellConfig$ReventlessLocal.overlay(manifest(undefined), undefined);
44
+ globalThis.expect(str(out, "manifestUrl")).toEqual("/component-manifest.json");
45
+ });
46
+ globalThis.test("follows a renamed bake", () => {
47
+ let out = ShellConfig$ReventlessLocal.overlay(manifest("storefront.json"), undefined);
48
+ globalThis.expect(str(out, "manifestUrl")).toEqual("/storefront.json");
49
+ });
50
+ globalThis.test("passes the deployment's own keys through verbatim", () => {
51
+ let out = ShellConfig$ReventlessLocal.overlay(undefined, Object.fromEntries([
52
+ [
53
+ "appName",
54
+ "Online Shop"
55
+ ],
56
+ [
57
+ "elevatedGroups",
58
+ ["Admin"].map(prim => prim)
59
+ ]
60
+ ]));
61
+ globalThis.expect(str(out, "appName")).toEqual("Online Shop");
62
+ globalThis.expect(Stdlib_Option.isSome(out["elevatedGroups"])).toBe(true);
63
+ });
64
+ globalThis.test("refuses a shellConfig key the platform computes", () => {
65
+ globalThis.expect(threw(() => {
66
+ ShellConfig$ReventlessLocal.overlay(manifest(undefined), Object.fromEntries([[
67
+ "manifestUrl",
68
+ "/elsewhere.json"
69
+ ]]));
70
+ })).toBe(true);
71
+ });
72
+ });
73
+
74
+ globalThis.describe("ShellConfig.emit", () => {
75
+ let shipped = `{\n "apiEndpoint": "/graphql",\n "appName": "Shipped"\n}`;
76
+ let distWithConfig = () => {
77
+ let dir = Nodefs.mkdtempSync(Nodepath.join(Nodeos.tmpdir(), "reventless-shellconfig-"));
78
+ Nodefs.writeFileSync(Nodepath.join(dir, "config.json"), shipped, "utf8");
79
+ return dir;
80
+ };
81
+ let readJson = path => Stdlib_Option.getOrThrow(Stdlib_JSON.Decode.object(JSON.parse(Nodefs.readFileSync(path, "utf8"))), undefined);
82
+ let readConfig = dir => readJson(Nodepath.join(dir, "config.json"));
83
+ let readBaseline = dir => readJson(Nodepath.join(dir, "config.base.json"));
84
+ let named = name => Object.fromEntries([[
85
+ "appName",
86
+ name
87
+ ]]);
88
+ globalThis.test("merges the overlay onto the shipped file and keeps its other keys", () => {
89
+ let dir = distWithConfig();
90
+ ShellConfig$ReventlessLocal.emit(manifest(undefined), undefined, dir);
91
+ let out = readConfig(dir);
92
+ globalThis.expect(str(out, "manifestUrl")).toEqual("/component-manifest.json");
93
+ globalThis.expect(str(out, "apiEndpoint")).toEqual("/graphql");
94
+ });
95
+ globalThis.test("leaves a platform that declares nothing entirely alone", () => {
96
+ let dir = distWithConfig();
97
+ ShellConfig$ReventlessLocal.emit(undefined, undefined, dir);
98
+ globalThis.expect(Nodefs.readFileSync(Nodepath.join(dir, "config.json"), "utf8")).toEqual(shipped);
99
+ globalThis.expect(Nodefs.existsSync(Nodepath.join(dir, "config.base.json"))).toBe(false);
100
+ });
101
+ globalThis.test("starts every boot from the shipped file, not the last output", () => {
102
+ let dir = distWithConfig();
103
+ ShellConfig$ReventlessLocal.emit(undefined, named("First"), dir);
104
+ ShellConfig$ReventlessLocal.emit(undefined, named("Second"), dir);
105
+ globalThis.expect(str(readConfig(dir), "appName")).toEqual("Second");
106
+ globalThis.expect(str(readBaseline(dir), "appName")).toEqual("Shipped");
107
+ });
108
+ globalThis.test("restores the shipped file once the declaration is withdrawn", () => {
109
+ let dir = distWithConfig();
110
+ ShellConfig$ReventlessLocal.emit(manifest(undefined), undefined, dir);
111
+ ShellConfig$ReventlessLocal.emit(undefined, undefined, dir);
112
+ let out = readConfig(dir);
113
+ globalThis.expect(out["manifestUrl"]).toEqual(undefined);
114
+ globalThis.expect(str(out, "appName")).toEqual("Shipped");
115
+ });
116
+ globalThis.test("refuses to overlay a dist that ships no config.json", () => {
117
+ let dir = Nodefs.mkdtempSync(Nodepath.join(Nodeos.tmpdir(), "reventless-shellconfig-"));
118
+ globalThis.expect(threw(() => ShellConfig$ReventlessLocal.emit(manifest(undefined), undefined, dir))).toBe(true);
119
+ });
120
+ });
121
+
122
+ function withJourneys(journeys) {
123
+ return {
124
+ components: [{
125
+ plugin: "Catalog",
126
+ views: ["Products"],
127
+ commands: []
128
+ }],
129
+ journeys: journeys
130
+ };
131
+ }
132
+
133
+ let shopper_components = [{
134
+ plugin: "Catalog",
135
+ views: ["Products"],
136
+ commands: []
137
+ }];
138
+
139
+ let shopper = {
140
+ group: "Shopper",
141
+ components: shopper_components
142
+ };
143
+
144
+ let fulfilment_components = [{
145
+ plugin: "Ordering",
146
+ views: ["Orders"],
147
+ commands: ["ShipOrder"]
148
+ }];
149
+
150
+ let fulfilment_key = "fulfilment.json";
151
+
152
+ let fulfilment = {
153
+ group: "Fulfilment",
154
+ components: fulfilment_components,
155
+ key: fulfilment_key
156
+ };
157
+
158
+ globalThis.describe("ShellConfig.overlay — journeys", () => {
159
+ globalThis.test("a bake declaring no journeys writes no map", () => {
160
+ let out = ShellConfig$ReventlessLocal.overlay(manifest(undefined), undefined);
161
+ globalThis.expect(out["journeyManifestUrls"]).toEqual(undefined);
162
+ });
163
+ globalThis.test("an empty journeys array is the same as none", () => {
164
+ let out = ShellConfig$ReventlessLocal.overlay(withJourneys([]), undefined);
165
+ globalThis.expect(out["journeyManifestUrls"]).toEqual(undefined);
166
+ });
167
+ globalThis.test("keeps manifestUrl as the default journey", () => {
168
+ let out = ShellConfig$ReventlessLocal.overlay(withJourneys([shopper]), undefined);
169
+ globalThis.expect(str(out, "manifestUrl")).toEqual("/component-manifest.json");
170
+ });
171
+ globalThis.test("maps each declared group to its own file", () => {
172
+ let out = ShellConfig$ReventlessLocal.overlay(withJourneys([
173
+ shopper,
174
+ fulfilment
175
+ ]), undefined);
176
+ let map = Stdlib_Option.getOrThrow(Stdlib_Option.flatMap(out["journeyManifestUrls"], Stdlib_JSON.Decode.object), undefined);
177
+ globalThis.expect([
178
+ str(map, "Shopper"),
179
+ str(map, "Fulfilment")
180
+ ]).toEqual([
181
+ "/component-manifest-shopper.json",
182
+ "/fulfilment.json"
183
+ ]);
184
+ });
185
+ globalThis.test("refuses a shellConfig that sets the journey map itself", () => {
186
+ globalThis.expect(threw(() => {
187
+ ShellConfig$ReventlessLocal.overlay(withJourneys([shopper]), Object.fromEntries([[
188
+ "journeyManifestUrls",
189
+ {}
190
+ ]]));
191
+ })).toBe(true);
192
+ });
193
+ });
194
+
195
+ export {
196
+ manifest,
197
+ str,
198
+ threw,
199
+ withJourneys,
200
+ shopper,
201
+ fulfilment,
202
+ }
203
+ /* Not a pure module */
@@ -0,0 +1,94 @@
1
+ open JestGlobals
2
+
3
+ // The `ui-hints.json` the local platform serves. Two things are under test:
4
+ // which file wins (the declaration or the host-shell package's own dev-mode
5
+ // fallback), and the baseline that makes a second boot — and a *withdrawn*
6
+ // declaration — land where they should. Every failure here is quiet: hints are
7
+ // presentation, so the symptom is a menu that reads slightly wrong.
8
+
9
+ let _ = TestRunner.setup()
10
+
11
+ let shipped = `{"Catalog": {"views": {"Products": {"nav": {"label": "Shipped"}}}}}`
12
+ let declared = `{"Catalog": {"views": {"Products": {"nav": {"label": "Declared"}}}}}`
13
+
14
+ let tmpdir = prefix => NodeFs.mkdtempSync(NodePath.join([NodeOs.tmpdir(), prefix]))
15
+
16
+ let distWithHints = () => {
17
+ let dir = tmpdir("reventless-uihints-dist-")
18
+ NodeFs.writeFileSync(NodePath.join([dir, "ui-hints.json"]), shipped)
19
+ dir
20
+ }
21
+
22
+ let declaredFile = (contents: string) => {
23
+ let path = NodePath.join([tmpdir("reventless-uihints-src-"), "ui-hints.json"])
24
+ NodeFs.writeFileSync(path, contents)
25
+ path
26
+ }
27
+
28
+ let served = (dir: string) => NodeFs.readFileSync(NodePath.join([dir, "ui-hints.json"]))
29
+ let baselinePath = (dir: string) => NodePath.join([dir, "ui-hints.base.json"])
30
+
31
+ let threw = (f: unit => unit): bool =>
32
+ try {
33
+ f()
34
+ false
35
+ } catch {
36
+ | _ => true
37
+ }
38
+
39
+ describe("UiHints.emit", () => {
40
+ testSync("serves the declared file verbatim", () => {
41
+ let dir = distWithHints()
42
+ UiHints.emit(~uiHintsFile=Some(declaredFile(declared)), ~dir)
43
+ expect(served(dir))->toEqual(declared)
44
+ })
45
+
46
+ // An undeclared platform has to be byte-identical to one built before this
47
+ // module existed, or every shell repo demonstration breaks to fix a problem
48
+ // it does not have.
49
+ testSync("leaves a platform that declares nothing entirely alone", () => {
50
+ let dir = distWithHints()
51
+ UiHints.emit(~uiHintsFile=None, ~dir)
52
+ expect(served(dir))->toEqual(shipped)
53
+ expect(NodeFs.existsSync(baselinePath(dir)))->toBe(false)
54
+ })
55
+
56
+ // Boot 2 seeding its baseline from boot 1's output would freeze the first
57
+ // declaration in as "shipped" and make the withdrawal below unreachable.
58
+ testSync("starts every boot from the shipped file, not the last output", () => {
59
+ let dir = distWithHints()
60
+ UiHints.emit(~uiHintsFile=Some(declaredFile(declared)), ~dir)
61
+ UiHints.emit(~uiHintsFile=Some(declaredFile(shipped)), ~dir)
62
+ expect(NodeFs.readFileSync(baselinePath(dir)))->toEqual(shipped)
63
+ })
64
+
65
+ testSync("restores the shipped file once the declaration is withdrawn", () => {
66
+ let dir = distWithHints()
67
+ UiHints.emit(~uiHintsFile=Some(declaredFile(declared)), ~dir)
68
+ UiHints.emit(~uiHintsFile=None, ~dir)
69
+ expect(served(dir))->toEqual(shipped)
70
+ })
71
+
72
+ // All three failures below produce the same symptom if swallowed — hints that
73
+ // quietly are not applied — so each has to be the boot's problem, not the
74
+ // reader's.
75
+ testSync("refuses a declaration naming a file that does not exist", () => {
76
+ let dir = distWithHints()
77
+ let missing = NodePath.join([tmpdir("reventless-uihints-src-"), "absent.json"])
78
+ expect(threw(() => UiHints.emit(~uiHintsFile=Some(missing), ~dir)))->toBe(true)
79
+ })
80
+
81
+ testSync("refuses a declared file that is not JSON", () => {
82
+ let dir = distWithHints()
83
+ expect(threw(() => UiHints.emit(~uiHintsFile=Some(declaredFile("not json")), ~dir)))->toBe(true)
84
+ })
85
+
86
+ // Read before anything is touched: a bad declaration must not leave the
87
+ // served file half-replaced, which would be worse than either outcome.
88
+ testSync("leaves the served file untouched when the declaration is bad", () => {
89
+ let dir = distWithHints()
90
+ let _ = threw(() => UiHints.emit(~uiHintsFile=Some(declaredFile("not json")), ~dir))
91
+ expect(served(dir))->toEqual(shipped)
92
+ expect(NodeFs.existsSync(baselinePath(dir)))->toBe(false)
93
+ })
94
+ })
@@ -0,0 +1,99 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Nodefs from "node:fs";
4
+ import * as Nodeos from "node:os";
5
+ import * as Nodepath from "node:path";
6
+ import * as UiHints$ReventlessLocal from "../src/UiHints.res.mjs";
7
+ import * as TestRunner$ReventlessLocal from "../src/test/TestRunner.res.mjs";
8
+
9
+ TestRunner$ReventlessLocal.setup();
10
+
11
+ let shipped = `{"Catalog": {"views": {"Products": {"nav": {"label": "Shipped"}}}}}`;
12
+
13
+ let declared = `{"Catalog": {"views": {"Products": {"nav": {"label": "Declared"}}}}}`;
14
+
15
+ function tmpdir(prefix) {
16
+ return Nodefs.mkdtempSync(Nodepath.join(Nodeos.tmpdir(), prefix));
17
+ }
18
+
19
+ function distWithHints() {
20
+ let dir = tmpdir("reventless-uihints-dist-");
21
+ Nodefs.writeFileSync(Nodepath.join(dir, "ui-hints.json"), shipped, "utf8");
22
+ return dir;
23
+ }
24
+
25
+ function declaredFile(contents) {
26
+ let path = Nodepath.join(tmpdir("reventless-uihints-src-"), "ui-hints.json");
27
+ Nodefs.writeFileSync(path, contents, "utf8");
28
+ return path;
29
+ }
30
+
31
+ function served(dir) {
32
+ return Nodefs.readFileSync(Nodepath.join(dir, "ui-hints.json"), "utf8");
33
+ }
34
+
35
+ function baselinePath(dir) {
36
+ return Nodepath.join(dir, "ui-hints.base.json");
37
+ }
38
+
39
+ function threw(f) {
40
+ try {
41
+ f();
42
+ return false;
43
+ } catch (exn) {
44
+ return true;
45
+ }
46
+ }
47
+
48
+ globalThis.describe("UiHints.emit", () => {
49
+ globalThis.test("serves the declared file verbatim", () => {
50
+ let dir = distWithHints();
51
+ UiHints$ReventlessLocal.emit(declaredFile(declared), dir);
52
+ globalThis.expect(served(dir)).toEqual(declared);
53
+ });
54
+ globalThis.test("leaves a platform that declares nothing entirely alone", () => {
55
+ let dir = distWithHints();
56
+ UiHints$ReventlessLocal.emit(undefined, dir);
57
+ globalThis.expect(served(dir)).toEqual(shipped);
58
+ globalThis.expect(Nodefs.existsSync(Nodepath.join(dir, "ui-hints.base.json"))).toBe(false);
59
+ });
60
+ globalThis.test("starts every boot from the shipped file, not the last output", () => {
61
+ let dir = distWithHints();
62
+ UiHints$ReventlessLocal.emit(declaredFile(declared), dir);
63
+ UiHints$ReventlessLocal.emit(declaredFile(shipped), dir);
64
+ globalThis.expect(Nodefs.readFileSync(Nodepath.join(dir, "ui-hints.base.json"), "utf8")).toEqual(shipped);
65
+ });
66
+ globalThis.test("restores the shipped file once the declaration is withdrawn", () => {
67
+ let dir = distWithHints();
68
+ UiHints$ReventlessLocal.emit(declaredFile(declared), dir);
69
+ UiHints$ReventlessLocal.emit(undefined, dir);
70
+ globalThis.expect(served(dir)).toEqual(shipped);
71
+ });
72
+ globalThis.test("refuses a declaration naming a file that does not exist", () => {
73
+ let dir = distWithHints();
74
+ let missing = Nodepath.join(tmpdir("reventless-uihints-src-"), "absent.json");
75
+ globalThis.expect(threw(() => UiHints$ReventlessLocal.emit(missing, dir))).toBe(true);
76
+ });
77
+ globalThis.test("refuses a declared file that is not JSON", () => {
78
+ let dir = distWithHints();
79
+ globalThis.expect(threw(() => UiHints$ReventlessLocal.emit(declaredFile("not json"), dir))).toBe(true);
80
+ });
81
+ globalThis.test("leaves the served file untouched when the declaration is bad", () => {
82
+ let dir = distWithHints();
83
+ threw(() => UiHints$ReventlessLocal.emit(declaredFile("not json"), dir));
84
+ globalThis.expect(served(dir)).toEqual(shipped);
85
+ globalThis.expect(Nodefs.existsSync(Nodepath.join(dir, "ui-hints.base.json"))).toBe(false);
86
+ });
87
+ });
88
+
89
+ export {
90
+ shipped,
91
+ declared,
92
+ tmpdir,
93
+ distWithHints,
94
+ declaredFile,
95
+ served,
96
+ baselinePath,
97
+ threw,
98
+ }
99
+ /* Not a pure module */
@@ -178,3 +178,217 @@ testPromise("setCredentials mirrors identity into the X-User registry", async ()
178
178
  | _ => JsError.throwWithMessage("expected Authenticated(alice) via X-User")
179
179
  }
180
180
  })
181
+
182
+ // ── Acting as one of the roles you hold ───────────────────────────────────
183
+ //
184
+ // The subset rule is the security-critical line of the feature: narrowing only,
185
+ // never widening, so a client that tampers with the request can only ever reduce
186
+ // its own privilege. The table below is the same one the Cognito minting path
187
+ // has to satisfy — the two implementations cannot be shared across a process
188
+ // boundary, so the cases are what keeps them from drifting.
189
+
190
+ let multiRole: Reventless.Identity.t = {
191
+ userId: "u-carol",
192
+ username: "carol",
193
+ groups: ["Fulfilment", "Shopper"],
194
+ provider: InMemory,
195
+ }
196
+
197
+ let decodeOrThrow = token =>
198
+ switch LocalAuth.Login.verifyAndDecode(token) {
199
+ | Some(i) => i
200
+ | None => JsError.throwWithMessage("expected a verifiable token")
201
+ }
202
+
203
+ let carolLoggedIn = () => {
204
+ resetAll()
205
+ LocalAuth.Login.setCredentials(~username="carol", ~password="carol-pw", ~identity=multiRole)
206
+ }
207
+
208
+ // The regression line. Every login that existed before this feature takes this
209
+ // path, and it has to mint what it always minted.
210
+ testPromise("a login naming no role mints exactly what it minted before", async () => {
211
+ carolLoggedIn()
212
+ let plain = switch await LocalAuth.Login.issue(~username="carol", ~password="carol-pw") {
213
+ | Ok(t) => t
214
+ | Error(e) => JsError.throwWithMessage(e)
215
+ }
216
+ let identity = decodeOrThrow(plain)
217
+ expect(identity.groups)->toEqual(["Fulfilment", "Shopper"])
218
+ expect(identity.claims)->toEqual(None)
219
+ })
220
+
221
+ testPromise("a login naming a held role mints that role alone", async () => {
222
+ carolLoggedIn()
223
+ let token = switch await LocalAuth.Login.issue(
224
+ ~username="carol",
225
+ ~password="carol-pw",
226
+ ~activeRole="Shopper",
227
+ ) {
228
+ | Ok(t) => t
229
+ | Error(e) => JsError.throwWithMessage(e)
230
+ }
231
+ let identity = decodeOrThrow(token)
232
+ // `groups` is what every enforcement point reads, so this is the assertion
233
+ // that the narrowing is real rather than cosmetic.
234
+ expect(identity.groups)->toEqual(["Shopper"])
235
+ })
236
+
237
+ testPromise("a narrowed token remembers the choice and what it gave up", async () => {
238
+ carolLoggedIn()
239
+ let token = switch await LocalAuth.Login.issue(
240
+ ~username="carol",
241
+ ~password="carol-pw",
242
+ ~activeRole="Shopper",
243
+ ) {
244
+ | Ok(t) => t
245
+ | Error(e) => JsError.throwWithMessage(e)
246
+ }
247
+ let identity = decodeOrThrow(token)
248
+ expect((
249
+ identity->Reventless.Identity.getClaim("activeRole"),
250
+ identity->Reventless.Identity.getClaim("availableRoles"),
251
+ ))->toEqual((Some("Shopper"), Some("Fulfilment,Shopper")))
252
+ })
253
+
254
+ // The line that decides whether this is a security feature or a suggestion.
255
+ // Refused, specifically — not ignored and minted at full membership, which is
256
+ // the failure that would hand a tampering client everything it asked for.
257
+ testPromise("a login naming a role the user does not hold is REFUSED", async () => {
258
+ carolLoggedIn()
259
+ switch await LocalAuth.Login.issue(
260
+ ~username="carol",
261
+ ~password="carol-pw",
262
+ ~activeRole="Admin",
263
+ ) {
264
+ | Ok(_) => JsError.throwWithMessage("expected a request to widen to be refused")
265
+ | Error(msg) => expect(msg->String.includes("Admin"))->toEqual(true)
266
+ }
267
+ })
268
+
269
+ // Narrowing to a role you hold while *also* naming one you do not is the same
270
+ // widening attempt wearing a disguise; there is no partial credit.
271
+ testPromise("narrowing cannot smuggle a group in through the claims bag", async () => {
272
+ resetAll()
273
+ let withClaims: Reventless.Identity.t = {
274
+ ...multiRole,
275
+ claims: Dict.fromArray([("availableRoles", "Admin,Fulfilment,Shopper")]),
276
+ }
277
+ LocalAuth.Login.setCredentials(~username="carol", ~password="carol-pw", ~identity=withClaims)
278
+ switch await LocalAuth.Login.issue(
279
+ ~username="carol",
280
+ ~password="carol-pw",
281
+ ~activeRole="Admin",
282
+ ) {
283
+ | Ok(_) =>
284
+ JsError.throwWithMessage("expected membership to be judged by groups, not by a claim")
285
+ | Error(_) => expect(true)->toEqual(true)
286
+ }
287
+ })
288
+
289
+ // A narrowed token has to survive the same round trip an ordinary one does, or
290
+ // the narrowing would hold only until the next request.
291
+ testPromise("a narrowed token authenticates as the narrowed identity", async () => {
292
+ carolLoggedIn()
293
+ let token = switch await LocalAuth.Login.issue(
294
+ ~username="carol",
295
+ ~password="carol-pw",
296
+ ~activeRole="Shopper",
297
+ ) {
298
+ | Ok(t) => t
299
+ | Error(e) => JsError.throwWithMessage(e)
300
+ }
301
+ let result = await LocalAuth.authenticate(
302
+ buildContext([("authorization", "Bearer " ++ token)]),
303
+ )
304
+ switch result {
305
+ | Authenticated(identity) => expect(identity.groups)->toEqual(["Shopper"])
306
+ | _ => JsError.throwWithMessage("expected the narrowed token to authenticate")
307
+ }
308
+ })
309
+
310
+ // The login response echoes this, and it has to describe the token it ships
311
+ // beside rather than the account behind it.
312
+ testPromise("the minted identity matches the token, not the stored user", async () => {
313
+ carolLoggedIn()
314
+ let minted = LocalAuth.Login.mintedIdentity(~username="carol", ~activeRole=Some("Shopper"))
315
+ let stored = LocalAuth.lookupUser("carol")
316
+ expect((
317
+ minted->Option.mapOr([], i => i.groups),
318
+ stored->Option.mapOr([], i => i.groups),
319
+ ))->toEqual((["Shopper"], ["Fulfilment", "Shopper"]))
320
+ })
321
+
322
+ // ── Switching an existing session ─────────────────────────────────────────
323
+ //
324
+ // A switch is not a re-authentication: the client holds a token, not a
325
+ // password. Possession of a token this server signed is proof of the
326
+ // credentials that produced it, so the switch re-mints from the token — and
327
+ // re-reads membership from the store, never from the token's own record of it.
328
+
329
+ testPromise("a session can be re-minted as one of its roles", async () => {
330
+ carolLoggedIn()
331
+ let wide = switch await LocalAuth.Login.issue(~username="carol", ~password="carol-pw") {
332
+ | Ok(t) => t
333
+ | Error(e) => JsError.throwWithMessage(e)
334
+ }
335
+ switch LocalAuth.Login.reissue(~token=wide, ~activeRole=Some("Shopper")) {
336
+ | Ok(t) => expect(decodeOrThrow(t).groups)->toEqual(["Shopper"])
337
+ | Error(e) => JsError.throwWithMessage(e)
338
+ }
339
+ })
340
+
341
+ // Switching back is the other half of a switcher, and it is not an escalation:
342
+ // the set being widened to is the one the store says the caller holds.
343
+ testPromise("a narrowed session can widen back to its full membership", async () => {
344
+ carolLoggedIn()
345
+ let narrow = switch await LocalAuth.Login.issue(
346
+ ~username="carol",
347
+ ~password="carol-pw",
348
+ ~activeRole="Shopper",
349
+ ) {
350
+ | Ok(t) => t
351
+ | Error(e) => JsError.throwWithMessage(e)
352
+ }
353
+ switch LocalAuth.Login.reissue(~token=narrow, ~activeRole=None) {
354
+ | Ok(t) =>
355
+ let identity = decodeOrThrow(t)
356
+ expect((identity.groups, identity.claims))->toEqual((["Fulfilment", "Shopper"], None))
357
+ | Error(e) => JsError.throwWithMessage(e)
358
+ }
359
+ })
360
+
361
+ // 🚨 The assertion that keeps `availableRoles` from becoming an authority. A
362
+ // narrowed token carries the record of what it gave up; if the switch trusted
363
+ // that record instead of the store, a token forged with a wider claim — or a
364
+ // role revoked since issuance — would widen right back into it.
365
+ testPromise("switching judges membership from the store, not the token's claim", async () => {
366
+ carolLoggedIn()
367
+ let narrow = switch await LocalAuth.Login.issue(
368
+ ~username="carol",
369
+ ~password="carol-pw",
370
+ ~activeRole="Shopper",
371
+ ) {
372
+ | Ok(t) => t
373
+ | Error(e) => JsError.throwWithMessage(e)
374
+ }
375
+ // The store loses the role while the token still remembers it.
376
+ LocalAuth.Login.setCredentials(
377
+ ~username="carol",
378
+ ~password="carol-pw",
379
+ ~identity={...multiRole, groups: ["Shopper"]},
380
+ )
381
+ switch LocalAuth.Login.reissue(~token=narrow, ~activeRole=Some("Fulfilment")) {
382
+ | Ok(_) =>
383
+ JsError.throwWithMessage("expected a revoked role to be unreachable via the token's claim")
384
+ | Error(_) => expect(true)->toEqual(true)
385
+ }
386
+ })
387
+
388
+ testPromise("switching refuses a token this server did not sign", async () => {
389
+ carolLoggedIn()
390
+ switch LocalAuth.Login.reissue(~token="not.a-real-token", ~activeRole=Some("Shopper")) {
391
+ | Ok(_) => JsError.throwWithMessage("expected an unverifiable token to be refused")
392
+ | Error(msg) => expect(msg)->toEqual("Invalid token")
393
+ }
394
+ })