@ontrails/permits 1.0.0-beta.15 → 1.0.0-beta.16

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.
Files changed (70) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +30 -22
  3. package/package.json +12 -3
  4. package/src/adapters/adapter.ts +41 -0
  5. package/src/{connectors → adapters}/jwt.ts +148 -21
  6. package/src/auth-resource.ts +81 -18
  7. package/src/boundary.ts +200 -0
  8. package/src/errors.ts +1 -18
  9. package/src/extraction.ts +22 -16
  10. package/src/index.ts +22 -10
  11. package/src/trails/auth-verify.ts +11 -13
  12. package/.turbo/turbo-build.log +0 -1
  13. package/.turbo/turbo-lint.log +0 -3
  14. package/.turbo/turbo-typecheck.log +0 -1
  15. package/dist/auth-layer.d.ts +0 -18
  16. package/dist/auth-layer.d.ts.map +0 -1
  17. package/dist/auth-layer.js +0 -56
  18. package/dist/auth-layer.js.map +0 -1
  19. package/dist/auth-resource.d.ts +0 -11
  20. package/dist/auth-resource.d.ts.map +0 -1
  21. package/dist/auth-resource.js +0 -22
  22. package/dist/auth-resource.js.map +0 -1
  23. package/dist/connectors/connector.d.ts +0 -26
  24. package/dist/connectors/connector.d.ts.map +0 -1
  25. package/dist/connectors/connector.js +0 -2
  26. package/dist/connectors/connector.js.map +0 -1
  27. package/dist/connectors/jwt.d.ts +0 -25
  28. package/dist/connectors/jwt.d.ts.map +0 -1
  29. package/dist/connectors/jwt.js +0 -148
  30. package/dist/connectors/jwt.js.map +0 -1
  31. package/dist/errors.d.ts +0 -15
  32. package/dist/errors.d.ts.map +0 -1
  33. package/dist/errors.js +0 -15
  34. package/dist/errors.js.map +0 -1
  35. package/dist/extraction.d.ts +0 -20
  36. package/dist/extraction.d.ts.map +0 -1
  37. package/dist/extraction.js +0 -2
  38. package/dist/extraction.js.map +0 -1
  39. package/dist/index.d.ts +0 -11
  40. package/dist/index.d.ts.map +0 -1
  41. package/dist/index.js +0 -11
  42. package/dist/index.js.map +0 -1
  43. package/dist/permit.d.ts +0 -26
  44. package/dist/permit.d.ts.map +0 -1
  45. package/dist/permit.js +0 -17
  46. package/dist/permit.js.map +0 -1
  47. package/dist/rules.d.ts +0 -47
  48. package/dist/rules.d.ts.map +0 -1
  49. package/dist/rules.js +0 -127
  50. package/dist/rules.js.map +0 -1
  51. package/dist/testing.d.ts +0 -20
  52. package/dist/testing.d.ts.map +0 -1
  53. package/dist/testing.js +0 -22
  54. package/dist/testing.js.map +0 -1
  55. package/dist/trails/auth-verify.d.ts +0 -22
  56. package/dist/trails/auth-verify.d.ts.map +0 -1
  57. package/dist/trails/auth-verify.js +0 -85
  58. package/dist/trails/auth-verify.js.map +0 -1
  59. package/src/__tests__/auth-layer.test.ts +0 -130
  60. package/src/__tests__/auth-resource.test.ts +0 -62
  61. package/src/__tests__/auth-verify.test.ts +0 -278
  62. package/src/__tests__/connector.test.ts +0 -338
  63. package/src/__tests__/permit.test.ts +0 -122
  64. package/src/__tests__/rules.test.ts +0 -239
  65. package/src/__tests__/testing.test.ts +0 -57
  66. package/src/auth-layer.ts +0 -80
  67. package/src/connectors/connector.ts +0 -35
  68. package/tsconfig.json +0 -9
  69. package/tsconfig.tests.json +0 -10
  70. package/tsconfig.tsbuildinfo +0 -1
@@ -1,338 +0,0 @@
1
- import { describe, expect, test } from 'bun:test';
2
-
3
- import { Result } from '@ontrails/core';
4
-
5
- import type { AuthConnector, AuthError } from '../connectors/connector.js';
6
- import type { PermitExtractionInput } from '../extraction.js';
7
- import type { Permit } from '../permit.js';
8
- import { createJwtConnector } from '../connectors/jwt.js';
9
-
10
- // ---------------------------------------------------------------------------
11
- // Test helper: sign a JWT with HMAC-SHA256 using crypto.subtle
12
- // ---------------------------------------------------------------------------
13
-
14
- const base64url = (buf: ArrayBuffer): string => {
15
- const bytes = new Uint8Array(buf);
16
- let binary = '';
17
- for (const b of bytes) {
18
- binary += String.fromCodePoint(b);
19
- }
20
- return btoa(binary)
21
- .replaceAll('+', '-')
22
- .replaceAll('/', '_')
23
- .replace(/=+$/, '');
24
- };
25
-
26
- const base64urlEncode = (str: string): string => {
27
- const encoder = new TextEncoder();
28
- return base64url(encoder.encode(str).buffer as ArrayBuffer);
29
- };
30
-
31
- const signJwt = async (
32
- payload: Record<string, unknown>,
33
- secret: string
34
- ): Promise<string> => {
35
- const header = base64urlEncode(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
36
- const body = base64urlEncode(JSON.stringify(payload));
37
- const data = `${header}.${body}`;
38
- const encoder = new TextEncoder();
39
- const key = await crypto.subtle.importKey(
40
- 'raw',
41
- encoder.encode(secret),
42
- { hash: 'SHA-256', name: 'HMAC' },
43
- false,
44
- ['sign']
45
- );
46
- const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
47
- return `${data}.${base64url(sig)}`;
48
- };
49
-
50
- const TEST_SECRET = 'test-secret-for-hmac-256';
51
-
52
- // ---------------------------------------------------------------------------
53
- // Tests
54
- // ---------------------------------------------------------------------------
55
-
56
- /** Minimal extraction input for tests that don't need all fields. */
57
- const testInput = (
58
- overrides?: Partial<PermitExtractionInput>
59
- ): PermitExtractionInput => ({
60
- requestId: 'test-req',
61
- trailhead: 'http',
62
- ...overrides,
63
- });
64
-
65
- describe('AuthConnector interface', () => {
66
- test('accepts a valid connector implementation', async () => {
67
- const connector: AuthConnector = {
68
- // oxlint-disable-next-line require-await -- stub connector for type test
69
- authenticate: async (_input: PermitExtractionInput) => Result.ok(null),
70
- };
71
- const result = await connector.authenticate(testInput());
72
- expect(result.isOk()).toBe(true);
73
- });
74
- });
75
-
76
- /* oxlint-disable max-statements -- test suite with multiple concern groups */
77
- describe('createJwtConnector', () => {
78
- test('returns an AuthConnector', () => {
79
- const connector = createJwtConnector({ secret: TEST_SECRET });
80
- expect(connector).toBeDefined();
81
- expect(connector.authenticate).toBeInstanceOf(Function);
82
- });
83
-
84
- test('verifies a valid HS256 token and returns a Permit', async () => {
85
- const connector = createJwtConnector({ secret: TEST_SECRET });
86
- const now = Math.floor(Date.now() / 1000);
87
- const token = await signJwt(
88
- { exp: now + 3600, scope: 'read write', sub: 'user-123' },
89
- TEST_SECRET
90
- );
91
- const result = await connector.authenticate(
92
- testInput({ bearerToken: token })
93
- );
94
- expect(result.isOk()).toBe(true);
95
- const permit = result.unwrap() as Permit;
96
- expect(permit).not.toBeNull();
97
- expect(permit.id).toBe('user-123');
98
- expect(permit.scopes).toEqual(['read', 'write']);
99
- });
100
-
101
- test('rejects an expired token', async () => {
102
- const connector = createJwtConnector({ secret: TEST_SECRET });
103
- const past = Math.floor(Date.now() / 1000) - 3600;
104
- const token = await signJwt(
105
- { exp: past, sub: 'user-expired' },
106
- TEST_SECRET
107
- );
108
- const result = await connector.authenticate(
109
- testInput({ bearerToken: token })
110
- );
111
- expect(result.isErr()).toBe(true);
112
- const err = (result as ReturnType<typeof Result.err<AuthError>>).error;
113
- expect(err.code).toBe('expired_token');
114
- });
115
-
116
- test('rejects an invalid signature', async () => {
117
- const connector = createJwtConnector({ secret: TEST_SECRET });
118
- const now = Math.floor(Date.now() / 1000);
119
- const token = await signJwt(
120
- { exp: now + 3600, sub: 'user-bad-sig' },
121
- 'wrong-secret'
122
- );
123
- const result = await connector.authenticate(
124
- testInput({ bearerToken: token })
125
- );
126
- expect(result.isErr()).toBe(true);
127
- const err = (result as ReturnType<typeof Result.err<AuthError>>).error;
128
- expect(err.code).toBe('invalid_token');
129
- });
130
-
131
- test('rejects tokens with a missing subject claim', async () => {
132
- const connector = createJwtConnector({ secret: TEST_SECRET });
133
- const now = Math.floor(Date.now() / 1000);
134
- const token = await signJwt(
135
- { exp: now + 3600, scope: 'read' },
136
- TEST_SECRET
137
- );
138
- const result = await connector.authenticate(
139
- testInput({ bearerToken: token })
140
- );
141
- expect(result.isErr()).toBe(true);
142
- const err = (result as ReturnType<typeof Result.err<AuthError>>).error;
143
- expect(err.code).toBe('invalid_token');
144
- expect(err.message).toContain('Missing subject claim');
145
- });
146
-
147
- test('extracts scopes from token claims', async () => {
148
- const connector = createJwtConnector({
149
- scopesClaim: 'permissions',
150
- secret: TEST_SECRET,
151
- });
152
- const now = Math.floor(Date.now() / 1000);
153
- const token = await signJwt(
154
- {
155
- exp: now + 3600,
156
- permissions: 'admin:read admin:write',
157
- sub: 'user-scopes',
158
- },
159
- TEST_SECRET
160
- );
161
- const result = await connector.authenticate(
162
- testInput({ bearerToken: token })
163
- );
164
- expect(result.isOk()).toBe(true);
165
- const permit = result.unwrap() as Permit;
166
- expect(permit.scopes).toEqual(['admin:read', 'admin:write']);
167
- });
168
-
169
- test('extracts roles from token claims', async () => {
170
- const connector = createJwtConnector({ secret: TEST_SECRET });
171
- const now = Math.floor(Date.now() / 1000);
172
- const token = await signJwt(
173
- { exp: now + 3600, roles: ['admin', 'editor'], sub: 'user-roles' },
174
- TEST_SECRET
175
- );
176
- const result = await connector.authenticate(
177
- testInput({ bearerToken: token })
178
- );
179
- expect(result.isOk()).toBe(true);
180
- const permit = result.unwrap() as Permit;
181
- expect(permit.roles).toEqual(['admin', 'editor']);
182
- });
183
-
184
- test('returns null for missing credentials', async () => {
185
- const connector = createJwtConnector({ secret: TEST_SECRET });
186
- const result = await connector.authenticate(testInput());
187
- expect(result.isOk()).toBe(true);
188
- expect(result.unwrap()).toBeNull();
189
- });
190
-
191
- test('checks issuer claim when configured', async () => {
192
- const connector = createJwtConnector({
193
- issuer: 'https://auth.example.com',
194
- secret: TEST_SECRET,
195
- });
196
- const now = Math.floor(Date.now() / 1000);
197
- const token = await signJwt(
198
- { exp: now + 3600, iss: 'https://evil.example.com', sub: 'user-iss' },
199
- TEST_SECRET
200
- );
201
- const result = await connector.authenticate(
202
- testInput({ bearerToken: token })
203
- );
204
- expect(result.isErr()).toBe(true);
205
- const err = (result as ReturnType<typeof Result.err<AuthError>>).error;
206
- expect(err.code).toBe('invalid_token');
207
- });
208
-
209
- test('checks audience claim when configured', async () => {
210
- const connector = createJwtConnector({
211
- audience: 'my-api',
212
- secret: TEST_SECRET,
213
- });
214
- const now = Math.floor(Date.now() / 1000);
215
- const token = await signJwt(
216
- { aud: 'other-api', exp: now + 3600, sub: 'user-aud' },
217
- TEST_SECRET
218
- );
219
- const result = await connector.authenticate(
220
- testInput({ bearerToken: token })
221
- );
222
- expect(result.isErr()).toBe(true);
223
- const err = (result as ReturnType<typeof Result.err<AuthError>>).error;
224
- expect(err.code).toBe('invalid_token');
225
- });
226
-
227
- test('accepts array-format audience containing configured value', async () => {
228
- const connector = createJwtConnector({
229
- audience: 'my-api',
230
- secret: TEST_SECRET,
231
- });
232
- const now = Math.floor(Date.now() / 1000);
233
- const token = await signJwt(
234
- { aud: ['my-api', 'account'], exp: now + 3600, sub: 'user-arr-aud' },
235
- TEST_SECRET
236
- );
237
- const result = await connector.authenticate(
238
- testInput({ bearerToken: token })
239
- );
240
- expect(result.isOk()).toBe(true);
241
- const permit = result.unwrap() as Permit;
242
- expect(permit.id).toBe('user-arr-aud');
243
- });
244
-
245
- test('rejects array-format audience not containing configured value', async () => {
246
- const connector = createJwtConnector({
247
- audience: 'my-api',
248
- secret: TEST_SECRET,
249
- });
250
- const now = Math.floor(Date.now() / 1000);
251
- const token = await signJwt(
252
- { aud: ['other-api', 'account'], exp: now + 3600, sub: 'user-no-aud' },
253
- TEST_SECRET
254
- );
255
- const result = await connector.authenticate(
256
- testInput({ bearerToken: token })
257
- );
258
- expect(result.isErr()).toBe(true);
259
- const err = (result as ReturnType<typeof Result.err<AuthError>>).error;
260
- expect(err.code).toBe('invalid_token');
261
- });
262
-
263
- test('extracts scopes from array-format claim', async () => {
264
- const connector = createJwtConnector({ secret: TEST_SECRET });
265
- const now = Math.floor(Date.now() / 1000);
266
- const token = await signJwt(
267
- { exp: now + 3600, scope: ['read', 'write'], sub: 'user-arr-scope' },
268
- TEST_SECRET
269
- );
270
- const result = await connector.authenticate(
271
- testInput({ bearerToken: token })
272
- );
273
- expect(result.isOk()).toBe(true);
274
- const permit = result.unwrap() as Permit;
275
- expect(permit.scopes).toEqual(['read', 'write']);
276
- });
277
-
278
- test('filters empty strings from array-format scope claims', async () => {
279
- const connector = createJwtConnector({ secret: TEST_SECRET });
280
- const now = Math.floor(Date.now() / 1000);
281
- const token = await signJwt(
282
- {
283
- exp: now + 3600,
284
- scope: ['', 'read', '', 'write'],
285
- sub: 'user-arr-scope-empty',
286
- },
287
- TEST_SECRET
288
- );
289
- const result = await connector.authenticate(
290
- testInput({ bearerToken: token })
291
- );
292
- expect(result.isOk()).toBe(true);
293
- const permit = result.unwrap() as Permit;
294
- expect(permit.scopes).toEqual(['read', 'write']);
295
- });
296
-
297
- test('returns error for malformed signature bytes', async () => {
298
- const connector = createJwtConnector({ secret: TEST_SECRET });
299
- const now = Math.floor(Date.now() / 1000);
300
- const token = await signJwt(
301
- { exp: now + 3600, sub: 'user-bad' },
302
- TEST_SECRET
303
- );
304
- const parts = token.split('.');
305
- const malformed = `${parts[0]}.${parts[1]}.!!!invalid-base64!!!`;
306
- const result = await connector.authenticate(
307
- testInput({ bearerToken: malformed })
308
- );
309
- expect(result.isErr()).toBe(true);
310
- const err = (result as ReturnType<typeof Result.err<AuthError>>).error;
311
- expect(err.code).toBe('invalid_token');
312
- });
313
-
314
- test('accepts token with matching issuer and audience', async () => {
315
- const connector = createJwtConnector({
316
- audience: 'my-api',
317
- issuer: 'https://auth.example.com',
318
- secret: TEST_SECRET,
319
- });
320
- const now = Math.floor(Date.now() / 1000);
321
- const token = await signJwt(
322
- {
323
- aud: 'my-api',
324
- exp: now + 3600,
325
- iss: 'https://auth.example.com',
326
- scope: 'read',
327
- sub: 'user-valid',
328
- },
329
- TEST_SECRET
330
- );
331
- const result = await connector.authenticate(
332
- testInput({ bearerToken: token })
333
- );
334
- expect(result.isOk()).toBe(true);
335
- const permit = result.unwrap() as Permit;
336
- expect(permit.id).toBe('user-valid');
337
- });
338
- });
@@ -1,122 +0,0 @@
1
- import { describe, expect, test } from 'bun:test';
2
-
3
- import type { Permit, PermitExtractionInput } from '../index';
4
- import { getPermit } from '../index';
5
-
6
- describe('Permit type', () => {
7
- test('accepts a valid permit with required fields only', () => {
8
- const permit: Permit = {
9
- id: 'usr_abc123',
10
- scopes: ['user:read', 'user:write'],
11
- };
12
- expect(permit.id).toBe('usr_abc123');
13
- expect(permit.scopes).toEqual(['user:read', 'user:write']);
14
- });
15
-
16
- test('accepts a permit with all optional fields', () => {
17
- const permit: Permit = {
18
- id: 'usr_full',
19
- metadata: { plan: 'pro', provider: 'clerk' },
20
- roles: ['admin', 'editor'],
21
- scopes: ['entity:read'],
22
- tenantId: 'tenant_xyz',
23
- };
24
- expect(permit.roles).toEqual(['admin', 'editor']);
25
- expect(permit.tenantId).toBe('tenant_xyz');
26
- expect(permit.metadata).toEqual({ plan: 'pro', provider: 'clerk' });
27
- });
28
-
29
- test('scopes and roles arrays are readonly', () => {
30
- const permit: Permit = {
31
- id: 'usr_ro',
32
- roles: ['viewer'],
33
- scopes: ['read'],
34
- };
35
- // Structural check: readonly arrays are assignable to readonly string[]
36
- const { scopes } = permit;
37
- const { roles } = permit;
38
- expect(scopes).toEqual(['read']);
39
- expect(roles).toEqual(['viewer']);
40
- });
41
- });
42
-
43
- describe('getPermit()', () => {
44
- test('returns Permit from a context with a permit', () => {
45
- const permit: Permit = { id: 'usr_1', scopes: ['user:read'] };
46
- const ctx = { permit, requestId: 'req-1' };
47
- const result = getPermit(ctx);
48
- expect(result).toEqual(permit);
49
- });
50
-
51
- test('returns undefined when context has no permit', () => {
52
- const ctx = { requestId: 'req-2' };
53
- const result = getPermit(ctx);
54
- expect(result).toBeUndefined();
55
- });
56
-
57
- test('returns undefined when permit is explicitly undefined', () => {
58
- const ctx = { permit: undefined, requestId: 'req-3' };
59
- const result = getPermit(ctx);
60
- expect(result).toBeUndefined();
61
- });
62
-
63
- test('preserves extended permit fields from the auth layer', () => {
64
- const ctx = {
65
- permit: {
66
- id: 'usr_2',
67
- metadata: { plan: 'pro' },
68
- roles: ['admin'],
69
- scopes: ['user:read'],
70
- tenantId: 'tenant-1',
71
- } satisfies Permit,
72
- requestId: 'req-4',
73
- };
74
- const result = getPermit(ctx);
75
- expect(result).toEqual(ctx.permit);
76
- });
77
- });
78
-
79
- describe('PermitExtractionInput', () => {
80
- test('accepts HTTP trailhead extraction', () => {
81
- const input: PermitExtractionInput = {
82
- bearerToken: 'eyJhbGciOiJSUzI1NiJ9.test',
83
- headers: new Headers({
84
- authorization: 'Bearer eyJhbGciOiJSUzI1NiJ9.test',
85
- }),
86
- requestId: 'req-http-1',
87
- trailhead: 'http',
88
- };
89
- expect(input.trailhead).toBe('http');
90
- expect(input.bearerToken).toBeDefined();
91
- });
92
-
93
- test('accepts MCP trailhead extraction', () => {
94
- const input: PermitExtractionInput = {
95
- requestId: 'req-mcp-1',
96
- sessionId: 'mcp-session-abc',
97
- trailhead: 'mcp',
98
- };
99
- expect(input.trailhead).toBe('mcp');
100
- expect(input.sessionId).toBe('mcp-session-abc');
101
- });
102
-
103
- test('accepts CLI trailhead extraction', () => {
104
- const input: PermitExtractionInput = {
105
- bearerToken: 'cli-token-from-keyring',
106
- requestId: 'req-cli-1',
107
- trailhead: 'cli',
108
- };
109
- expect(input.trailhead).toBe('cli');
110
- });
111
-
112
- test('accepts minimal extraction with only required fields', () => {
113
- const input: PermitExtractionInput = {
114
- requestId: 'req-minimal',
115
- trailhead: 'http',
116
- };
117
- expect(input.requestId).toBe('req-minimal');
118
- expect(input.bearerToken).toBeUndefined();
119
- expect(input.sessionId).toBeUndefined();
120
- expect(input.headers).toBeUndefined();
121
- });
122
- });
@@ -1,239 +0,0 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import { trail, Result } from '@ontrails/core';
3
- import { z } from 'zod';
4
-
5
- import {
6
- destroyWithoutPermit,
7
- writeWithoutPermit,
8
- scopeNamingConsistency,
9
- orphanScopeDetection,
10
- validatePermits,
11
- } from '../rules.js';
12
-
13
- // ---------------------------------------------------------------------------
14
- // Helpers
15
- // ---------------------------------------------------------------------------
16
-
17
- const emptyInput = z.object({});
18
- const noopRun = () => Result.ok({});
19
-
20
- // ---------------------------------------------------------------------------
21
- // destroyWithoutPermit
22
- // ---------------------------------------------------------------------------
23
-
24
- describe('destroyWithoutPermit', () => {
25
- test('error when destroy trail has no permit', () => {
26
- const t = trail('user.delete', {
27
- blaze: noopRun,
28
- input: emptyInput,
29
- intent: 'destroy',
30
- });
31
- const diagnostics = destroyWithoutPermit([t]);
32
- expect(diagnostics).toHaveLength(1);
33
- expect(diagnostics[0]).toMatchObject({
34
- message: expect.stringContaining('destroy'),
35
- rule: 'destroyWithoutPermit',
36
- severity: 'error',
37
- trailId: 'user.delete',
38
- });
39
- });
40
-
41
- test('no diagnostic when destroy trail has a scoped permit', () => {
42
- const t = trail('user.delete', {
43
- blaze: noopRun,
44
- input: emptyInput,
45
- intent: 'destroy',
46
- permit: { scopes: ['user:delete'] },
47
- });
48
- const diagnostics = destroyWithoutPermit([t]);
49
- expect(diagnostics).toHaveLength(0);
50
- });
51
-
52
- test('error when destroy trail has permit: public', () => {
53
- const t = trail('user.delete', {
54
- blaze: noopRun,
55
- input: emptyInput,
56
- intent: 'destroy',
57
- permit: 'public',
58
- });
59
- const diagnostics = destroyWithoutPermit([t]);
60
- expect(diagnostics).toHaveLength(1);
61
- expect(diagnostics[0]).toMatchObject({
62
- rule: 'destroyWithoutPermit',
63
- severity: 'error',
64
- trailId: 'user.delete',
65
- });
66
- });
67
- });
68
-
69
- // ---------------------------------------------------------------------------
70
- // writeWithoutPermit
71
- // ---------------------------------------------------------------------------
72
-
73
- describe('writeWithoutPermit', () => {
74
- test('warning when write trail has no permit', () => {
75
- const t = trail('user.create', {
76
- blaze: noopRun,
77
- input: emptyInput,
78
- });
79
- const diagnostics = writeWithoutPermit([t]);
80
- expect(diagnostics).toHaveLength(1);
81
- expect(diagnostics[0]).toMatchObject({
82
- rule: 'writeWithoutPermit',
83
- severity: 'warning',
84
- trailId: 'user.create',
85
- });
86
- });
87
-
88
- test('no warning when write trail has permit: public', () => {
89
- const t = trail('user.create', {
90
- blaze: noopRun,
91
- input: emptyInput,
92
- permit: 'public',
93
- });
94
- const diagnostics = writeWithoutPermit([t]);
95
- expect(diagnostics).toHaveLength(0);
96
- });
97
-
98
- test('warning when trail has no intent (defaults to write)', () => {
99
- const t = trail('user.update', {
100
- blaze: noopRun,
101
- input: emptyInput,
102
- });
103
- // Override intent to undefined to simulate a manually constructed trail
104
- const noIntent = { ...t, intent: undefined } as unknown as ReturnType<
105
- typeof trail
106
- >;
107
- const diagnostics = writeWithoutPermit([noIntent]);
108
- expect(diagnostics).toHaveLength(1);
109
- expect(diagnostics[0]).toMatchObject({
110
- rule: 'writeWithoutPermit',
111
- severity: 'warning',
112
- trailId: 'user.update',
113
- });
114
- });
115
-
116
- test('no diagnostic for read trail without permit', () => {
117
- const t = trail('user.list', {
118
- blaze: noopRun,
119
- input: emptyInput,
120
- intent: 'read',
121
- });
122
- const diagnostics = writeWithoutPermit([t]);
123
- expect(diagnostics).toHaveLength(0);
124
- });
125
- });
126
-
127
- // ---------------------------------------------------------------------------
128
- // scopeNamingConsistency
129
- // ---------------------------------------------------------------------------
130
-
131
- describe('scopeNamingConsistency', () => {
132
- test('scope user:write passes naming check', () => {
133
- const t = trail('user.update', {
134
- blaze: noopRun,
135
- input: emptyInput,
136
- permit: { scopes: ['user:write'] },
137
- });
138
- const diagnostics = scopeNamingConsistency([t]);
139
- expect(diagnostics).toHaveLength(0);
140
- });
141
-
142
- test('warning for scope without colon', () => {
143
- const t = trail('admin.panel', {
144
- blaze: noopRun,
145
- input: emptyInput,
146
- permit: { scopes: ['admin'] },
147
- });
148
- const diagnostics = scopeNamingConsistency([t]);
149
- expect(diagnostics).toHaveLength(1);
150
- expect(diagnostics[0]).toMatchObject({
151
- message: expect.stringContaining('admin'),
152
- rule: 'scopeNamingConsistency',
153
- severity: 'warning',
154
- trailId: 'admin.panel',
155
- });
156
- });
157
- });
158
-
159
- // ---------------------------------------------------------------------------
160
- // orphanScopeDetection
161
- // ---------------------------------------------------------------------------
162
-
163
- describe('orphanScopeDetection', () => {
164
- test('warning for orphan scope (typo)', () => {
165
- const t1 = trail('user.read', {
166
- blaze: noopRun,
167
- input: emptyInput,
168
- permit: { scopes: ['user:read'] },
169
- });
170
- const t2 = trail('user.write', {
171
- blaze: noopRun,
172
- input: emptyInput,
173
- permit: { scopes: ['user:wirte'] },
174
- });
175
- const diagnostics = orphanScopeDetection([t1, t2]);
176
- // Both scopes are unique (appear in only 1 trail each)
177
- expect(diagnostics).toHaveLength(2);
178
- const messages = diagnostics.map((d) => d.message);
179
- expect(messages.some((m) => m.includes('user:wirte'))).toBe(true);
180
- });
181
-
182
- test('no warning for shared scopes', () => {
183
- const t1 = trail('user.read', {
184
- blaze: noopRun,
185
- input: emptyInput,
186
- permit: { scopes: ['user:read'] },
187
- });
188
- const t2 = trail('user.profile', {
189
- blaze: noopRun,
190
- input: emptyInput,
191
- permit: { scopes: ['user:read'] },
192
- });
193
- const diagnostics = orphanScopeDetection([t1, t2]);
194
- expect(diagnostics).toHaveLength(0);
195
- });
196
- });
197
-
198
- // ---------------------------------------------------------------------------
199
- // validatePermits
200
- // ---------------------------------------------------------------------------
201
-
202
- /* oxlint-disable max-statements -- integration test validates all rules fire */
203
- describe('validatePermits', () => {
204
- test('runs all rules and aggregates diagnostics', () => {
205
- const destroyNoPerm = trail('user.delete', {
206
- blaze: noopRun,
207
- input: emptyInput,
208
- intent: 'destroy',
209
- });
210
- const writeNoPerm = trail('user.create', {
211
- blaze: noopRun,
212
- input: emptyInput,
213
- });
214
- const badScope = trail('admin.panel', {
215
- blaze: noopRun,
216
- input: emptyInput,
217
- permit: { scopes: ['admin'] },
218
- });
219
- const orphanScope = trail('analytics.export', {
220
- blaze: noopRun,
221
- input: emptyInput,
222
- permit: { scopes: ['analytics:exportt'] },
223
- });
224
-
225
- const diagnostics = validatePermits([
226
- destroyNoPerm,
227
- writeNoPerm,
228
- badScope,
229
- orphanScope,
230
- ]);
231
-
232
- const rules = diagnostics.map((d) => d.rule);
233
- expect(rules).toContain('destroyWithoutPermit');
234
- expect(rules).toContain('writeWithoutPermit');
235
- expect(rules).toContain('scopeNamingConsistency');
236
- expect(rules).toContain('orphanScopeDetection');
237
- expect(diagnostics.length).toBeGreaterThanOrEqual(4);
238
- });
239
- });