@emkodev/emkore 1.0.3 → 1.1.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.
@@ -0,0 +1,1001 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "bun:test";
2
+ import { Actor } from "../../src/common/abstract.actor.ts";
3
+ import { Usecase } from "../../src/common/abstract.usecase.ts";
4
+ import { AuthorizationInterceptor } from "../../src/common/interceptor/authorization.interceptor.ts";
5
+ import { AuthorizationException } from "../../src/common/exception/authorization-exception.ts";
6
+ import {
7
+ type Permission,
8
+ ResourceScope,
9
+ } from "../../src/common/type/permission.type.ts";
10
+ import type { ApiDefinition } from "../../src/common/llm/api-definition.type.ts";
11
+ import type { UsecaseName } from "../../src/common/abstract.usecase.ts";
12
+
13
+ class TestActor extends Actor {
14
+ constructor(
15
+ private _id: string,
16
+ private _businessId: string,
17
+ private _token: string,
18
+ permissions: Permission[] = [],
19
+ ) {
20
+ super();
21
+ this.permissions = permissions;
22
+ }
23
+
24
+ get id(): string {
25
+ return this._id;
26
+ }
27
+
28
+ get businessId(): string {
29
+ return this._businessId;
30
+ }
31
+
32
+ get token(): string {
33
+ return this._token;
34
+ }
35
+ }
36
+
37
+ class TestUsecase extends Usecase<void, void> {
38
+ override readonly usecaseName: UsecaseName;
39
+ override readonly apiDefinition: ApiDefinition = {
40
+ description: "test",
41
+ input: {},
42
+ output: {},
43
+ };
44
+
45
+ private _requiredPermissions: Permission[];
46
+
47
+ constructor(name: UsecaseName, requiredPermissions: Permission[]) {
48
+ super();
49
+ this.usecaseName = name;
50
+ this._requiredPermissions = requiredPermissions;
51
+ }
52
+
53
+ override get requiredPermissions(): Permission[] {
54
+ return this._requiredPermissions;
55
+ }
56
+
57
+ protected async _execute(): Promise<void> {}
58
+ }
59
+
60
+ describe("Authorization constraint matching", () => {
61
+ beforeEach(() => {
62
+ Usecase.use([new AuthorizationInterceptor()]);
63
+ });
64
+
65
+ afterEach(() => {
66
+ // Clear global interceptors by accessing private field
67
+ (Usecase as any)._globalInterceptors.clear();
68
+ });
69
+
70
+ describe("grant-side constraints are ignored when usecase does not require them", () => {
71
+ test("actor with teamId constraint passes when usecase only requires scope", async () => {
72
+ const actor = new TestActor("u1", "b1", "t1", [
73
+ {
74
+ resource: "invoice",
75
+ action: "update",
76
+ constraints: { scope: ResourceScope.BUSINESS, teamId: "sales" },
77
+ },
78
+ ]);
79
+
80
+ const usecase = new TestUsecase(["update", "invoice"], [
81
+ {
82
+ resource: "invoice",
83
+ action: "update",
84
+ constraints: { scope: ResourceScope.TEAM },
85
+ },
86
+ ]);
87
+
88
+ await expect(usecase.execute(actor, undefined)).resolves.toBeUndefined();
89
+ });
90
+
91
+ test("actor with statuses constraint passes when usecase does not require statuses", async () => {
92
+ const actor = new TestActor("u1", "b1", "t1", [
93
+ {
94
+ resource: "order",
95
+ action: "retrieve",
96
+ constraints: { scope: ResourceScope.TEAM, statuses: ["draft"] },
97
+ },
98
+ ]);
99
+
100
+ const usecase = new TestUsecase(["retrieve", "order"], [
101
+ {
102
+ resource: "order",
103
+ action: "retrieve",
104
+ constraints: { scope: ResourceScope.TEAM },
105
+ },
106
+ ]);
107
+
108
+ await expect(usecase.execute(actor, undefined)).resolves.toBeUndefined();
109
+ });
110
+
111
+ test("actor with businessId constraint passes when usecase does not require businessId", async () => {
112
+ const actor = new TestActor("u1", "b1", "t1", [
113
+ {
114
+ resource: "report",
115
+ action: "list",
116
+ constraints: { scope: ResourceScope.BUSINESS, businessId: "biz-1" },
117
+ },
118
+ ]);
119
+
120
+ const usecase = new TestUsecase(["list", "report"], [
121
+ {
122
+ resource: "report",
123
+ action: "list",
124
+ constraints: { scope: ResourceScope.BUSINESS },
125
+ },
126
+ ]);
127
+
128
+ await expect(usecase.execute(actor, undefined)).resolves.toBeUndefined();
129
+ });
130
+ });
131
+
132
+ describe("no constraints required means any grant matches", () => {
133
+ test("actor with no constraints passes when usecase requires none", async () => {
134
+ const actor = new TestActor("u1", "b1", "t1", [
135
+ { resource: "item", action: "retrieve" },
136
+ ]);
137
+
138
+ const usecase = new TestUsecase(["retrieve", "item"], [
139
+ { resource: "item", action: "retrieve" },
140
+ ]);
141
+
142
+ await expect(usecase.execute(actor, undefined)).resolves.toBeUndefined();
143
+ });
144
+
145
+ test("actor with constraints passes when usecase requires none", async () => {
146
+ const actor = new TestActor("u1", "b1", "t1", [
147
+ {
148
+ resource: "item",
149
+ action: "retrieve",
150
+ constraints: { scope: ResourceScope.OWNED, teamId: "t1" },
151
+ },
152
+ ]);
153
+
154
+ const usecase = new TestUsecase(["retrieve", "item"], [
155
+ { resource: "item", action: "retrieve" },
156
+ ]);
157
+
158
+ await expect(usecase.execute(actor, undefined)).resolves.toBeUndefined();
159
+ });
160
+ });
161
+
162
+ describe("required constraints must be present on grant", () => {
163
+ test("actor without constraints fails when usecase requires scope", async () => {
164
+ const actor = new TestActor("u1", "b1", "t1", [
165
+ { resource: "invoice", action: "update" },
166
+ ]);
167
+
168
+ const usecase = new TestUsecase(["update", "invoice"], [
169
+ {
170
+ resource: "invoice",
171
+ action: "update",
172
+ constraints: { scope: ResourceScope.TEAM },
173
+ },
174
+ ]);
175
+
176
+ await expect(usecase.execute(actor, undefined)).rejects.toThrow(
177
+ AuthorizationException,
178
+ );
179
+ });
180
+
181
+ test("actor without teamId fails when usecase requires specific teamId", async () => {
182
+ const actor = new TestActor("u1", "b1", "t1", [
183
+ {
184
+ resource: "doc",
185
+ action: "update",
186
+ constraints: { scope: ResourceScope.TEAM },
187
+ },
188
+ ]);
189
+
190
+ const usecase = new TestUsecase(["update", "doc"], [
191
+ {
192
+ resource: "doc",
193
+ action: "update",
194
+ constraints: { scope: ResourceScope.TEAM, teamId: "legal" },
195
+ },
196
+ ]);
197
+
198
+ await expect(usecase.execute(actor, undefined)).rejects.toThrow(
199
+ AuthorizationException,
200
+ );
201
+ });
202
+ });
203
+
204
+ describe("scope hierarchy in constraint matching", () => {
205
+ test("ALL scope satisfies TEAM requirement", async () => {
206
+ const actor = new TestActor("u1", "b1", "t1", [
207
+ {
208
+ resource: "doc",
209
+ action: "retrieve",
210
+ constraints: { scope: ResourceScope.ALL },
211
+ },
212
+ ]);
213
+
214
+ const usecase = new TestUsecase(["retrieve", "doc"], [
215
+ {
216
+ resource: "doc",
217
+ action: "retrieve",
218
+ constraints: { scope: ResourceScope.TEAM },
219
+ },
220
+ ]);
221
+
222
+ await expect(usecase.execute(actor, undefined)).resolves.toBeUndefined();
223
+ });
224
+
225
+ test("TEAM scope does not satisfy PROJECT requirement (parallel siblings)", async () => {
226
+ const actor = new TestActor("u1", "b1", "t1", [
227
+ {
228
+ resource: "task",
229
+ action: "update",
230
+ constraints: { scope: ResourceScope.TEAM },
231
+ },
232
+ ]);
233
+
234
+ const usecase = new TestUsecase(["update", "task"], [
235
+ {
236
+ resource: "task",
237
+ action: "update",
238
+ constraints: { scope: ResourceScope.PROJECT },
239
+ },
240
+ ]);
241
+
242
+ await expect(usecase.execute(actor, undefined)).rejects.toThrow(
243
+ AuthorizationException,
244
+ );
245
+ });
246
+
247
+ test("OWNED scope does not satisfy TEAM requirement", async () => {
248
+ const actor = new TestActor("u1", "b1", "t1", [
249
+ {
250
+ resource: "invoice",
251
+ action: "update",
252
+ constraints: { scope: ResourceScope.OWNED },
253
+ },
254
+ ]);
255
+
256
+ const usecase = new TestUsecase(["update", "invoice"], [
257
+ {
258
+ resource: "invoice",
259
+ action: "update",
260
+ constraints: { scope: ResourceScope.TEAM },
261
+ },
262
+ ]);
263
+
264
+ await expect(usecase.execute(actor, undefined)).rejects.toThrow(
265
+ AuthorizationException,
266
+ );
267
+ });
268
+ });
269
+
270
+ describe("ID constraint exact matching", () => {
271
+ test("matching teamId passes", async () => {
272
+ const actor = new TestActor("u1", "b1", "t1", [
273
+ {
274
+ resource: "doc",
275
+ action: "update",
276
+ constraints: { scope: ResourceScope.TEAM, teamId: "legal" },
277
+ },
278
+ ]);
279
+
280
+ const usecase = new TestUsecase(["update", "doc"], [
281
+ {
282
+ resource: "doc",
283
+ action: "update",
284
+ constraints: { scope: ResourceScope.TEAM, teamId: "legal" },
285
+ },
286
+ ]);
287
+
288
+ await expect(usecase.execute(actor, undefined)).resolves.toBeUndefined();
289
+ });
290
+
291
+ test("mismatching teamId fails", async () => {
292
+ const actor = new TestActor("u1", "b1", "t1", [
293
+ {
294
+ resource: "doc",
295
+ action: "update",
296
+ constraints: { scope: ResourceScope.TEAM, teamId: "sales" },
297
+ },
298
+ ]);
299
+
300
+ const usecase = new TestUsecase(["update", "doc"], [
301
+ {
302
+ resource: "doc",
303
+ action: "update",
304
+ constraints: { scope: ResourceScope.TEAM, teamId: "legal" },
305
+ },
306
+ ]);
307
+
308
+ await expect(usecase.execute(actor, undefined)).rejects.toThrow(
309
+ AuthorizationException,
310
+ );
311
+ });
312
+ });
313
+
314
+ describe("status constraint matching", () => {
315
+ test("granted statuses superset of required passes", async () => {
316
+ const actor = new TestActor("u1", "b1", "t1", [
317
+ {
318
+ resource: "order",
319
+ action: "update",
320
+ constraints: { statuses: ["draft", "pending", "active"] },
321
+ },
322
+ ]);
323
+
324
+ const usecase = new TestUsecase(["update", "order"], [
325
+ {
326
+ resource: "order",
327
+ action: "update",
328
+ constraints: { statuses: ["draft", "pending"] },
329
+ },
330
+ ]);
331
+
332
+ await expect(usecase.execute(actor, undefined)).resolves.toBeUndefined();
333
+ });
334
+
335
+ test("granted statuses subset of required fails", async () => {
336
+ const actor = new TestActor("u1", "b1", "t1", [
337
+ {
338
+ resource: "order",
339
+ action: "update",
340
+ constraints: { statuses: ["draft"] },
341
+ },
342
+ ]);
343
+
344
+ const usecase = new TestUsecase(["update", "order"], [
345
+ {
346
+ resource: "order",
347
+ action: "update",
348
+ constraints: { statuses: ["draft", "pending"] },
349
+ },
350
+ ]);
351
+
352
+ await expect(usecase.execute(actor, undefined)).rejects.toThrow(
353
+ AuthorizationException,
354
+ );
355
+ });
356
+ });
357
+
358
+ describe("empty requiredPermissions skips authorization", () => {
359
+ test("any actor passes when usecase requires no permissions", async () => {
360
+ const actor = new TestActor("u1", "b1", "t1", []);
361
+
362
+ const usecase = new TestUsecase(["retrieve", "public-item"], []);
363
+
364
+ await expect(usecase.execute(actor, undefined)).resolves.toBeUndefined();
365
+ });
366
+ });
367
+
368
+ describe("resourceId constraint", () => {
369
+ test("matching resourceId passes", async () => {
370
+ const actor = new TestActor("u1", "b1", "t1", [
371
+ {
372
+ resource: "document",
373
+ action: "update",
374
+ constraints: { scope: ResourceScope.TEAM, resourceId: "doc-42" },
375
+ },
376
+ ]);
377
+
378
+ const usecase = new TestUsecase(["update", "document"], [
379
+ {
380
+ resource: "document",
381
+ action: "update",
382
+ constraints: { scope: ResourceScope.TEAM, resourceId: "doc-42" },
383
+ },
384
+ ]);
385
+
386
+ await expect(usecase.execute(actor, undefined)).resolves.toBeUndefined();
387
+ });
388
+
389
+ test("mismatching resourceId fails", async () => {
390
+ const actor = new TestActor("u1", "b1", "t1", [
391
+ {
392
+ resource: "document",
393
+ action: "update",
394
+ constraints: { scope: ResourceScope.TEAM, resourceId: "doc-42" },
395
+ },
396
+ ]);
397
+
398
+ const usecase = new TestUsecase(["update", "document"], [
399
+ {
400
+ resource: "document",
401
+ action: "update",
402
+ constraints: { scope: ResourceScope.TEAM, resourceId: "doc-99" },
403
+ },
404
+ ]);
405
+
406
+ await expect(usecase.execute(actor, undefined)).rejects.toThrow(
407
+ AuthorizationException,
408
+ );
409
+ });
410
+
411
+ test("grant without resourceId fails when usecase requires it", async () => {
412
+ const actor = new TestActor("u1", "b1", "t1", [
413
+ {
414
+ resource: "document",
415
+ action: "update",
416
+ constraints: { scope: ResourceScope.TEAM },
417
+ },
418
+ ]);
419
+
420
+ const usecase = new TestUsecase(["update", "document"], [
421
+ {
422
+ resource: "document",
423
+ action: "update",
424
+ constraints: { scope: ResourceScope.TEAM, resourceId: "doc-42" },
425
+ },
426
+ ]);
427
+
428
+ await expect(usecase.execute(actor, undefined)).rejects.toThrow(
429
+ AuthorizationException,
430
+ );
431
+ });
432
+
433
+ test("grant with resourceId passes when usecase does not require it", async () => {
434
+ const actor = new TestActor("u1", "b1", "t1", [
435
+ {
436
+ resource: "document",
437
+ action: "update",
438
+ constraints: { scope: ResourceScope.TEAM, resourceId: "doc-42" },
439
+ },
440
+ ]);
441
+
442
+ const usecase = new TestUsecase(["update", "document"], [
443
+ {
444
+ resource: "document",
445
+ action: "update",
446
+ constraints: { scope: ResourceScope.TEAM },
447
+ },
448
+ ]);
449
+
450
+ await expect(usecase.execute(actor, undefined)).resolves.toBeUndefined();
451
+ });
452
+
453
+ test("resourceId alone without scope", async () => {
454
+ const actor = new TestActor("u1", "b1", "t1", [
455
+ {
456
+ resource: "document",
457
+ action: "delete",
458
+ constraints: { resourceId: "doc-42" },
459
+ },
460
+ ]);
461
+
462
+ const usecase = new TestUsecase(["delete", "document"], [
463
+ {
464
+ resource: "document",
465
+ action: "delete",
466
+ constraints: { resourceId: "doc-42" },
467
+ },
468
+ ]);
469
+
470
+ await expect(usecase.execute(actor, undefined)).resolves.toBeUndefined();
471
+ });
472
+ });
473
+
474
+ describe("projectId constraint", () => {
475
+ test("matching projectId passes", async () => {
476
+ const actor = new TestActor("u1", "b1", "t1", [
477
+ {
478
+ resource: "task",
479
+ action: "create",
480
+ constraints: { scope: ResourceScope.PROJECT, projectId: "proj-1" },
481
+ },
482
+ ]);
483
+
484
+ const usecase = new TestUsecase(["create", "task"], [
485
+ {
486
+ resource: "task",
487
+ action: "create",
488
+ constraints: { scope: ResourceScope.PROJECT, projectId: "proj-1" },
489
+ },
490
+ ]);
491
+
492
+ await expect(usecase.execute(actor, undefined)).resolves.toBeUndefined();
493
+ });
494
+
495
+ test("mismatching projectId fails", async () => {
496
+ const actor = new TestActor("u1", "b1", "t1", [
497
+ {
498
+ resource: "task",
499
+ action: "create",
500
+ constraints: { scope: ResourceScope.PROJECT, projectId: "proj-1" },
501
+ },
502
+ ]);
503
+
504
+ const usecase = new TestUsecase(["create", "task"], [
505
+ {
506
+ resource: "task",
507
+ action: "create",
508
+ constraints: { scope: ResourceScope.PROJECT, projectId: "proj-2" },
509
+ },
510
+ ]);
511
+
512
+ await expect(usecase.execute(actor, undefined)).rejects.toThrow(
513
+ AuthorizationException,
514
+ );
515
+ });
516
+ });
517
+
518
+ describe("constraint combinations", () => {
519
+ test("scope + teamId + statuses all matching passes", async () => {
520
+ const actor = new TestActor("u1", "b1", "t1", [
521
+ {
522
+ resource: "order",
523
+ action: "update",
524
+ constraints: {
525
+ scope: ResourceScope.TEAM,
526
+ teamId: "sales",
527
+ statuses: ["draft", "pending"],
528
+ },
529
+ },
530
+ ]);
531
+
532
+ const usecase = new TestUsecase(["update", "order"], [
533
+ {
534
+ resource: "order",
535
+ action: "update",
536
+ constraints: {
537
+ scope: ResourceScope.TEAM,
538
+ teamId: "sales",
539
+ statuses: ["draft"],
540
+ },
541
+ },
542
+ ]);
543
+
544
+ await expect(usecase.execute(actor, undefined)).resolves.toBeUndefined();
545
+ });
546
+
547
+ test("scope + teamId + statuses — statuses mismatch fails despite scope and teamId matching", async () => {
548
+ const actor = new TestActor("u1", "b1", "t1", [
549
+ {
550
+ resource: "order",
551
+ action: "update",
552
+ constraints: {
553
+ scope: ResourceScope.TEAM,
554
+ teamId: "sales",
555
+ statuses: ["active"],
556
+ },
557
+ },
558
+ ]);
559
+
560
+ const usecase = new TestUsecase(["update", "order"], [
561
+ {
562
+ resource: "order",
563
+ action: "update",
564
+ constraints: {
565
+ scope: ResourceScope.TEAM,
566
+ teamId: "sales",
567
+ statuses: ["draft"],
568
+ },
569
+ },
570
+ ]);
571
+
572
+ await expect(usecase.execute(actor, undefined)).rejects.toThrow(
573
+ AuthorizationException,
574
+ );
575
+ });
576
+
577
+ test("scope + resourceId + businessId all matching passes", async () => {
578
+ const actor = new TestActor("u1", "b1", "t1", [
579
+ {
580
+ resource: "contract",
581
+ action: "retrieve",
582
+ constraints: {
583
+ scope: ResourceScope.BUSINESS,
584
+ businessId: "biz-1",
585
+ resourceId: "contract-77",
586
+ },
587
+ },
588
+ ]);
589
+
590
+ const usecase = new TestUsecase(["retrieve", "contract"], [
591
+ {
592
+ resource: "contract",
593
+ action: "retrieve",
594
+ constraints: {
595
+ scope: ResourceScope.BUSINESS,
596
+ businessId: "biz-1",
597
+ resourceId: "contract-77",
598
+ },
599
+ },
600
+ ]);
601
+
602
+ await expect(usecase.execute(actor, undefined)).resolves.toBeUndefined();
603
+ });
604
+
605
+ test("scope + resourceId + businessId — resourceId mismatch fails", async () => {
606
+ const actor = new TestActor("u1", "b1", "t1", [
607
+ {
608
+ resource: "contract",
609
+ action: "retrieve",
610
+ constraints: {
611
+ scope: ResourceScope.BUSINESS,
612
+ businessId: "biz-1",
613
+ resourceId: "contract-77",
614
+ },
615
+ },
616
+ ]);
617
+
618
+ const usecase = new TestUsecase(["retrieve", "contract"], [
619
+ {
620
+ resource: "contract",
621
+ action: "retrieve",
622
+ constraints: {
623
+ scope: ResourceScope.BUSINESS,
624
+ businessId: "biz-1",
625
+ resourceId: "contract-99",
626
+ },
627
+ },
628
+ ]);
629
+
630
+ await expect(usecase.execute(actor, undefined)).rejects.toThrow(
631
+ AuthorizationException,
632
+ );
633
+ });
634
+
635
+ test("ALL scope does not bypass ID constraint requirements", async () => {
636
+ const actor = new TestActor("u1", "b1", "t1", [
637
+ {
638
+ resource: "document",
639
+ action: "delete",
640
+ constraints: { scope: ResourceScope.ALL },
641
+ },
642
+ ]);
643
+
644
+ const usecase = new TestUsecase(["delete", "document"], [
645
+ {
646
+ resource: "document",
647
+ action: "delete",
648
+ constraints: { scope: ResourceScope.ALL, resourceId: "doc-42" },
649
+ },
650
+ ]);
651
+
652
+ await expect(usecase.execute(actor, undefined)).rejects.toThrow(
653
+ AuthorizationException,
654
+ );
655
+ });
656
+
657
+ test("multiple grants — first fails, second matches", async () => {
658
+ const actor = new TestActor("u1", "b1", "t1", [
659
+ {
660
+ resource: "document",
661
+ action: "update",
662
+ constraints: { scope: ResourceScope.TEAM, teamId: "sales" },
663
+ },
664
+ {
665
+ resource: "document",
666
+ action: "update",
667
+ constraints: { scope: ResourceScope.TEAM, teamId: "legal" },
668
+ },
669
+ ]);
670
+
671
+ const usecase = new TestUsecase(["update", "document"], [
672
+ {
673
+ resource: "document",
674
+ action: "update",
675
+ constraints: { scope: ResourceScope.TEAM, teamId: "legal" },
676
+ },
677
+ ]);
678
+
679
+ await expect(usecase.execute(actor, undefined)).resolves.toBeUndefined();
680
+ });
681
+
682
+ test("multiple grants — none match", async () => {
683
+ const actor = new TestActor("u1", "b1", "t1", [
684
+ {
685
+ resource: "document",
686
+ action: "update",
687
+ constraints: { scope: ResourceScope.TEAM, teamId: "sales" },
688
+ },
689
+ {
690
+ resource: "document",
691
+ action: "update",
692
+ constraints: { scope: ResourceScope.TEAM, teamId: "legal" },
693
+ },
694
+ ]);
695
+
696
+ const usecase = new TestUsecase(["update", "document"], [
697
+ {
698
+ resource: "document",
699
+ action: "update",
700
+ constraints: { scope: ResourceScope.TEAM, teamId: "engineering" },
701
+ },
702
+ ]);
703
+
704
+ await expect(usecase.execute(actor, undefined)).rejects.toThrow(
705
+ AuthorizationException,
706
+ );
707
+ });
708
+ });
709
+
710
+ describe("Recipe 1: edit a specific resource", () => {
711
+ test("actor with matching resourceId can edit the specific document", async () => {
712
+ const actor = new TestActor("u1", "b1", "t1", [
713
+ {
714
+ resource: "document",
715
+ action: "update",
716
+ constraints: { scope: ResourceScope.TEAM, resourceId: "doc-42" },
717
+ },
718
+ ]);
719
+
720
+ const usecase = new TestUsecase(["update", "document"], [
721
+ {
722
+ resource: "document",
723
+ action: "update",
724
+ constraints: { scope: ResourceScope.TEAM, resourceId: "doc-42" },
725
+ },
726
+ ]);
727
+
728
+ await expect(usecase.execute(actor, undefined)).resolves.toBeUndefined();
729
+ });
730
+
731
+ test("actor with different resourceId cannot edit someone else's document", async () => {
732
+ const actor = new TestActor("u1", "b1", "t1", [
733
+ {
734
+ resource: "document",
735
+ action: "update",
736
+ constraints: { scope: ResourceScope.TEAM, resourceId: "doc-42" },
737
+ },
738
+ ]);
739
+
740
+ const usecase = new TestUsecase(["update", "document"], [
741
+ {
742
+ resource: "document",
743
+ action: "update",
744
+ constraints: { scope: ResourceScope.TEAM, resourceId: "doc-99" },
745
+ },
746
+ ]);
747
+
748
+ await expect(usecase.execute(actor, undefined)).rejects.toThrow(
749
+ AuthorizationException,
750
+ );
751
+ });
752
+ });
753
+
754
+ describe("Recipe 2: see everything in a team", () => {
755
+ test("team member can list their team's tickets", async () => {
756
+ const actor = new TestActor("u1", "b1", "t1", [
757
+ {
758
+ resource: "ticket",
759
+ action: "list",
760
+ constraints: { scope: ResourceScope.TEAM, teamId: "engineering" },
761
+ },
762
+ ]);
763
+
764
+ const usecase = new TestUsecase(["list", "ticket"], [
765
+ {
766
+ resource: "ticket",
767
+ action: "list",
768
+ constraints: { scope: ResourceScope.TEAM, teamId: "engineering" },
769
+ },
770
+ ]);
771
+
772
+ await expect(usecase.execute(actor, undefined)).resolves.toBeUndefined();
773
+ });
774
+
775
+ test("BUSINESS scope also satisfies team-scoped list", async () => {
776
+ const actor = new TestActor("u1", "b1", "t1", [
777
+ {
778
+ resource: "ticket",
779
+ action: "list",
780
+ constraints: { scope: ResourceScope.BUSINESS, teamId: "engineering" },
781
+ },
782
+ ]);
783
+
784
+ const usecase = new TestUsecase(["list", "ticket"], [
785
+ {
786
+ resource: "ticket",
787
+ action: "list",
788
+ constraints: { scope: ResourceScope.TEAM, teamId: "engineering" },
789
+ },
790
+ ]);
791
+
792
+ await expect(usecase.execute(actor, undefined)).resolves.toBeUndefined();
793
+ });
794
+
795
+ test("actor from a different team cannot list", async () => {
796
+ const actor = new TestActor("u1", "b1", "t1", [
797
+ {
798
+ resource: "ticket",
799
+ action: "list",
800
+ constraints: { scope: ResourceScope.TEAM, teamId: "sales" },
801
+ },
802
+ ]);
803
+
804
+ const usecase = new TestUsecase(["list", "ticket"], [
805
+ {
806
+ resource: "ticket",
807
+ action: "list",
808
+ constraints: { scope: ResourceScope.TEAM, teamId: "engineering" },
809
+ },
810
+ ]);
811
+
812
+ await expect(usecase.execute(actor, undefined)).rejects.toThrow(
813
+ AuthorizationException,
814
+ );
815
+ });
816
+ });
817
+
818
+ describe("Recipe 3: only modify draft resources", () => {
819
+ test("actor with draft+pending statuses can update drafts", async () => {
820
+ const actor = new TestActor("u1", "b1", "t1", [
821
+ {
822
+ resource: "invoice",
823
+ action: "update",
824
+ constraints: { statuses: ["draft", "pending"] },
825
+ },
826
+ ]);
827
+
828
+ const usecase = new TestUsecase(["update", "invoice"], [
829
+ {
830
+ resource: "invoice",
831
+ action: "update",
832
+ constraints: { statuses: ["draft"] },
833
+ },
834
+ ]);
835
+
836
+ await expect(usecase.execute(actor, undefined)).resolves.toBeUndefined();
837
+ });
838
+
839
+ test("actor with only pending status cannot update drafts", async () => {
840
+ const actor = new TestActor("u1", "b1", "t1", [
841
+ {
842
+ resource: "invoice",
843
+ action: "update",
844
+ constraints: { statuses: ["pending"] },
845
+ },
846
+ ]);
847
+
848
+ const usecase = new TestUsecase(["update", "invoice"], [
849
+ {
850
+ resource: "invoice",
851
+ action: "update",
852
+ constraints: { statuses: ["draft"] },
853
+ },
854
+ ]);
855
+
856
+ await expect(usecase.execute(actor, undefined)).rejects.toThrow(
857
+ AuthorizationException,
858
+ );
859
+ });
860
+ });
861
+
862
+ describe("Recipe 4: public access via guest grants", () => {
863
+ test("guest with explicit grant passes usecase", async () => {
864
+ const guest = new TestActor("guest", "b1", "guest-token", [
865
+ { resource: "article", action: "retrieve" },
866
+ ]);
867
+
868
+ const usecase = new TestUsecase(["retrieve", "article"], [
869
+ { resource: "article", action: "retrieve" },
870
+ ]);
871
+
872
+ await expect(usecase.execute(guest, undefined)).resolves.toBeUndefined();
873
+ });
874
+
875
+ test("guest without grant for that resource is denied", async () => {
876
+ const guest = new TestActor("guest", "b1", "guest-token", [
877
+ { resource: "article", action: "retrieve" },
878
+ ]);
879
+
880
+ const usecase = new TestUsecase(["retrieve", "document"], [
881
+ { resource: "document", action: "retrieve" },
882
+ ]);
883
+
884
+ await expect(usecase.execute(guest, undefined)).rejects.toThrow(
885
+ AuthorizationException,
886
+ );
887
+ });
888
+
889
+ test("empty requiredPermissions skips interceptor — no grant needed at all", async () => {
890
+ const guest = new TestActor("guest", "b1", "guest-token", []);
891
+
892
+ const usecase = new TestUsecase(["check", "health"], []);
893
+
894
+ await expect(usecase.execute(guest, undefined)).resolves.toBeUndefined();
895
+ });
896
+ });
897
+
898
+ describe("ALL scope — god-mode, opt-in", () => {
899
+ test("ALL grant satisfies ALL requirement", async () => {
900
+ const actor = new TestActor("u1", "b1", "t1", [
901
+ {
902
+ resource: "system",
903
+ action: "configure",
904
+ constraints: { scope: ResourceScope.ALL },
905
+ },
906
+ ]);
907
+
908
+ const usecase = new TestUsecase(["configure", "system"], [
909
+ {
910
+ resource: "system",
911
+ action: "configure",
912
+ constraints: { scope: ResourceScope.ALL },
913
+ },
914
+ ]);
915
+
916
+ await expect(usecase.execute(actor, undefined)).resolves.toBeUndefined();
917
+ });
918
+
919
+ test("ALL requirement is the most restrictive — BUSINESS grant does not satisfy it", async () => {
920
+ const actor = new TestActor("u1", "b1", "t1", [
921
+ {
922
+ resource: "system",
923
+ action: "configure",
924
+ constraints: { scope: ResourceScope.BUSINESS },
925
+ },
926
+ ]);
927
+
928
+ const usecase = new TestUsecase(["configure", "system"], [
929
+ {
930
+ resource: "system",
931
+ action: "configure",
932
+ constraints: { scope: ResourceScope.ALL },
933
+ },
934
+ ]);
935
+
936
+ await expect(usecase.execute(actor, undefined)).rejects.toThrow(
937
+ AuthorizationException,
938
+ );
939
+ });
940
+
941
+ test("ALL grant satisfies every lower scope requirement (god-mode)", async () => {
942
+ const actor = new TestActor("u1", "b1", "t1", [
943
+ {
944
+ resource: "document",
945
+ action: "update",
946
+ constraints: { scope: ResourceScope.ALL },
947
+ },
948
+ ]);
949
+
950
+ const businessUsecase = new TestUsecase(["update", "document"], [
951
+ {
952
+ resource: "document",
953
+ action: "update",
954
+ constraints: { scope: ResourceScope.BUSINESS },
955
+ },
956
+ ]);
957
+
958
+ const teamUsecase = new TestUsecase(["update", "document"], [
959
+ {
960
+ resource: "document",
961
+ action: "update",
962
+ constraints: { scope: ResourceScope.TEAM },
963
+ },
964
+ ]);
965
+
966
+ const ownedUsecase = new TestUsecase(["update", "document"], [
967
+ {
968
+ resource: "document",
969
+ action: "update",
970
+ constraints: { scope: ResourceScope.OWNED },
971
+ },
972
+ ]);
973
+
974
+ await expect(businessUsecase.execute(actor, undefined)).resolves.toBeUndefined();
975
+ await expect(teamUsecase.execute(actor, undefined)).resolves.toBeUndefined();
976
+ await expect(ownedUsecase.execute(actor, undefined)).resolves.toBeUndefined();
977
+ });
978
+
979
+ test("ALL grant does not bypass non-scope constraints", async () => {
980
+ const actor = new TestActor("u1", "b1", "t1", [
981
+ {
982
+ resource: "document",
983
+ action: "update",
984
+ constraints: { scope: ResourceScope.ALL },
985
+ },
986
+ ]);
987
+
988
+ const usecase = new TestUsecase(["update", "document"], [
989
+ {
990
+ resource: "document",
991
+ action: "update",
992
+ constraints: { scope: ResourceScope.TEAM, teamId: "legal" },
993
+ },
994
+ ]);
995
+
996
+ await expect(usecase.execute(actor, undefined)).rejects.toThrow(
997
+ AuthorizationException,
998
+ );
999
+ });
1000
+ });
1001
+ });