@cerbos/orm-prisma 3.2.1 → 3.3.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/src/index.test.ts CHANGED
@@ -5513,3 +5513,810 @@ describe("Return Types", () => {
5513
5513
  expect(conditional.kind).toBe(PlanKind.CONDITIONAL);
5514
5514
  });
5515
5515
  });
5516
+
5517
+ // Issue #156: add operator (string concatenation)
5518
+ describe("Add Operator", () => {
5519
+ test("add with two values (constant folding)", () => {
5520
+ // #given eq(field, add("prefix:", "suffix"))
5521
+ const plan = createConditionalPlan({
5522
+ operator: "eq",
5523
+ operands: [
5524
+ { name: "request.resource.attr.aString" },
5525
+ {
5526
+ operator: "add",
5527
+ operands: [{ value: "prefix:" }, { value: "suffix" }],
5528
+ },
5529
+ ],
5530
+ });
5531
+
5532
+ // #when
5533
+ const result = queryPlanToPrisma({
5534
+ queryPlan: plan,
5535
+ mapper: {
5536
+ "request.resource.attr.aString": { field: "aString" },
5537
+ },
5538
+ });
5539
+
5540
+ // #then constant fold: "prefix:" + "suffix" = "prefix:suffix"
5541
+ expect(result).toEqual({
5542
+ kind: PlanKind.CONDITIONAL,
5543
+ filters: { aString: { equals: "prefix:suffix" } },
5544
+ });
5545
+ });
5546
+
5547
+ test("add with value prefix and field reference in eq", () => {
5548
+ // #given eq("projects:123", add("projects:", R.attr.id))
5549
+ // This is the exact plan Cerbos produces for:
5550
+ // P.attr.context == "projects:" + request.resource.attr.id
5551
+ const plan = createConditionalPlan({
5552
+ operator: "eq",
5553
+ operands: [
5554
+ { value: "projects:123" },
5555
+ {
5556
+ operator: "add",
5557
+ operands: [
5558
+ { value: "projects:" },
5559
+ { name: "request.resource.attr.id" },
5560
+ ],
5561
+ },
5562
+ ],
5563
+ });
5564
+
5565
+ // #when
5566
+ const result = queryPlanToPrisma({
5567
+ queryPlan: plan,
5568
+ mapper: {
5569
+ "request.resource.attr.id": { field: "id" },
5570
+ },
5571
+ });
5572
+
5573
+ // #then solve: "projects:123" == "projects:" + id → id == "123"
5574
+ expect(result).toEqual({
5575
+ kind: PlanKind.CONDITIONAL,
5576
+ filters: { id: { equals: "123" } },
5577
+ });
5578
+ });
5579
+
5580
+ test("add with field reference and value suffix in eq", () => {
5581
+ // #given eq("hello_world", add(R.attr.aString, "_world"))
5582
+ const plan = createConditionalPlan({
5583
+ operator: "eq",
5584
+ operands: [
5585
+ { value: "hello_world" },
5586
+ {
5587
+ operator: "add",
5588
+ operands: [
5589
+ { name: "request.resource.attr.aString" },
5590
+ { value: "_world" },
5591
+ ],
5592
+ },
5593
+ ],
5594
+ });
5595
+
5596
+ // #when
5597
+ const result = queryPlanToPrisma({
5598
+ queryPlan: plan,
5599
+ mapper: {
5600
+ "request.resource.attr.aString": { field: "aString" },
5601
+ },
5602
+ });
5603
+
5604
+ // #then solve: "hello_world" == aString + "_world" → aString == "hello"
5605
+ expect(result).toEqual({
5606
+ kind: PlanKind.CONDITIONAL,
5607
+ filters: { aString: { equals: "hello" } },
5608
+ });
5609
+ });
5610
+
5611
+ test("add with value prefix and field reference in ne", () => {
5612
+ // #given ne("projects:123", add("projects:", R.attr.id))
5613
+ const plan = createConditionalPlan({
5614
+ operator: "ne",
5615
+ operands: [
5616
+ { value: "projects:123" },
5617
+ {
5618
+ operator: "add",
5619
+ operands: [
5620
+ { value: "projects:" },
5621
+ { name: "request.resource.attr.id" },
5622
+ ],
5623
+ },
5624
+ ],
5625
+ });
5626
+
5627
+ // #when
5628
+ const result = queryPlanToPrisma({
5629
+ queryPlan: plan,
5630
+ mapper: {
5631
+ "request.resource.attr.id": { field: "id" },
5632
+ },
5633
+ });
5634
+
5635
+ // #then solve: "projects:123" != "projects:" + id → id != "123"
5636
+ expect(result).toEqual({
5637
+ kind: PlanKind.CONDITIONAL,
5638
+ filters: { id: { not: "123" } },
5639
+ });
5640
+ });
5641
+
5642
+ test("add with prefix mismatch yields impossible filter", () => {
5643
+ // #given eq("other:123", add("projects:", R.attr.id))
5644
+ // "other:123" doesn't start with "projects:", so no value of id can match
5645
+ const plan = createConditionalPlan({
5646
+ operator: "eq",
5647
+ operands: [
5648
+ { value: "other:123" },
5649
+ {
5650
+ operator: "add",
5651
+ operands: [
5652
+ { value: "projects:" },
5653
+ { name: "request.resource.attr.id" },
5654
+ ],
5655
+ },
5656
+ ],
5657
+ });
5658
+
5659
+ // #when
5660
+ const result = queryPlanToPrisma({
5661
+ queryPlan: plan,
5662
+ mapper: {
5663
+ "request.resource.attr.id": { field: "id" },
5664
+ },
5665
+ });
5666
+
5667
+ // #then impossible: produces a filter that matches nothing
5668
+ // Use an impossible equality that Prisma won't match
5669
+ expect(result).toEqual({
5670
+ kind: PlanKind.CONDITIONAL,
5671
+ filters: {
5672
+ id: { in: [] },
5673
+ },
5674
+ });
5675
+ });
5676
+
5677
+ test("add with relation mapping", () => {
5678
+ // #given eq("projects:123", add("projects:", R.attr.nested.id))
5679
+ const plan = createConditionalPlan({
5680
+ operator: "eq",
5681
+ operands: [
5682
+ { value: "projects:123" },
5683
+ {
5684
+ operator: "add",
5685
+ operands: [
5686
+ { value: "projects:" },
5687
+ { name: "request.resource.attr.nested.id" },
5688
+ ],
5689
+ },
5690
+ ],
5691
+ });
5692
+
5693
+ // #when
5694
+ const result = queryPlanToPrisma({
5695
+ queryPlan: plan,
5696
+ mapper: {
5697
+ "request.resource.attr.nested": {
5698
+ relation: {
5699
+ name: "nested",
5700
+ type: "one",
5701
+ field: "id",
5702
+ },
5703
+ },
5704
+ },
5705
+ });
5706
+
5707
+ // #then
5708
+ expect(result).toEqual({
5709
+ kind: PlanKind.CONDITIONAL,
5710
+ filters: { nested: { is: { id: { equals: "123" } } } },
5711
+ });
5712
+ });
5713
+
5714
+ test("add via cerbos integration", async () => {
5715
+ const queryPlan = await cerbos.planResources({
5716
+ principal: {
5717
+ id: "user1",
5718
+ roles: ["USER"],
5719
+ attr: { context: "projects:123" },
5720
+ },
5721
+ resource: { kind: "resource" },
5722
+ action: "string-concat-principal",
5723
+ });
5724
+
5725
+ expect(queryPlan.kind).toBe(PlanKind.CONDITIONAL);
5726
+
5727
+ const result = queryPlanToPrisma({
5728
+ queryPlan,
5729
+ mapper: {
5730
+ "request.resource.attr.id": { field: "id" },
5731
+ },
5732
+ });
5733
+
5734
+ expect(result).toEqual({
5735
+ kind: PlanKind.CONDITIONAL,
5736
+ filters: { id: { equals: "123" } },
5737
+ });
5738
+ });
5739
+
5740
+ test("add with numeric values", () => {
5741
+ // #given add with numbers: eq(10, add(3, R.attr.aNumber))
5742
+ const plan = createConditionalPlan({
5743
+ operator: "eq",
5744
+ operands: [
5745
+ { value: 10 },
5746
+ {
5747
+ operator: "add",
5748
+ operands: [
5749
+ { value: 3 },
5750
+ { name: "request.resource.attr.aNumber" },
5751
+ ],
5752
+ },
5753
+ ],
5754
+ });
5755
+
5756
+ // #when
5757
+ const result = queryPlanToPrisma({
5758
+ queryPlan: plan,
5759
+ mapper: {
5760
+ "request.resource.attr.aNumber": { field: "aNumber" },
5761
+ },
5762
+ });
5763
+
5764
+ // #then solve: 10 == 3 + aNumber → aNumber == 7
5765
+ expect(result).toEqual({
5766
+ kind: PlanKind.CONDITIONAL,
5767
+ filters: { aNumber: { equals: 7 } },
5768
+ });
5769
+ });
5770
+ });
5771
+
5772
+ // Issue #155: hierarchy overlaps operator
5773
+ // overlaps = one hierarchy is a prefix of the other (segment-wise comparison)
5774
+ describe("Hierarchy Overlaps", () => {
5775
+ test("overlaps - equal length hierarchies, field must match", () => {
5776
+ // #given hierarchy("projects:123", ":").overlaps(hierarchy(["projects", R.id]))
5777
+ // Left segments: ["projects", "123"], Right segments: ["projects", R.id]
5778
+ // Same length: prefix check both directions requires all segments match
5779
+ // segments[0]: "projects" == "projects" ✓
5780
+ // segments[1]: "123" == R.id → R.id == "123"
5781
+ const plan = createConditionalPlan({
5782
+ operator: "overlaps",
5783
+ operands: [
5784
+ {
5785
+ operator: "hierarchy",
5786
+ operands: [{ value: "projects:123" }, { value: ":" }],
5787
+ },
5788
+ {
5789
+ operator: "hierarchy",
5790
+ operands: [
5791
+ {
5792
+ operator: "list",
5793
+ operands: [
5794
+ { value: "projects" },
5795
+ { name: "request.resource.id" },
5796
+ ],
5797
+ },
5798
+ ],
5799
+ },
5800
+ ],
5801
+ });
5802
+
5803
+ // #when
5804
+ const result = queryPlanToPrisma({
5805
+ queryPlan: plan,
5806
+ mapper: {
5807
+ "request.resource.id": { field: "id" },
5808
+ },
5809
+ });
5810
+
5811
+ // #then
5812
+ expect(result).toEqual({
5813
+ kind: PlanKind.CONDITIONAL,
5814
+ filters: { id: { equals: "123" } },
5815
+ });
5816
+ });
5817
+
5818
+ test("overlaps - left shorter than right, left is always prefix", () => {
5819
+ // #given hierarchy("admin", ":").overlaps(hierarchy(["admin", R.attr.scope]))
5820
+ // Left segments: ["admin"] (length 1), Right segments: ["admin", R.attr.scope] (length 2)
5821
+ // Left prefix of right: "admin" == "admin" ✓ → always true
5822
+ const plan = createConditionalPlan({
5823
+ operator: "overlaps",
5824
+ operands: [
5825
+ {
5826
+ operator: "hierarchy",
5827
+ operands: [{ value: "admin" }, { value: ":" }],
5828
+ },
5829
+ {
5830
+ operator: "hierarchy",
5831
+ operands: [
5832
+ {
5833
+ operator: "list",
5834
+ operands: [
5835
+ { value: "admin" },
5836
+ { name: "request.resource.attr.scope" },
5837
+ ],
5838
+ },
5839
+ ],
5840
+ },
5841
+ ],
5842
+ });
5843
+
5844
+ // #when
5845
+ const result = queryPlanToPrisma({
5846
+ queryPlan: plan,
5847
+ mapper: {
5848
+ "request.resource.attr.scope": { field: "scope" },
5849
+ },
5850
+ });
5851
+
5852
+ // #then always true
5853
+ expect(result).toEqual({
5854
+ kind: PlanKind.CONDITIONAL,
5855
+ filters: {},
5856
+ });
5857
+ });
5858
+
5859
+ test("overlaps - left longer, right is prefix when field matches", () => {
5860
+ // #given hierarchy("dept:engineering:frontend", ":").overlaps(hierarchy(["dept", R.attr.department]))
5861
+ // Left segments: ["dept", "engineering", "frontend"] (3)
5862
+ // Right segments: ["dept", R.attr.department] (2)
5863
+ // Left prefix of right: len 3 > len 2 → impossible
5864
+ // Right prefix of left: "dept" == "dept" ✓, R.attr.department == "engineering"
5865
+ const plan = createConditionalPlan({
5866
+ operator: "overlaps",
5867
+ operands: [
5868
+ {
5869
+ operator: "hierarchy",
5870
+ operands: [
5871
+ { value: "dept:engineering:frontend" },
5872
+ { value: ":" },
5873
+ ],
5874
+ },
5875
+ {
5876
+ operator: "hierarchy",
5877
+ operands: [
5878
+ {
5879
+ operator: "list",
5880
+ operands: [
5881
+ { value: "dept" },
5882
+ { name: "request.resource.attr.department" },
5883
+ ],
5884
+ },
5885
+ ],
5886
+ },
5887
+ ],
5888
+ });
5889
+
5890
+ // #when
5891
+ const result = queryPlanToPrisma({
5892
+ queryPlan: plan,
5893
+ mapper: {
5894
+ "request.resource.attr.department": { field: "department" },
5895
+ },
5896
+ });
5897
+
5898
+ // #then
5899
+ expect(result).toEqual({
5900
+ kind: PlanKind.CONDITIONAL,
5901
+ filters: { department: { equals: "engineering" } },
5902
+ });
5903
+ });
5904
+
5905
+ test("overlaps - different roots, impossible", () => {
5906
+ // #given hierarchy("org:acme:team1", ":").overlaps(hierarchy(["company", R.attr.team]))
5907
+ // Left: ["org", "acme", "team1"], Right: ["company", R.attr.team]
5908
+ // Both directions fail at segment[0]: "org" ≠ "company"
5909
+ const plan = createConditionalPlan({
5910
+ operator: "overlaps",
5911
+ operands: [
5912
+ {
5913
+ operator: "hierarchy",
5914
+ operands: [{ value: "org:acme:team1" }, { value: ":" }],
5915
+ },
5916
+ {
5917
+ operator: "hierarchy",
5918
+ operands: [
5919
+ {
5920
+ operator: "list",
5921
+ operands: [
5922
+ { value: "company" },
5923
+ { name: "request.resource.attr.team" },
5924
+ ],
5925
+ },
5926
+ ],
5927
+ },
5928
+ ],
5929
+ });
5930
+
5931
+ // #when
5932
+ const result = queryPlanToPrisma({
5933
+ queryPlan: plan,
5934
+ mapper: {
5935
+ "request.resource.attr.team": { field: "team" },
5936
+ },
5937
+ });
5938
+
5939
+ // #then impossible
5940
+ expect(result).toEqual({
5941
+ kind: PlanKind.CONDITIONAL,
5942
+ filters: {
5943
+ team: { in: [] },
5944
+ },
5945
+ });
5946
+ });
5947
+
5948
+ test("overlaps - equal length with field in middle, field must match", () => {
5949
+ // #given hierarchy("region:us:west", ":").overlaps(hierarchy(["region", "us", R.attr.zone]))
5950
+ // Left: ["region", "us", "west"], Right: ["region", "us", R.attr.zone]
5951
+ // Same length, prefix check: "region"=="region" ✓, "us"=="us" ✓, "west"==R.attr.zone
5952
+ const plan = createConditionalPlan({
5953
+ operator: "overlaps",
5954
+ operands: [
5955
+ {
5956
+ operator: "hierarchy",
5957
+ operands: [{ value: "region:us:west" }, { value: ":" }],
5958
+ },
5959
+ {
5960
+ operator: "hierarchy",
5961
+ operands: [
5962
+ {
5963
+ operator: "list",
5964
+ operands: [
5965
+ { value: "region" },
5966
+ { value: "us" },
5967
+ { name: "request.resource.attr.zone" },
5968
+ ],
5969
+ },
5970
+ ],
5971
+ },
5972
+ ],
5973
+ });
5974
+
5975
+ // #when
5976
+ const result = queryPlanToPrisma({
5977
+ queryPlan: plan,
5978
+ mapper: {
5979
+ "request.resource.attr.zone": { field: "zone" },
5980
+ },
5981
+ });
5982
+
5983
+ // #then
5984
+ expect(result).toEqual({
5985
+ kind: PlanKind.CONDITIONAL,
5986
+ filters: { zone: { equals: "west" } },
5987
+ });
5988
+ });
5989
+
5990
+ test("overlaps - single-field right hierarchy as prefix of left", () => {
5991
+ // #given hierarchy("team:alpha", ":").overlaps(hierarchy([R.attr.group]))
5992
+ // Left: ["team", "alpha"] (2), Right: [R.attr.group] (1)
5993
+ // Left prefix of right: len 2 > len 1 → impossible
5994
+ // Right prefix of left: R.attr.group == "team" (first segment)
5995
+ const plan = createConditionalPlan({
5996
+ operator: "overlaps",
5997
+ operands: [
5998
+ {
5999
+ operator: "hierarchy",
6000
+ operands: [{ value: "team:alpha" }, { value: ":" }],
6001
+ },
6002
+ {
6003
+ operator: "hierarchy",
6004
+ operands: [
6005
+ {
6006
+ operator: "list",
6007
+ operands: [{ name: "request.resource.attr.group" }],
6008
+ },
6009
+ ],
6010
+ },
6011
+ ],
6012
+ });
6013
+
6014
+ // #when
6015
+ const result = queryPlanToPrisma({
6016
+ queryPlan: plan,
6017
+ mapper: {
6018
+ "request.resource.attr.group": { field: "group" },
6019
+ },
6020
+ });
6021
+
6022
+ // #then
6023
+ expect(result).toEqual({
6024
+ kind: PlanKind.CONDITIONAL,
6025
+ filters: { group: { equals: "team" } },
6026
+ });
6027
+ });
6028
+
6029
+ test("overlaps - hierarchy(dotted string) without delimiter", () => {
6030
+ // #given hierarchy("a.b.c").overlaps(hierarchy(["a", "b", R.attr.zone]))
6031
+ // hierarchy("a.b.c") uses default "." delimiter → ["a", "b", "c"]
6032
+ const plan = createConditionalPlan({
6033
+ operator: "overlaps",
6034
+ operands: [
6035
+ {
6036
+ operator: "hierarchy",
6037
+ operands: [{ value: "a.b.c" }],
6038
+ },
6039
+ {
6040
+ operator: "hierarchy",
6041
+ operands: [
6042
+ {
6043
+ operator: "list",
6044
+ operands: [
6045
+ { value: "a" },
6046
+ { value: "b" },
6047
+ { name: "request.resource.attr.zone" },
6048
+ ],
6049
+ },
6050
+ ],
6051
+ },
6052
+ ],
6053
+ });
6054
+
6055
+ // #when
6056
+ const result = queryPlanToPrisma({
6057
+ queryPlan: plan,
6058
+ mapper: { "request.resource.attr.zone": { field: "zone" } },
6059
+ });
6060
+
6061
+ // #then
6062
+ expect(result).toEqual({
6063
+ kind: PlanKind.CONDITIONAL,
6064
+ filters: { zone: { equals: "c" } },
6065
+ });
6066
+ });
6067
+
6068
+ test("overlaps via cerbos integration", async () => {
6069
+ const queryPlan = await cerbos.planResources({
6070
+ principal: {
6071
+ id: "user1",
6072
+ roles: ["USER"],
6073
+ attr: { context: "projects:123" },
6074
+ },
6075
+ resource: { kind: "resource" },
6076
+ action: "hierarchy-overlaps",
6077
+ });
6078
+
6079
+ expect(queryPlan.kind).toBe(PlanKind.CONDITIONAL);
6080
+
6081
+ const result = queryPlanToPrisma({
6082
+ queryPlan,
6083
+ mapper: {
6084
+ "request.resource.id": { field: "id" },
6085
+ },
6086
+ });
6087
+
6088
+ // hierarchy("projects:123", ":") segments = ["projects", "123"]
6089
+ // hierarchy(["projects", R.id]) segments = ["projects", R.id]
6090
+ // Same length, segments[1]: "123" == R.id → R.id == "123"
6091
+ expect(result).toEqual({
6092
+ kind: PlanKind.CONDITIONAL,
6093
+ filters: { id: { equals: "123" } },
6094
+ });
6095
+ });
6096
+ });
6097
+
6098
+ const hierarchyMapper = {
6099
+ "request.resource.attr.scope": { field: "scope" },
6100
+ "request.resource.attr.path": { field: "path" },
6101
+ };
6102
+
6103
+ // ancestorOf = first hierarchy is a strict prefix of second
6104
+ // hierarchy("a.b").ancestorOf(hierarchy("a.b.c.d")) == true
6105
+ describe("Hierarchy ancestorOf", () => {
6106
+ test("ancestorOf - constant is ancestor, field is descendant", () => {
6107
+ // #given hierarchy("a.b.c").ancestorOf(hierarchy(R.attr.scope))
6108
+ const plan = createConditionalPlan({
6109
+ operator: "ancestorOf",
6110
+ operands: [
6111
+ {
6112
+ operator: "hierarchy",
6113
+ operands: [{ value: "a.b.c" }],
6114
+ },
6115
+ {
6116
+ operator: "hierarchy",
6117
+ operands: [{ name: "request.resource.attr.scope" }],
6118
+ },
6119
+ ],
6120
+ });
6121
+
6122
+ // #when
6123
+ const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
6124
+
6125
+ // #then - R.attr.scope must start with "a.b.c." (ancestor means strict prefix)
6126
+ expect(result).toEqual({
6127
+ kind: PlanKind.CONDITIONAL,
6128
+ filters: { scope: { startsWith: "a.b.c." } },
6129
+ });
6130
+ });
6131
+
6132
+ test("ancestorOf - field is ancestor of constant", () => {
6133
+ // #given hierarchy(R.attr.scope).ancestorOf(hierarchy("a.b.c.d"))
6134
+ // R.attr.scope must be a prefix of "a.b.c.d"
6135
+ // Possible prefixes: "a", "a.b", "a.b.c"
6136
+ const plan = createConditionalPlan({
6137
+ operator: "ancestorOf",
6138
+ operands: [
6139
+ {
6140
+ operator: "hierarchy",
6141
+ operands: [{ name: "request.resource.attr.scope" }],
6142
+ },
6143
+ {
6144
+ operator: "hierarchy",
6145
+ operands: [{ value: "a.b.c.d" }],
6146
+ },
6147
+ ],
6148
+ });
6149
+
6150
+ // #when
6151
+ const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
6152
+
6153
+ // #then - scope must be one of the strict prefixes of "a.b.c.d"
6154
+ expect(result).toEqual({
6155
+ kind: PlanKind.CONDITIONAL,
6156
+ filters: { scope: { in: ["a", "a.b", "a.b.c"] } },
6157
+ });
6158
+ });
6159
+
6160
+ test("ancestorOf - single segment constant ancestor", () => {
6161
+ // #given hierarchy("root").ancestorOf(hierarchy(R.attr.scope))
6162
+ const plan = createConditionalPlan({
6163
+ operator: "ancestorOf",
6164
+ operands: [
6165
+ {
6166
+ operator: "hierarchy",
6167
+ operands: [{ value: "root" }],
6168
+ },
6169
+ {
6170
+ operator: "hierarchy",
6171
+ operands: [{ name: "request.resource.attr.scope" }],
6172
+ },
6173
+ ],
6174
+ });
6175
+
6176
+ // #when
6177
+ const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
6178
+
6179
+ // #then
6180
+ expect(result).toEqual({
6181
+ kind: PlanKind.CONDITIONAL,
6182
+ filters: { scope: { startsWith: "root." } },
6183
+ });
6184
+ });
6185
+
6186
+ test("ancestorOf - with custom delimiter", () => {
6187
+ // #given hierarchy(P.attr.scope, ":").ancestorOf(hierarchy(R.attr.path, ":"))
6188
+ // P.attr.scope is resolved at plan time → constant "org:team"
6189
+ const plan = createConditionalPlan({
6190
+ operator: "ancestorOf",
6191
+ operands: [
6192
+ {
6193
+ operator: "hierarchy",
6194
+ operands: [{ value: "org:team" }, { value: ":" }],
6195
+ },
6196
+ {
6197
+ operator: "hierarchy",
6198
+ operands: [{ name: "request.resource.attr.path" }, { value: ":" }],
6199
+ },
6200
+ ],
6201
+ });
6202
+
6203
+ // #when
6204
+ const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
6205
+
6206
+ // #then - R.attr.path must start with "org:team:" (using the ":" delimiter)
6207
+ expect(result).toEqual({
6208
+ kind: PlanKind.CONDITIONAL,
6209
+ filters: { path: { startsWith: "org:team:" } },
6210
+ });
6211
+ });
6212
+
6213
+ test("ancestorOf via cerbos integration", async () => {
6214
+ const plan = await cerbos.planResources({
6215
+ principal: {
6216
+ id: "user1",
6217
+ roles: ["USER"],
6218
+ attr: { scope: "a.b.c" },
6219
+ },
6220
+ resource: { kind: "resource" },
6221
+ action: "hierarchy-ancestor-of",
6222
+ });
6223
+
6224
+ const result = queryPlanToPrisma({
6225
+ queryPlan: plan,
6226
+ mapper: {
6227
+ "request.resource.attr.scope": { field: "scope" },
6228
+ },
6229
+ });
6230
+
6231
+ // hierarchy("a.b.c").ancestorOf(hierarchy(R.attr.scope))
6232
+ // "a.b.c" is ancestor of R.attr.scope → R.attr.scope starts with "a.b.c."
6233
+ expect(result).toEqual({
6234
+ kind: PlanKind.CONDITIONAL,
6235
+ filters: { scope: { startsWith: "a.b.c." } },
6236
+ });
6237
+ });
6238
+ });
6239
+
6240
+ // descendentOf = mirror of ancestorOf
6241
+ // hierarchy("a.b.c.d").descendentOf(hierarchy("a.b")) == true
6242
+ describe("Hierarchy descendentOf", () => {
6243
+ test("descendentOf - field is descendant of constant", () => {
6244
+ // #given hierarchy(R.attr.scope).descendentOf(hierarchy("a.b"))
6245
+ // R.attr.scope must be a descendant of "a.b" → "a.b" is a prefix of R.attr.scope
6246
+ const plan = createConditionalPlan({
6247
+ operator: "descendentOf",
6248
+ operands: [
6249
+ {
6250
+ operator: "hierarchy",
6251
+ operands: [{ name: "request.resource.attr.scope" }],
6252
+ },
6253
+ {
6254
+ operator: "hierarchy",
6255
+ operands: [{ value: "a.b" }],
6256
+ },
6257
+ ],
6258
+ });
6259
+
6260
+ // #when
6261
+ const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
6262
+
6263
+ // #then - scope must start with "a.b." (descendant = strict, must be longer)
6264
+ expect(result).toEqual({
6265
+ kind: PlanKind.CONDITIONAL,
6266
+ filters: { scope: { startsWith: "a.b." } },
6267
+ });
6268
+ });
6269
+
6270
+ test("descendentOf - constant is descendant of field", () => {
6271
+ // #given hierarchy("a.b.c.d").descendentOf(hierarchy(R.attr.scope))
6272
+ // "a.b.c.d" descends from R.attr.scope → R.attr.scope is a prefix of "a.b.c.d"
6273
+ const plan = createConditionalPlan({
6274
+ operator: "descendentOf",
6275
+ operands: [
6276
+ {
6277
+ operator: "hierarchy",
6278
+ operands: [{ value: "a.b.c.d" }],
6279
+ },
6280
+ {
6281
+ operator: "hierarchy",
6282
+ operands: [{ name: "request.resource.attr.scope" }],
6283
+ },
6284
+ ],
6285
+ });
6286
+
6287
+ // #when
6288
+ const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
6289
+
6290
+ // #then - scope must be one of the strict prefixes of "a.b.c.d"
6291
+ expect(result).toEqual({
6292
+ kind: PlanKind.CONDITIONAL,
6293
+ filters: { scope: { in: ["a", "a.b", "a.b.c"] } },
6294
+ });
6295
+ });
6296
+
6297
+ test("descendentOf via cerbos integration", async () => {
6298
+ const plan = await cerbos.planResources({
6299
+ principal: {
6300
+ id: "user1",
6301
+ roles: ["USER"],
6302
+ attr: { scope: "a.b.c" },
6303
+ },
6304
+ resource: { kind: "resource" },
6305
+ action: "hierarchy-descendent-of",
6306
+ });
6307
+
6308
+ const result = queryPlanToPrisma({
6309
+ queryPlan: plan,
6310
+ mapper: {
6311
+ "request.resource.attr.scope": { field: "scope" },
6312
+ },
6313
+ });
6314
+
6315
+ // hierarchy(R.attr.scope).descendentOf(hierarchy("a.b.c"))
6316
+ // R.attr.scope descends from "a.b.c" → R.attr.scope starts with "a.b.c."
6317
+ expect(result).toEqual({
6318
+ kind: PlanKind.CONDITIONAL,
6319
+ filters: { scope: { startsWith: "a.b.c." } },
6320
+ });
6321
+ });
6322
+ });