|
| 1 | +import { buildScope } from "@trigger.dev/plugins"; |
1 | 2 | import { describe, it, expect } from "vitest"; |
2 | 3 | import { |
3 | 4 | permissiveAbility, |
@@ -60,17 +61,42 @@ describe("buildJwtAbility", () => { |
60 | 61 | expect(ability.can("read", { type: "runs" })).toBe(false); |
61 | 62 | }); |
62 | 63 |
|
| 64 | + it.each(["read:runs:", "read:runs: "])( |
| 65 | + "rejects an explicitly empty resource ID in %s", |
| 66 | + (scope) => { |
| 67 | + const ability = buildJwtAbility([scope]); |
| 68 | + expect(ability.can("read", { type: "runs", id: "run_abc" })).toBe(false); |
| 69 | + expect(ability.can("read", { type: "runs" })).toBe(false); |
| 70 | + } |
| 71 | + ); |
| 72 | + |
63 | 73 | it("preserves colons in the resource id (everything after the 2nd colon)", () => { |
64 | | - // Resource ids can contain colons (e.g. user-provided tags like |
65 | | - // `env:staging`). The naive `[a, b, c] = scope.split(":")` form |
66 | | - // truncated `read:tags:env:staging` → scopeId="env" and silently |
67 | | - // mis-matched. Regression coverage for the multi-colon id path. |
68 | 74 | const ability = buildJwtAbility(["read:tags:env:staging"]); |
69 | 75 | expect(ability.can("read", { type: "tags", id: "env:staging" })).toBe(true); |
70 | 76 | expect(ability.can("read", { type: "tags", id: "env" })).toBe(false); |
71 | 77 | expect(ability.can("read", { type: "tags", id: "env:prod" })).toBe(false); |
72 | 78 | }); |
73 | 79 |
|
| 80 | + it("preserves meaningful whitespace around resource ids", () => { |
| 81 | + const ability = buildJwtAbility(["read:tags: env:staging "]); |
| 82 | + expect(ability.can("read", { type: "tags", id: " env:staging " })).toBe(true); |
| 83 | + expect(ability.can("read", { type: "tags", id: "env:staging" })).toBe(false); |
| 84 | + }); |
| 85 | + |
| 86 | + it("preserves wildcard actions and ignores resource condition fields", () => { |
| 87 | + const ability = buildJwtAbility(["*:runs:run_abc"]); |
| 88 | + const resource = { |
| 89 | + type: "runs", |
| 90 | + id: "run_abc", |
| 91 | + projectId: "proj_123", |
| 92 | + environmentId: "env_123", |
| 93 | + }; |
| 94 | + |
| 95 | + expect(ability.can("read", resource)).toBe(true); |
| 96 | + expect(ability.can("write", resource)).toBe(true); |
| 97 | + expect(ability.can("read", { ...resource, id: "run_other" })).toBe(false); |
| 98 | + }); |
| 99 | + |
74 | 100 | it("allows any read with read:all scope", () => { |
75 | 101 | const ability = buildJwtAbility(["read:all"]); |
76 | 102 | expect(ability.can("read", { type: "runs" })).toBe(true); |
@@ -122,6 +148,32 @@ describe("buildJwtAbility", () => { |
122 | 148 | const ability = buildJwtAbility(["read:runs"]); |
123 | 149 | expect(ability.can("write", { type: "runs" })).toBe(false); |
124 | 150 | }); |
| 151 | + |
| 152 | + it("does not revisit the supplied scope set during repeated checks", () => { |
| 153 | + const rawScopes = Array.from({ length: 300 }, (_, index) => `read:runs:allowed_${index}`); |
| 154 | + let scopeReads = 0; |
| 155 | + const scopes = new Proxy(rawScopes, { |
| 156 | + get(target, property, receiver) { |
| 157 | + if (typeof property === "string" && /^\d+$/.test(property)) { |
| 158 | + scopeReads++; |
| 159 | + } |
| 160 | + return Reflect.get(target, property, receiver); |
| 161 | + }, |
| 162 | + }); |
| 163 | + const ability = buildJwtAbility(scopes); |
| 164 | + const readsAfterConstruction = scopeReads; |
| 165 | + const childAliases = Array.from({ length: 13 }, (_, index) => ({ |
| 166 | + type: "runs", |
| 167 | + id: `denied_${index}`, |
| 168 | + })); |
| 169 | + |
| 170 | + for (let child = 0; child < 50; child++) { |
| 171 | + expect(ability.can("read", childAliases)).toBe(false); |
| 172 | + } |
| 173 | + |
| 174 | + expect(readsAfterConstruction).toBeGreaterThan(0); |
| 175 | + expect(scopeReads).toBe(readsAfterConstruction); |
| 176 | + }); |
125 | 177 | }); |
126 | 178 |
|
127 | 179 | describe("scopesWithinAbility", () => { |
@@ -189,13 +241,27 @@ describe("scopesWithinAbility", () => { |
189 | 241 | }); |
190 | 242 |
|
191 | 243 | it("rejects malformed scopes for restricted abilities", () => { |
192 | | - expect(scopesWithinAbility(["read"], buildJwtAbility(["read:all"]))).toEqual({ |
| 244 | + expect( |
| 245 | + scopesWithinAbility(["read", "read:runs:", "read:tags: "], buildJwtAbility(["read:all"])) |
| 246 | + ).toEqual({ |
193 | 247 | ok: false, |
194 | | - deniedScopes: ["read"], |
| 248 | + deniedScopes: ["read", "read:runs:", "read:tags: "], |
195 | 249 | }); |
196 | 250 | }); |
197 | 251 | }); |
198 | 252 |
|
| 253 | +describe("buildScope", () => { |
| 254 | + it("distinguishes an absent ID from an explicitly empty ID", () => { |
| 255 | + expect(buildScope("read", "runs")).toBe("read:runs"); |
| 256 | + expect(() => buildScope("read", "runs", "")).toThrow("Scope resource IDs must not be empty"); |
| 257 | + expect(() => buildScope("read", "runs", " ")).toThrow("Scope resource IDs must not be empty"); |
| 258 | + }); |
| 259 | + |
| 260 | + it("preserves nonempty IDs exactly", () => { |
| 261 | + expect(buildScope("read", "tags", " env:staging ")).toBe("read:tags: env:staging "); |
| 262 | + }); |
| 263 | +}); |
| 264 | + |
199 | 265 | describe("buildJwtAbility — array resources", () => { |
200 | 266 | it("authorizes when any resource in the array passes a scope check", () => { |
201 | 267 | const ability = buildJwtAbility(["read:batch:batch_abc"]); |
|
0 commit comments