@checkstack/auth-backend 0.0.3 → 0.2.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/CHANGELOG.md +174 -0
- package/drizzle/0002_lowly_squirrel_girl.sql +43 -0
- package/drizzle/0003_tranquil_sally_floyd.sql +8 -0
- package/drizzle/0004_lucky_power_man.sql +21 -0
- package/drizzle/meta/0002_snapshot.json +1017 -0
- package/drizzle/meta/0003_snapshot.json +1050 -0
- package/drizzle/meta/0004_snapshot.json +1050 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +1 -1
- package/src/index.ts +176 -162
- package/src/router.test.ts +11 -11
- package/src/router.ts +525 -90
- package/src/schema.ts +125 -18
- package/src/teams.test.ts +1985 -0
- package/src/utils/user.test.ts +65 -46
- package/src/utils/user.ts +21 -13
|
@@ -0,0 +1,1985 @@
|
|
|
1
|
+
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
2
|
+
import { createAuthRouter } from "./router";
|
|
3
|
+
import { createMockRpcContext } from "@checkstack/backend-api";
|
|
4
|
+
import { call } from "@orpc/server";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import * as schema from "./schema";
|
|
7
|
+
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
8
|
+
|
|
9
|
+
/** Type alias for the database type used in auth router */
|
|
10
|
+
type AuthDatabase = NodePgDatabase<typeof schema>;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Tests for Team and Resource-Level Access Control endpoints.
|
|
14
|
+
*
|
|
15
|
+
* These tests cover:
|
|
16
|
+
* - Team CRUD operations (getTeams, createTeam, updateTeam, deleteTeam)
|
|
17
|
+
* - Team membership management (addUserToTeam, removeUserFromTeam)
|
|
18
|
+
* - Team manager operations (addTeamManager, removeTeamManager)
|
|
19
|
+
* - Resource access grants (getResourceTeamAccess, setResourceTeamAccess, removeResourceTeamAccess)
|
|
20
|
+
* - S2S access checks (checkResourceTeamAccess, getAccessibleResourceIds)
|
|
21
|
+
*/
|
|
22
|
+
describe("Teams and Resource Access Control", () => {
|
|
23
|
+
// Mock user with admin accesss
|
|
24
|
+
const mockAdminUser = {
|
|
25
|
+
type: "user" as const,
|
|
26
|
+
id: "admin-user",
|
|
27
|
+
accessRules: ["*"],
|
|
28
|
+
roles: ["admin"],
|
|
29
|
+
teamIds: ["team-alpha"],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Mock regular user with limited access
|
|
33
|
+
// Note: Uses test-plugin prefix to match createMockRpcContext's pluginMetadata
|
|
34
|
+
const mockRegularUser = {
|
|
35
|
+
type: "user" as const,
|
|
36
|
+
id: "regular-user",
|
|
37
|
+
accessRules: ["test-plugin.teams.read"],
|
|
38
|
+
roles: ["users"],
|
|
39
|
+
teamIds: ["team-beta"],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Mock service user for S2S calls
|
|
43
|
+
const mockServiceUser = {
|
|
44
|
+
type: "service" as const,
|
|
45
|
+
pluginId: "backend-api",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Creates a chainable mock for database query operations.
|
|
50
|
+
* Allows chaining .where().innerJoin().limit().offset().orderBy()
|
|
51
|
+
*/
|
|
52
|
+
function createChain<T>(data: T[] = []): Record<string, unknown> {
|
|
53
|
+
const chain: Record<string, unknown> = {
|
|
54
|
+
where: mock(() => chain),
|
|
55
|
+
innerJoin: mock(() => chain),
|
|
56
|
+
limit: mock(() => chain),
|
|
57
|
+
offset: mock(() => chain),
|
|
58
|
+
orderBy: mock(() => chain),
|
|
59
|
+
onConflictDoUpdate: mock(() => Promise.resolve()),
|
|
60
|
+
onConflictDoNothing: mock(() => Promise.resolve()),
|
|
61
|
+
then: (resolve: (value: T[]) => void) => Promise.resolve(resolve(data)),
|
|
62
|
+
};
|
|
63
|
+
return chain;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Creates a fresh mock database for each test.
|
|
68
|
+
* Uses type assertion to satisfy NodePgDatabase interface for testing.
|
|
69
|
+
*/
|
|
70
|
+
function createMockDb(): AuthDatabase {
|
|
71
|
+
const mockDb = {
|
|
72
|
+
select: mock(() => ({
|
|
73
|
+
from: mock(() => createChain([])),
|
|
74
|
+
})),
|
|
75
|
+
insert: mock(() => ({
|
|
76
|
+
values: mock(() => ({
|
|
77
|
+
onConflictDoNothing: mock(() => Promise.resolve()),
|
|
78
|
+
onConflictDoUpdate: mock(() => Promise.resolve()),
|
|
79
|
+
then: (resolve: (value: unknown) => void) =>
|
|
80
|
+
Promise.resolve(resolve(undefined)),
|
|
81
|
+
})),
|
|
82
|
+
})),
|
|
83
|
+
update: mock(() => ({
|
|
84
|
+
set: mock(() => ({
|
|
85
|
+
where: mock(() => Promise.resolve()),
|
|
86
|
+
})),
|
|
87
|
+
})),
|
|
88
|
+
delete: mock(() => ({
|
|
89
|
+
where: mock(() => Promise.resolve()),
|
|
90
|
+
})),
|
|
91
|
+
transaction: mock((cb: (tx: typeof mockDb) => Promise<void>) =>
|
|
92
|
+
cb(mockDb)
|
|
93
|
+
),
|
|
94
|
+
};
|
|
95
|
+
// Type assertion for mock database - only used in tests
|
|
96
|
+
return mockDb as unknown as AuthDatabase;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const mockRegistry = {
|
|
100
|
+
getStrategies: () => [
|
|
101
|
+
{
|
|
102
|
+
id: "credential",
|
|
103
|
+
displayName: "Credentials",
|
|
104
|
+
description: "Email and password authentication",
|
|
105
|
+
configSchema: z.object({ enabled: z.boolean() }),
|
|
106
|
+
configVersion: 1,
|
|
107
|
+
migrations: [],
|
|
108
|
+
requiresManualRegistration: true,
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const mockConfigService = {
|
|
114
|
+
get: mock(() => Promise.resolve(undefined)),
|
|
115
|
+
getRedacted: mock(() => Promise.resolve({})),
|
|
116
|
+
set: mock(() => Promise.resolve()),
|
|
117
|
+
delete: mock(() => Promise.resolve()),
|
|
118
|
+
list: mock(() => Promise.resolve([])),
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const mockAccessRuleRegistry = {
|
|
122
|
+
getAccessRules: () => [
|
|
123
|
+
{ id: "auth.teams.read", description: "View teams" },
|
|
124
|
+
{ id: "auth.teams.manage", description: "Manage teams" },
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// ==========================================================================
|
|
129
|
+
// TEAM CRUD TESTS
|
|
130
|
+
// ==========================================================================
|
|
131
|
+
|
|
132
|
+
describe("getTeams", () => {
|
|
133
|
+
it("returns empty array when no teams exist", async () => {
|
|
134
|
+
const mockDb = createMockDb();
|
|
135
|
+
const router = createAuthRouter(
|
|
136
|
+
mockDb,
|
|
137
|
+
mockRegistry,
|
|
138
|
+
async () => {},
|
|
139
|
+
mockConfigService,
|
|
140
|
+
mockAccessRuleRegistry
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const context = createMockRpcContext({ user: mockAdminUser });
|
|
144
|
+
const result = await call(router.getTeams, undefined, { context });
|
|
145
|
+
|
|
146
|
+
expect(Array.isArray(result)).toBe(true);
|
|
147
|
+
expect(result).toHaveLength(0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("returns teams with member counts", async () => {
|
|
151
|
+
const mockDb = createMockDb();
|
|
152
|
+
|
|
153
|
+
// Mock teams query
|
|
154
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
155
|
+
from: mock(() =>
|
|
156
|
+
createChain([
|
|
157
|
+
{
|
|
158
|
+
id: "team-1",
|
|
159
|
+
name: "Platform Team",
|
|
160
|
+
description: "Core platform team",
|
|
161
|
+
},
|
|
162
|
+
{ id: "team-2", name: "API Team", description: null },
|
|
163
|
+
])
|
|
164
|
+
),
|
|
165
|
+
}));
|
|
166
|
+
|
|
167
|
+
// Mock member counts query
|
|
168
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
169
|
+
from: mock(() =>
|
|
170
|
+
createChain([
|
|
171
|
+
{ teamId: "team-1" },
|
|
172
|
+
{ teamId: "team-1" },
|
|
173
|
+
{ teamId: "team-2" },
|
|
174
|
+
])
|
|
175
|
+
),
|
|
176
|
+
}));
|
|
177
|
+
|
|
178
|
+
// Mock manager query (user is manager of team-1)
|
|
179
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
180
|
+
from: mock(() =>
|
|
181
|
+
createChain([{ teamId: "team-1", userId: "admin-user" }])
|
|
182
|
+
),
|
|
183
|
+
}));
|
|
184
|
+
|
|
185
|
+
const router = createAuthRouter(
|
|
186
|
+
mockDb,
|
|
187
|
+
mockRegistry,
|
|
188
|
+
async () => {},
|
|
189
|
+
mockConfigService,
|
|
190
|
+
mockAccessRuleRegistry
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const context = createMockRpcContext({ user: mockAdminUser });
|
|
194
|
+
const result = await call(router.getTeams, undefined, { context });
|
|
195
|
+
|
|
196
|
+
expect(result).toHaveLength(2);
|
|
197
|
+
expect(result[0]).toEqual({
|
|
198
|
+
id: "team-1",
|
|
199
|
+
name: "Platform Team",
|
|
200
|
+
description: "Core platform team",
|
|
201
|
+
memberCount: 2,
|
|
202
|
+
isManager: true,
|
|
203
|
+
});
|
|
204
|
+
expect(result[1]).toEqual({
|
|
205
|
+
id: "team-2",
|
|
206
|
+
name: "API Team",
|
|
207
|
+
description: null,
|
|
208
|
+
memberCount: 1,
|
|
209
|
+
isManager: false,
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("returns teams for regular user with read-only access", async () => {
|
|
214
|
+
const mockDb = createMockDb();
|
|
215
|
+
|
|
216
|
+
// Mock teams query
|
|
217
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
218
|
+
from: mock(() =>
|
|
219
|
+
createChain([
|
|
220
|
+
{
|
|
221
|
+
id: "team-alpha",
|
|
222
|
+
name: "Alpha Team",
|
|
223
|
+
description: "First team",
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
id: "team-beta",
|
|
227
|
+
name: "Beta Team",
|
|
228
|
+
description: "Second team",
|
|
229
|
+
},
|
|
230
|
+
])
|
|
231
|
+
),
|
|
232
|
+
}));
|
|
233
|
+
|
|
234
|
+
// Mock member counts query
|
|
235
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
236
|
+
from: mock(() =>
|
|
237
|
+
createChain([
|
|
238
|
+
{ teamId: "team-alpha" },
|
|
239
|
+
{ teamId: "team-beta" },
|
|
240
|
+
{ teamId: "team-beta" },
|
|
241
|
+
])
|
|
242
|
+
),
|
|
243
|
+
}));
|
|
244
|
+
|
|
245
|
+
// Mock manager query (regular-user is not a manager of any team)
|
|
246
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
247
|
+
from: mock(() => createChain([])),
|
|
248
|
+
}));
|
|
249
|
+
|
|
250
|
+
const router = createAuthRouter(
|
|
251
|
+
mockDb,
|
|
252
|
+
mockRegistry,
|
|
253
|
+
async () => {},
|
|
254
|
+
mockConfigService,
|
|
255
|
+
mockAccessRuleRegistry
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// Use mockRegularUser who has only auth.teams.read access
|
|
259
|
+
const context = createMockRpcContext({ user: mockRegularUser });
|
|
260
|
+
const result = await call(router.getTeams, undefined, { context });
|
|
261
|
+
|
|
262
|
+
expect(result).toHaveLength(2);
|
|
263
|
+
expect(result[0]).toEqual({
|
|
264
|
+
id: "team-alpha",
|
|
265
|
+
name: "Alpha Team",
|
|
266
|
+
description: "First team",
|
|
267
|
+
memberCount: 1,
|
|
268
|
+
isManager: false,
|
|
269
|
+
});
|
|
270
|
+
expect(result[1]).toEqual({
|
|
271
|
+
id: "team-beta",
|
|
272
|
+
name: "Beta Team",
|
|
273
|
+
description: "Second team",
|
|
274
|
+
memberCount: 2,
|
|
275
|
+
isManager: false,
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("shows correct manager status for regular user who is a team manager", async () => {
|
|
280
|
+
const mockDb = createMockDb();
|
|
281
|
+
|
|
282
|
+
// Mock teams query
|
|
283
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
284
|
+
from: mock(() =>
|
|
285
|
+
createChain([
|
|
286
|
+
{
|
|
287
|
+
id: "team-beta",
|
|
288
|
+
name: "Beta Team",
|
|
289
|
+
description: "User's team",
|
|
290
|
+
},
|
|
291
|
+
])
|
|
292
|
+
),
|
|
293
|
+
}));
|
|
294
|
+
|
|
295
|
+
// Mock member counts query
|
|
296
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
297
|
+
from: mock(() => createChain([{ teamId: "team-beta" }])),
|
|
298
|
+
}));
|
|
299
|
+
|
|
300
|
+
// Mock manager query (regular-user IS a manager of team-beta)
|
|
301
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
302
|
+
from: mock(() =>
|
|
303
|
+
createChain([{ teamId: "team-beta", userId: "regular-user" }])
|
|
304
|
+
),
|
|
305
|
+
}));
|
|
306
|
+
|
|
307
|
+
const router = createAuthRouter(
|
|
308
|
+
mockDb,
|
|
309
|
+
mockRegistry,
|
|
310
|
+
async () => {},
|
|
311
|
+
mockConfigService,
|
|
312
|
+
mockAccessRuleRegistry
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const context = createMockRpcContext({ user: mockRegularUser });
|
|
316
|
+
const result = await call(router.getTeams, undefined, { context });
|
|
317
|
+
|
|
318
|
+
expect(result).toHaveLength(1);
|
|
319
|
+
expect(result[0].isManager).toBe(true);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe("getTeam", () => {
|
|
324
|
+
it("returns undefined for non-existent team", async () => {
|
|
325
|
+
const mockDb = createMockDb();
|
|
326
|
+
|
|
327
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
328
|
+
from: mock(() => createChain([])),
|
|
329
|
+
}));
|
|
330
|
+
|
|
331
|
+
const router = createAuthRouter(
|
|
332
|
+
mockDb,
|
|
333
|
+
mockRegistry,
|
|
334
|
+
async () => {},
|
|
335
|
+
mockConfigService,
|
|
336
|
+
mockAccessRuleRegistry
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const context = createMockRpcContext({ user: mockAdminUser });
|
|
340
|
+
const result = await call(
|
|
341
|
+
router.getTeam,
|
|
342
|
+
{ teamId: "non-existent" },
|
|
343
|
+
{ context }
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
expect(result).toBeUndefined();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("returns team with members and managers", async () => {
|
|
350
|
+
const mockDb = createMockDb();
|
|
351
|
+
|
|
352
|
+
// Mock team query
|
|
353
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
354
|
+
from: mock(() =>
|
|
355
|
+
createChain([
|
|
356
|
+
{
|
|
357
|
+
id: "team-1",
|
|
358
|
+
name: "Platform Team",
|
|
359
|
+
description: "Core team",
|
|
360
|
+
},
|
|
361
|
+
])
|
|
362
|
+
),
|
|
363
|
+
}));
|
|
364
|
+
|
|
365
|
+
// Mock members query
|
|
366
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
367
|
+
from: mock(() =>
|
|
368
|
+
createChain([{ userId: "user-1" }, { userId: "user-2" }])
|
|
369
|
+
),
|
|
370
|
+
}));
|
|
371
|
+
|
|
372
|
+
// Mock managers query
|
|
373
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
374
|
+
from: mock(() => createChain([{ userId: "user-1" }])),
|
|
375
|
+
}));
|
|
376
|
+
|
|
377
|
+
// Mock users query
|
|
378
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
379
|
+
from: mock(() =>
|
|
380
|
+
createChain([
|
|
381
|
+
{ id: "user-1", name: "Alice", email: "alice@test.com" },
|
|
382
|
+
{ id: "user-2", name: "Bob", email: "bob@test.com" },
|
|
383
|
+
])
|
|
384
|
+
),
|
|
385
|
+
}));
|
|
386
|
+
|
|
387
|
+
const router = createAuthRouter(
|
|
388
|
+
mockDb,
|
|
389
|
+
mockRegistry,
|
|
390
|
+
async () => {},
|
|
391
|
+
mockConfigService,
|
|
392
|
+
mockAccessRuleRegistry
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
const context = createMockRpcContext({ user: mockAdminUser });
|
|
396
|
+
const result = await call(
|
|
397
|
+
router.getTeam,
|
|
398
|
+
{ teamId: "team-1" },
|
|
399
|
+
{ context }
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
expect(result).toBeDefined();
|
|
403
|
+
expect(result?.name).toBe("Platform Team");
|
|
404
|
+
expect(result?.members).toHaveLength(2);
|
|
405
|
+
expect(result?.managers).toHaveLength(1);
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
describe("createTeam", () => {
|
|
410
|
+
it("creates team with name and description", async () => {
|
|
411
|
+
const mockDb = createMockDb();
|
|
412
|
+
let insertedData: Record<string, unknown> | undefined;
|
|
413
|
+
|
|
414
|
+
(mockDb.insert as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
415
|
+
values: mock((data: Record<string, unknown>) => {
|
|
416
|
+
insertedData = data;
|
|
417
|
+
return Promise.resolve();
|
|
418
|
+
}),
|
|
419
|
+
}));
|
|
420
|
+
|
|
421
|
+
const router = createAuthRouter(
|
|
422
|
+
mockDb,
|
|
423
|
+
mockRegistry,
|
|
424
|
+
async () => {},
|
|
425
|
+
mockConfigService,
|
|
426
|
+
mockAccessRuleRegistry
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
const context = createMockRpcContext({ user: mockAdminUser });
|
|
430
|
+
const result = await call(
|
|
431
|
+
router.createTeam,
|
|
432
|
+
{ name: "New Team", description: "A new team" },
|
|
433
|
+
{ context }
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
expect(result).toBeDefined();
|
|
437
|
+
expect(result.id).toBeDefined();
|
|
438
|
+
expect(typeof result.id).toBe("string");
|
|
439
|
+
expect(mockDb.insert).toHaveBeenCalled();
|
|
440
|
+
expect(insertedData?.name).toBe("New Team");
|
|
441
|
+
expect(insertedData?.description).toBe("A new team");
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("creates team with minimal data", async () => {
|
|
445
|
+
const mockDb = createMockDb();
|
|
446
|
+
|
|
447
|
+
(mockDb.insert as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
448
|
+
values: mock(() => Promise.resolve()),
|
|
449
|
+
}));
|
|
450
|
+
|
|
451
|
+
const router = createAuthRouter(
|
|
452
|
+
mockDb,
|
|
453
|
+
mockRegistry,
|
|
454
|
+
async () => {},
|
|
455
|
+
mockConfigService,
|
|
456
|
+
mockAccessRuleRegistry
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
const context = createMockRpcContext({ user: mockAdminUser });
|
|
460
|
+
const result = await call(
|
|
461
|
+
router.createTeam,
|
|
462
|
+
{ name: "Minimal Team" },
|
|
463
|
+
{ context }
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
expect(result).toBeDefined();
|
|
467
|
+
expect(result.id).toBeDefined();
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
describe("updateTeam", () => {
|
|
472
|
+
it("updates team name", async () => {
|
|
473
|
+
const mockDb = createMockDb();
|
|
474
|
+
let updatedData: Record<string, unknown> | undefined;
|
|
475
|
+
|
|
476
|
+
(mockDb.update as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
477
|
+
set: mock((data: Record<string, unknown>) => {
|
|
478
|
+
updatedData = data;
|
|
479
|
+
return {
|
|
480
|
+
where: mock(() => Promise.resolve()),
|
|
481
|
+
};
|
|
482
|
+
}),
|
|
483
|
+
}));
|
|
484
|
+
|
|
485
|
+
const router = createAuthRouter(
|
|
486
|
+
mockDb,
|
|
487
|
+
mockRegistry,
|
|
488
|
+
async () => {},
|
|
489
|
+
mockConfigService,
|
|
490
|
+
mockAccessRuleRegistry
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
const context = createMockRpcContext({ user: mockAdminUser });
|
|
494
|
+
await call(
|
|
495
|
+
router.updateTeam,
|
|
496
|
+
{ id: "team-1", name: "Updated Name" },
|
|
497
|
+
{ context }
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
expect(mockDb.update).toHaveBeenCalled();
|
|
501
|
+
expect(updatedData?.name).toBe("Updated Name");
|
|
502
|
+
expect(updatedData?.updatedAt).toBeInstanceOf(Date);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("updates team description", async () => {
|
|
506
|
+
const mockDb = createMockDb();
|
|
507
|
+
let updatedData: Record<string, unknown> | undefined;
|
|
508
|
+
|
|
509
|
+
(mockDb.update as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
510
|
+
set: mock((data: Record<string, unknown>) => {
|
|
511
|
+
updatedData = data;
|
|
512
|
+
return {
|
|
513
|
+
where: mock(() => Promise.resolve()),
|
|
514
|
+
};
|
|
515
|
+
}),
|
|
516
|
+
}));
|
|
517
|
+
|
|
518
|
+
const router = createAuthRouter(
|
|
519
|
+
mockDb,
|
|
520
|
+
mockRegistry,
|
|
521
|
+
async () => {},
|
|
522
|
+
mockConfigService,
|
|
523
|
+
mockAccessRuleRegistry
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
const context = createMockRpcContext({ user: mockAdminUser });
|
|
527
|
+
await call(
|
|
528
|
+
router.updateTeam,
|
|
529
|
+
{ id: "team-1", description: "New description" },
|
|
530
|
+
{ context }
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
expect(updatedData?.description).toBe("New description");
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
describe("deleteTeam", () => {
|
|
538
|
+
it("deletes team and cascades to related tables", async () => {
|
|
539
|
+
const mockDb = createMockDb();
|
|
540
|
+
const deletedTables: unknown[] = [];
|
|
541
|
+
|
|
542
|
+
const mockTx = {
|
|
543
|
+
delete: mock((table: unknown) => {
|
|
544
|
+
deletedTables.push(table);
|
|
545
|
+
return {
|
|
546
|
+
where: mock(() => Promise.resolve()),
|
|
547
|
+
};
|
|
548
|
+
}),
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
(mockDb.transaction as ReturnType<typeof mock>).mockImplementationOnce(
|
|
552
|
+
(cb: (tx: typeof mockTx) => Promise<void>) => cb(mockTx)
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
const router = createAuthRouter(
|
|
556
|
+
mockDb,
|
|
557
|
+
mockRegistry,
|
|
558
|
+
async () => {},
|
|
559
|
+
mockConfigService,
|
|
560
|
+
mockAccessRuleRegistry
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
const context = createMockRpcContext({ user: mockAdminUser });
|
|
564
|
+
await call(router.deleteTeam, "team-1", { context });
|
|
565
|
+
|
|
566
|
+
expect(mockDb.transaction).toHaveBeenCalled();
|
|
567
|
+
expect(deletedTables).toHaveLength(5);
|
|
568
|
+
expect(deletedTables.includes(schema.userTeam)).toBe(true);
|
|
569
|
+
expect(deletedTables.includes(schema.teamManager)).toBe(true);
|
|
570
|
+
expect(deletedTables.includes(schema.applicationTeam)).toBe(true);
|
|
571
|
+
expect(deletedTables.includes(schema.resourceTeamAccess)).toBe(true);
|
|
572
|
+
expect(deletedTables.includes(schema.team)).toBe(true);
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// ==========================================================================
|
|
577
|
+
// TEAM MEMBERSHIP TESTS
|
|
578
|
+
// ==========================================================================
|
|
579
|
+
|
|
580
|
+
describe("addUserToTeam", () => {
|
|
581
|
+
it("adds user to team with conflict handling", async () => {
|
|
582
|
+
const mockDb = createMockDb();
|
|
583
|
+
let insertedData: Record<string, unknown> | undefined;
|
|
584
|
+
|
|
585
|
+
(mockDb.insert as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
586
|
+
values: mock((data: Record<string, unknown>) => {
|
|
587
|
+
insertedData = data;
|
|
588
|
+
return {
|
|
589
|
+
onConflictDoNothing: mock(() => Promise.resolve()),
|
|
590
|
+
};
|
|
591
|
+
}),
|
|
592
|
+
}));
|
|
593
|
+
|
|
594
|
+
const router = createAuthRouter(
|
|
595
|
+
mockDb,
|
|
596
|
+
mockRegistry,
|
|
597
|
+
async () => {},
|
|
598
|
+
mockConfigService,
|
|
599
|
+
mockAccessRuleRegistry
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
const context = createMockRpcContext({ user: mockAdminUser });
|
|
603
|
+
await call(
|
|
604
|
+
router.addUserToTeam,
|
|
605
|
+
{ teamId: "team-1", userId: "user-1" },
|
|
606
|
+
{ context }
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
expect(mockDb.insert).toHaveBeenCalled();
|
|
610
|
+
expect(insertedData?.teamId).toBe("team-1");
|
|
611
|
+
expect(insertedData?.userId).toBe("user-1");
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
describe("removeUserFromTeam", () => {
|
|
616
|
+
it("removes user from team", async () => {
|
|
617
|
+
const mockDb = createMockDb();
|
|
618
|
+
|
|
619
|
+
const router = createAuthRouter(
|
|
620
|
+
mockDb,
|
|
621
|
+
mockRegistry,
|
|
622
|
+
async () => {},
|
|
623
|
+
mockConfigService,
|
|
624
|
+
mockAccessRuleRegistry
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
const context = createMockRpcContext({ user: mockAdminUser });
|
|
628
|
+
await call(
|
|
629
|
+
router.removeUserFromTeam,
|
|
630
|
+
{ teamId: "team-1", userId: "user-1" },
|
|
631
|
+
{ context }
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
expect(mockDb.delete).toHaveBeenCalled();
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
// ==========================================================================
|
|
639
|
+
// TEAM MANAGER TESTS
|
|
640
|
+
// ==========================================================================
|
|
641
|
+
|
|
642
|
+
describe("addTeamManager", () => {
|
|
643
|
+
it("grants manager privileges", async () => {
|
|
644
|
+
const mockDb = createMockDb();
|
|
645
|
+
let insertedData: Record<string, unknown> | undefined;
|
|
646
|
+
|
|
647
|
+
(mockDb.insert as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
648
|
+
values: mock((data: Record<string, unknown>) => {
|
|
649
|
+
insertedData = data;
|
|
650
|
+
return {
|
|
651
|
+
onConflictDoNothing: mock(() => Promise.resolve()),
|
|
652
|
+
};
|
|
653
|
+
}),
|
|
654
|
+
}));
|
|
655
|
+
|
|
656
|
+
const router = createAuthRouter(
|
|
657
|
+
mockDb,
|
|
658
|
+
mockRegistry,
|
|
659
|
+
async () => {},
|
|
660
|
+
mockConfigService,
|
|
661
|
+
mockAccessRuleRegistry
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
const context = createMockRpcContext({ user: mockAdminUser });
|
|
665
|
+
await call(
|
|
666
|
+
router.addTeamManager,
|
|
667
|
+
{ teamId: "team-1", userId: "user-1" },
|
|
668
|
+
{ context }
|
|
669
|
+
);
|
|
670
|
+
|
|
671
|
+
expect(mockDb.insert).toHaveBeenCalled();
|
|
672
|
+
expect(insertedData?.teamId).toBe("team-1");
|
|
673
|
+
expect(insertedData?.userId).toBe("user-1");
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
describe("removeTeamManager", () => {
|
|
678
|
+
it("revokes manager privileges", async () => {
|
|
679
|
+
const mockDb = createMockDb();
|
|
680
|
+
|
|
681
|
+
const router = createAuthRouter(
|
|
682
|
+
mockDb,
|
|
683
|
+
mockRegistry,
|
|
684
|
+
async () => {},
|
|
685
|
+
mockConfigService,
|
|
686
|
+
mockAccessRuleRegistry
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
const context = createMockRpcContext({ user: mockAdminUser });
|
|
690
|
+
await call(
|
|
691
|
+
router.removeTeamManager,
|
|
692
|
+
{ teamId: "team-1", userId: "user-1" },
|
|
693
|
+
{ context }
|
|
694
|
+
);
|
|
695
|
+
|
|
696
|
+
expect(mockDb.delete).toHaveBeenCalled();
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
// ==========================================================================
|
|
701
|
+
// RESOURCE ACCESS GRANT TESTS
|
|
702
|
+
// ==========================================================================
|
|
703
|
+
|
|
704
|
+
describe("getResourceTeamAccess", () => {
|
|
705
|
+
it("returns empty array when no grants exist", async () => {
|
|
706
|
+
const mockDb = createMockDb();
|
|
707
|
+
|
|
708
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
709
|
+
from: mock(() => ({
|
|
710
|
+
innerJoin: mock(() => createChain([])),
|
|
711
|
+
})),
|
|
712
|
+
}));
|
|
713
|
+
|
|
714
|
+
const router = createAuthRouter(
|
|
715
|
+
mockDb,
|
|
716
|
+
mockRegistry,
|
|
717
|
+
async () => {},
|
|
718
|
+
mockConfigService,
|
|
719
|
+
mockAccessRuleRegistry
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
const context = createMockRpcContext({ user: mockAdminUser });
|
|
723
|
+
const result = await call(
|
|
724
|
+
router.getResourceTeamAccess,
|
|
725
|
+
{ resourceType: "catalog.system", resourceId: "sys-1" },
|
|
726
|
+
{ context }
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
expect(Array.isArray(result)).toBe(true);
|
|
730
|
+
expect(result).toHaveLength(0);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it("returns grants with team names", async () => {
|
|
734
|
+
const mockDb = createMockDb();
|
|
735
|
+
|
|
736
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
737
|
+
from: mock(() => ({
|
|
738
|
+
innerJoin: mock(() =>
|
|
739
|
+
createChain([
|
|
740
|
+
{
|
|
741
|
+
resource_team_access: {
|
|
742
|
+
teamId: "team-1",
|
|
743
|
+
canRead: true,
|
|
744
|
+
canManage: true,
|
|
745
|
+
},
|
|
746
|
+
team: { name: "Platform Team" },
|
|
747
|
+
},
|
|
748
|
+
{
|
|
749
|
+
resource_team_access: {
|
|
750
|
+
teamId: "team-2",
|
|
751
|
+
canRead: true,
|
|
752
|
+
canManage: false,
|
|
753
|
+
},
|
|
754
|
+
team: { name: "API Team" },
|
|
755
|
+
},
|
|
756
|
+
])
|
|
757
|
+
),
|
|
758
|
+
})),
|
|
759
|
+
}));
|
|
760
|
+
|
|
761
|
+
const router = createAuthRouter(
|
|
762
|
+
mockDb,
|
|
763
|
+
mockRegistry,
|
|
764
|
+
async () => {},
|
|
765
|
+
mockConfigService,
|
|
766
|
+
mockAccessRuleRegistry
|
|
767
|
+
);
|
|
768
|
+
|
|
769
|
+
const context = createMockRpcContext({ user: mockAdminUser });
|
|
770
|
+
const result = await call(
|
|
771
|
+
router.getResourceTeamAccess,
|
|
772
|
+
{ resourceType: "catalog.system", resourceId: "sys-1" },
|
|
773
|
+
{ context }
|
|
774
|
+
);
|
|
775
|
+
|
|
776
|
+
expect(result).toHaveLength(2);
|
|
777
|
+
expect(result[0]).toEqual({
|
|
778
|
+
teamId: "team-1",
|
|
779
|
+
teamName: "Platform Team",
|
|
780
|
+
canRead: true,
|
|
781
|
+
canManage: true,
|
|
782
|
+
});
|
|
783
|
+
expect(result[1]).toEqual({
|
|
784
|
+
teamId: "team-2",
|
|
785
|
+
teamName: "API Team",
|
|
786
|
+
canRead: true,
|
|
787
|
+
canManage: false,
|
|
788
|
+
});
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
describe("setResourceTeamAccess", () => {
|
|
793
|
+
it("creates new grant with default access", async () => {
|
|
794
|
+
const mockDb = createMockDb();
|
|
795
|
+
let insertedData: Record<string, unknown> | undefined;
|
|
796
|
+
|
|
797
|
+
(mockDb.insert as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
798
|
+
values: mock((data: Record<string, unknown>) => {
|
|
799
|
+
insertedData = data;
|
|
800
|
+
return {
|
|
801
|
+
onConflictDoUpdate: mock(() => Promise.resolve()),
|
|
802
|
+
};
|
|
803
|
+
}),
|
|
804
|
+
}));
|
|
805
|
+
|
|
806
|
+
const router = createAuthRouter(
|
|
807
|
+
mockDb,
|
|
808
|
+
mockRegistry,
|
|
809
|
+
async () => {},
|
|
810
|
+
mockConfigService,
|
|
811
|
+
mockAccessRuleRegistry
|
|
812
|
+
);
|
|
813
|
+
|
|
814
|
+
const context = createMockRpcContext({ user: mockAdminUser });
|
|
815
|
+
await call(
|
|
816
|
+
router.setResourceTeamAccess,
|
|
817
|
+
{
|
|
818
|
+
resourceType: "catalog.system",
|
|
819
|
+
resourceId: "sys-1",
|
|
820
|
+
teamId: "team-1",
|
|
821
|
+
},
|
|
822
|
+
{ context }
|
|
823
|
+
);
|
|
824
|
+
|
|
825
|
+
expect(mockDb.insert).toHaveBeenCalled();
|
|
826
|
+
expect(insertedData?.resourceType).toBe("catalog.system");
|
|
827
|
+
expect(insertedData?.resourceId).toBe("sys-1");
|
|
828
|
+
expect(insertedData?.teamId).toBe("team-1");
|
|
829
|
+
expect(insertedData?.canRead).toBe(true);
|
|
830
|
+
expect(insertedData?.canManage).toBe(false);
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it("creates grant with custom access", async () => {
|
|
834
|
+
const mockDb = createMockDb();
|
|
835
|
+
let insertedData: Record<string, unknown> | undefined;
|
|
836
|
+
|
|
837
|
+
(mockDb.insert as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
838
|
+
values: mock((data: Record<string, unknown>) => {
|
|
839
|
+
insertedData = data;
|
|
840
|
+
return {
|
|
841
|
+
onConflictDoUpdate: mock(() => Promise.resolve()),
|
|
842
|
+
};
|
|
843
|
+
}),
|
|
844
|
+
}));
|
|
845
|
+
|
|
846
|
+
const router = createAuthRouter(
|
|
847
|
+
mockDb,
|
|
848
|
+
mockRegistry,
|
|
849
|
+
async () => {},
|
|
850
|
+
mockConfigService,
|
|
851
|
+
mockAccessRuleRegistry
|
|
852
|
+
);
|
|
853
|
+
|
|
854
|
+
const context = createMockRpcContext({ user: mockAdminUser });
|
|
855
|
+
await call(
|
|
856
|
+
router.setResourceTeamAccess,
|
|
857
|
+
{
|
|
858
|
+
resourceType: "catalog.system",
|
|
859
|
+
resourceId: "sys-1",
|
|
860
|
+
teamId: "team-1",
|
|
861
|
+
canRead: true,
|
|
862
|
+
canManage: true,
|
|
863
|
+
},
|
|
864
|
+
{ context }
|
|
865
|
+
);
|
|
866
|
+
|
|
867
|
+
expect(insertedData?.canRead).toBe(true);
|
|
868
|
+
expect(insertedData?.canManage).toBe(true);
|
|
869
|
+
});
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
describe("removeResourceTeamAccess", () => {
|
|
873
|
+
it("removes grant for specific team", async () => {
|
|
874
|
+
const mockDb = createMockDb();
|
|
875
|
+
|
|
876
|
+
const router = createAuthRouter(
|
|
877
|
+
mockDb,
|
|
878
|
+
mockRegistry,
|
|
879
|
+
async () => {},
|
|
880
|
+
mockConfigService,
|
|
881
|
+
mockAccessRuleRegistry
|
|
882
|
+
);
|
|
883
|
+
|
|
884
|
+
const context = createMockRpcContext({ user: mockAdminUser });
|
|
885
|
+
await call(
|
|
886
|
+
router.removeResourceTeamAccess,
|
|
887
|
+
{
|
|
888
|
+
resourceType: "catalog.system",
|
|
889
|
+
resourceId: "sys-1",
|
|
890
|
+
teamId: "team-1",
|
|
891
|
+
},
|
|
892
|
+
{ context }
|
|
893
|
+
);
|
|
894
|
+
|
|
895
|
+
expect(mockDb.delete).toHaveBeenCalled();
|
|
896
|
+
});
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
// ==========================================================================
|
|
900
|
+
// S2S ACCESS CHECK TESTS
|
|
901
|
+
// ==========================================================================
|
|
902
|
+
|
|
903
|
+
describe("checkResourceTeamAccess (S2S)", () => {
|
|
904
|
+
it("allows access when no grants exist and user has global access", async () => {
|
|
905
|
+
const mockDb = createMockDb();
|
|
906
|
+
|
|
907
|
+
// No grants exist
|
|
908
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
909
|
+
from: mock(() => createChain([])),
|
|
910
|
+
}));
|
|
911
|
+
|
|
912
|
+
const router = createAuthRouter(
|
|
913
|
+
mockDb,
|
|
914
|
+
mockRegistry,
|
|
915
|
+
async () => {},
|
|
916
|
+
mockConfigService,
|
|
917
|
+
mockAccessRuleRegistry
|
|
918
|
+
);
|
|
919
|
+
|
|
920
|
+
const context = createMockRpcContext({ user: mockServiceUser });
|
|
921
|
+
const result = await call(
|
|
922
|
+
router.checkResourceTeamAccess,
|
|
923
|
+
{
|
|
924
|
+
userId: "user-1",
|
|
925
|
+
userType: "user",
|
|
926
|
+
resourceType: "catalog.system",
|
|
927
|
+
resourceId: "sys-1",
|
|
928
|
+
action: "read",
|
|
929
|
+
hasGlobalAccess: true,
|
|
930
|
+
},
|
|
931
|
+
{ context }
|
|
932
|
+
);
|
|
933
|
+
|
|
934
|
+
expect(result.hasAccess).toBe(true);
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
it("denies access when no grants exist and user lacks global access", async () => {
|
|
938
|
+
const mockDb = createMockDb();
|
|
939
|
+
|
|
940
|
+
// No grants exist
|
|
941
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
942
|
+
from: mock(() => createChain([])),
|
|
943
|
+
}));
|
|
944
|
+
|
|
945
|
+
const router = createAuthRouter(
|
|
946
|
+
mockDb,
|
|
947
|
+
mockRegistry,
|
|
948
|
+
async () => {},
|
|
949
|
+
mockConfigService,
|
|
950
|
+
mockAccessRuleRegistry
|
|
951
|
+
);
|
|
952
|
+
|
|
953
|
+
const context = createMockRpcContext({ user: mockServiceUser });
|
|
954
|
+
const result = await call(
|
|
955
|
+
router.checkResourceTeamAccess,
|
|
956
|
+
{
|
|
957
|
+
userId: "user-1",
|
|
958
|
+
userType: "user",
|
|
959
|
+
resourceType: "catalog.system",
|
|
960
|
+
resourceId: "sys-1",
|
|
961
|
+
action: "read",
|
|
962
|
+
hasGlobalAccess: false,
|
|
963
|
+
},
|
|
964
|
+
{ context }
|
|
965
|
+
);
|
|
966
|
+
|
|
967
|
+
expect(result.hasAccess).toBe(false);
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
it("allows access when user's team has grant with canRead", async () => {
|
|
971
|
+
const mockDb = createMockDb();
|
|
972
|
+
|
|
973
|
+
// Grant exists for team-1
|
|
974
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
975
|
+
from: mock(() =>
|
|
976
|
+
createChain([
|
|
977
|
+
{
|
|
978
|
+
teamId: "team-1",
|
|
979
|
+
canRead: true,
|
|
980
|
+
canManage: false,
|
|
981
|
+
},
|
|
982
|
+
])
|
|
983
|
+
),
|
|
984
|
+
}));
|
|
985
|
+
|
|
986
|
+
// Settings query - returns empty (teamOnly = false by default)
|
|
987
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
988
|
+
from: mock(() => createChain([])),
|
|
989
|
+
}));
|
|
990
|
+
|
|
991
|
+
// User is member of team-1
|
|
992
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
993
|
+
from: mock(() => createChain([{ teamId: "team-1" }])),
|
|
994
|
+
}));
|
|
995
|
+
|
|
996
|
+
const router = createAuthRouter(
|
|
997
|
+
mockDb,
|
|
998
|
+
mockRegistry,
|
|
999
|
+
async () => {},
|
|
1000
|
+
mockConfigService,
|
|
1001
|
+
mockAccessRuleRegistry
|
|
1002
|
+
);
|
|
1003
|
+
|
|
1004
|
+
const context = createMockRpcContext({ user: mockServiceUser });
|
|
1005
|
+
const result = await call(
|
|
1006
|
+
router.checkResourceTeamAccess,
|
|
1007
|
+
{
|
|
1008
|
+
userId: "user-1",
|
|
1009
|
+
userType: "user",
|
|
1010
|
+
resourceType: "catalog.system",
|
|
1011
|
+
resourceId: "sys-1",
|
|
1012
|
+
action: "read",
|
|
1013
|
+
hasGlobalAccess: false,
|
|
1014
|
+
},
|
|
1015
|
+
{ context }
|
|
1016
|
+
);
|
|
1017
|
+
|
|
1018
|
+
expect(result.hasAccess).toBe(true);
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
it("denies access when user's team has grant but lacks canManage for manage action", async () => {
|
|
1022
|
+
const mockDb = createMockDb();
|
|
1023
|
+
|
|
1024
|
+
// Grant exists for team-1 with only read access
|
|
1025
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1026
|
+
from: mock(() =>
|
|
1027
|
+
createChain([
|
|
1028
|
+
{
|
|
1029
|
+
teamId: "team-1",
|
|
1030
|
+
canRead: true,
|
|
1031
|
+
canManage: false,
|
|
1032
|
+
},
|
|
1033
|
+
])
|
|
1034
|
+
),
|
|
1035
|
+
}));
|
|
1036
|
+
|
|
1037
|
+
// Settings query - returns empty (teamOnly = false by default)
|
|
1038
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1039
|
+
from: mock(() => createChain([])),
|
|
1040
|
+
}));
|
|
1041
|
+
|
|
1042
|
+
// User is member of team-1
|
|
1043
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1044
|
+
from: mock(() => createChain([{ teamId: "team-1" }])),
|
|
1045
|
+
}));
|
|
1046
|
+
|
|
1047
|
+
const router = createAuthRouter(
|
|
1048
|
+
mockDb,
|
|
1049
|
+
mockRegistry,
|
|
1050
|
+
async () => {},
|
|
1051
|
+
mockConfigService,
|
|
1052
|
+
mockAccessRuleRegistry
|
|
1053
|
+
);
|
|
1054
|
+
|
|
1055
|
+
const context = createMockRpcContext({ user: mockServiceUser });
|
|
1056
|
+
const result = await call(
|
|
1057
|
+
router.checkResourceTeamAccess,
|
|
1058
|
+
{
|
|
1059
|
+
userId: "user-1",
|
|
1060
|
+
userType: "user",
|
|
1061
|
+
resourceType: "catalog.system",
|
|
1062
|
+
resourceId: "sys-1",
|
|
1063
|
+
action: "manage",
|
|
1064
|
+
hasGlobalAccess: false,
|
|
1065
|
+
},
|
|
1066
|
+
{ context }
|
|
1067
|
+
);
|
|
1068
|
+
|
|
1069
|
+
expect(result.hasAccess).toBe(false);
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
it("allows access for teamOnly resource when user is in granted team", async () => {
|
|
1073
|
+
const mockDb = createMockDb();
|
|
1074
|
+
|
|
1075
|
+
// Grant exists for team-1
|
|
1076
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1077
|
+
from: mock(() =>
|
|
1078
|
+
createChain([
|
|
1079
|
+
{
|
|
1080
|
+
teamId: "team-1",
|
|
1081
|
+
canRead: true,
|
|
1082
|
+
canManage: false,
|
|
1083
|
+
},
|
|
1084
|
+
])
|
|
1085
|
+
),
|
|
1086
|
+
}));
|
|
1087
|
+
|
|
1088
|
+
// Settings query - returns teamOnly = true
|
|
1089
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1090
|
+
from: mock(() =>
|
|
1091
|
+
createChain([{ teamOnly: true, resourceId: "sys-1" }])
|
|
1092
|
+
),
|
|
1093
|
+
}));
|
|
1094
|
+
|
|
1095
|
+
// User is member of team-1
|
|
1096
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1097
|
+
from: mock(() => createChain([{ teamId: "team-1" }])),
|
|
1098
|
+
}));
|
|
1099
|
+
|
|
1100
|
+
const router = createAuthRouter(
|
|
1101
|
+
mockDb,
|
|
1102
|
+
mockRegistry,
|
|
1103
|
+
async () => {},
|
|
1104
|
+
mockConfigService,
|
|
1105
|
+
mockAccessRuleRegistry
|
|
1106
|
+
);
|
|
1107
|
+
|
|
1108
|
+
const context = createMockRpcContext({ user: mockServiceUser });
|
|
1109
|
+
const result = await call(
|
|
1110
|
+
router.checkResourceTeamAccess,
|
|
1111
|
+
{
|
|
1112
|
+
userId: "user-1",
|
|
1113
|
+
userType: "user",
|
|
1114
|
+
resourceType: "catalog.system",
|
|
1115
|
+
resourceId: "sys-1",
|
|
1116
|
+
action: "read",
|
|
1117
|
+
hasGlobalAccess: true, // Global access doesn't help with teamOnly
|
|
1118
|
+
},
|
|
1119
|
+
{ context }
|
|
1120
|
+
);
|
|
1121
|
+
|
|
1122
|
+
expect(result.hasAccess).toBe(true);
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
it("denies access for teamOnly resource when user is not in granted team", async () => {
|
|
1126
|
+
const mockDb = createMockDb();
|
|
1127
|
+
|
|
1128
|
+
// Grant exists for team-1
|
|
1129
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1130
|
+
from: mock(() =>
|
|
1131
|
+
createChain([
|
|
1132
|
+
{
|
|
1133
|
+
teamId: "team-1",
|
|
1134
|
+
canRead: true,
|
|
1135
|
+
canManage: false,
|
|
1136
|
+
},
|
|
1137
|
+
])
|
|
1138
|
+
),
|
|
1139
|
+
}));
|
|
1140
|
+
|
|
1141
|
+
// Settings query - returns teamOnly = true
|
|
1142
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1143
|
+
from: mock(() =>
|
|
1144
|
+
createChain([{ teamOnly: true, resourceId: "sys-1" }])
|
|
1145
|
+
),
|
|
1146
|
+
}));
|
|
1147
|
+
|
|
1148
|
+
// User is member of team-2 (not team-1)
|
|
1149
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1150
|
+
from: mock(() => createChain([{ teamId: "team-2" }])),
|
|
1151
|
+
}));
|
|
1152
|
+
|
|
1153
|
+
const router = createAuthRouter(
|
|
1154
|
+
mockDb,
|
|
1155
|
+
mockRegistry,
|
|
1156
|
+
async () => {},
|
|
1157
|
+
mockConfigService,
|
|
1158
|
+
mockAccessRuleRegistry
|
|
1159
|
+
);
|
|
1160
|
+
|
|
1161
|
+
const context = createMockRpcContext({ user: mockServiceUser });
|
|
1162
|
+
const result = await call(
|
|
1163
|
+
router.checkResourceTeamAccess,
|
|
1164
|
+
{
|
|
1165
|
+
userId: "user-1",
|
|
1166
|
+
userType: "user",
|
|
1167
|
+
resourceType: "catalog.system",
|
|
1168
|
+
resourceId: "sys-1",
|
|
1169
|
+
action: "read",
|
|
1170
|
+
hasGlobalAccess: true, // Global access doesn't help with teamOnly
|
|
1171
|
+
},
|
|
1172
|
+
{ context }
|
|
1173
|
+
);
|
|
1174
|
+
|
|
1175
|
+
expect(result.hasAccess).toBe(false);
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
it("allows manage access when user's team has canManage grant", async () => {
|
|
1179
|
+
const mockDb = createMockDb();
|
|
1180
|
+
|
|
1181
|
+
// Grant exists for team-1 with canManage
|
|
1182
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1183
|
+
from: mock(() =>
|
|
1184
|
+
createChain([
|
|
1185
|
+
{
|
|
1186
|
+
teamId: "team-1",
|
|
1187
|
+
canRead: true,
|
|
1188
|
+
canManage: true,
|
|
1189
|
+
},
|
|
1190
|
+
])
|
|
1191
|
+
),
|
|
1192
|
+
}));
|
|
1193
|
+
|
|
1194
|
+
// Settings query - returns empty (teamOnly = false by default)
|
|
1195
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1196
|
+
from: mock(() => createChain([])),
|
|
1197
|
+
}));
|
|
1198
|
+
|
|
1199
|
+
// User is member of team-1
|
|
1200
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1201
|
+
from: mock(() => createChain([{ teamId: "team-1" }])),
|
|
1202
|
+
}));
|
|
1203
|
+
|
|
1204
|
+
const router = createAuthRouter(
|
|
1205
|
+
mockDb,
|
|
1206
|
+
mockRegistry,
|
|
1207
|
+
async () => {},
|
|
1208
|
+
mockConfigService,
|
|
1209
|
+
mockAccessRuleRegistry
|
|
1210
|
+
);
|
|
1211
|
+
|
|
1212
|
+
const context = createMockRpcContext({ user: mockServiceUser });
|
|
1213
|
+
const result = await call(
|
|
1214
|
+
router.checkResourceTeamAccess,
|
|
1215
|
+
{
|
|
1216
|
+
userId: "user-1",
|
|
1217
|
+
userType: "user",
|
|
1218
|
+
resourceType: "catalog.system",
|
|
1219
|
+
resourceId: "sys-1",
|
|
1220
|
+
action: "manage",
|
|
1221
|
+
hasGlobalAccess: false,
|
|
1222
|
+
},
|
|
1223
|
+
{ context }
|
|
1224
|
+
);
|
|
1225
|
+
|
|
1226
|
+
expect(result.hasAccess).toBe(true);
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
it("allows access via global access when grants exist but resource is not teamOnly", async () => {
|
|
1230
|
+
const mockDb = createMockDb();
|
|
1231
|
+
|
|
1232
|
+
// Grant exists for team-1 but user is not in team-1
|
|
1233
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1234
|
+
from: mock(() =>
|
|
1235
|
+
createChain([
|
|
1236
|
+
{
|
|
1237
|
+
teamId: "team-1",
|
|
1238
|
+
canRead: true,
|
|
1239
|
+
canManage: false,
|
|
1240
|
+
},
|
|
1241
|
+
])
|
|
1242
|
+
),
|
|
1243
|
+
}));
|
|
1244
|
+
|
|
1245
|
+
// Settings query - returns empty (teamOnly = false by default)
|
|
1246
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1247
|
+
from: mock(() => createChain([])),
|
|
1248
|
+
}));
|
|
1249
|
+
|
|
1250
|
+
const router = createAuthRouter(
|
|
1251
|
+
mockDb,
|
|
1252
|
+
mockRegistry,
|
|
1253
|
+
async () => {},
|
|
1254
|
+
mockConfigService,
|
|
1255
|
+
mockAccessRuleRegistry
|
|
1256
|
+
);
|
|
1257
|
+
|
|
1258
|
+
const context = createMockRpcContext({ user: mockServiceUser });
|
|
1259
|
+
const result = await call(
|
|
1260
|
+
router.checkResourceTeamAccess,
|
|
1261
|
+
{
|
|
1262
|
+
userId: "user-1",
|
|
1263
|
+
userType: "user",
|
|
1264
|
+
resourceType: "catalog.system",
|
|
1265
|
+
resourceId: "sys-1",
|
|
1266
|
+
action: "read",
|
|
1267
|
+
hasGlobalAccess: true, // User has global access
|
|
1268
|
+
},
|
|
1269
|
+
{ context }
|
|
1270
|
+
);
|
|
1271
|
+
|
|
1272
|
+
expect(result.hasAccess).toBe(true);
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
it("denies access when user is not in any team and lacks global access", async () => {
|
|
1276
|
+
const mockDb = createMockDb();
|
|
1277
|
+
|
|
1278
|
+
// Grant exists for team-1
|
|
1279
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1280
|
+
from: mock(() =>
|
|
1281
|
+
createChain([
|
|
1282
|
+
{
|
|
1283
|
+
teamId: "team-1",
|
|
1284
|
+
canRead: true,
|
|
1285
|
+
canManage: false,
|
|
1286
|
+
},
|
|
1287
|
+
])
|
|
1288
|
+
),
|
|
1289
|
+
}));
|
|
1290
|
+
|
|
1291
|
+
// Settings query - returns empty (teamOnly = false by default)
|
|
1292
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1293
|
+
from: mock(() => createChain([])),
|
|
1294
|
+
}));
|
|
1295
|
+
|
|
1296
|
+
// User is not in any team
|
|
1297
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1298
|
+
from: mock(() => createChain([])),
|
|
1299
|
+
}));
|
|
1300
|
+
|
|
1301
|
+
const router = createAuthRouter(
|
|
1302
|
+
mockDb,
|
|
1303
|
+
mockRegistry,
|
|
1304
|
+
async () => {},
|
|
1305
|
+
mockConfigService,
|
|
1306
|
+
mockAccessRuleRegistry
|
|
1307
|
+
);
|
|
1308
|
+
|
|
1309
|
+
const context = createMockRpcContext({ user: mockServiceUser });
|
|
1310
|
+
const result = await call(
|
|
1311
|
+
router.checkResourceTeamAccess,
|
|
1312
|
+
{
|
|
1313
|
+
userId: "user-1",
|
|
1314
|
+
userType: "user",
|
|
1315
|
+
resourceType: "catalog.system",
|
|
1316
|
+
resourceId: "sys-1",
|
|
1317
|
+
action: "read",
|
|
1318
|
+
hasGlobalAccess: false,
|
|
1319
|
+
},
|
|
1320
|
+
{ context }
|
|
1321
|
+
);
|
|
1322
|
+
|
|
1323
|
+
expect(result.hasAccess).toBe(false);
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
it("allows access when user is in multiple teams and one has the required grant", async () => {
|
|
1327
|
+
const mockDb = createMockDb();
|
|
1328
|
+
|
|
1329
|
+
// Grant exists for team-2 only
|
|
1330
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1331
|
+
from: mock(() =>
|
|
1332
|
+
createChain([
|
|
1333
|
+
{
|
|
1334
|
+
teamId: "team-2",
|
|
1335
|
+
canRead: true,
|
|
1336
|
+
canManage: false,
|
|
1337
|
+
},
|
|
1338
|
+
])
|
|
1339
|
+
),
|
|
1340
|
+
}));
|
|
1341
|
+
|
|
1342
|
+
// Settings query - returns empty (teamOnly = false by default)
|
|
1343
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1344
|
+
from: mock(() => createChain([])),
|
|
1345
|
+
}));
|
|
1346
|
+
|
|
1347
|
+
// User is member of team-1 AND team-2
|
|
1348
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1349
|
+
from: mock(() =>
|
|
1350
|
+
createChain([{ teamId: "team-1" }, { teamId: "team-2" }])
|
|
1351
|
+
),
|
|
1352
|
+
}));
|
|
1353
|
+
|
|
1354
|
+
const router = createAuthRouter(
|
|
1355
|
+
mockDb,
|
|
1356
|
+
mockRegistry,
|
|
1357
|
+
async () => {},
|
|
1358
|
+
mockConfigService,
|
|
1359
|
+
mockAccessRuleRegistry
|
|
1360
|
+
);
|
|
1361
|
+
|
|
1362
|
+
const context = createMockRpcContext({ user: mockServiceUser });
|
|
1363
|
+
const result = await call(
|
|
1364
|
+
router.checkResourceTeamAccess,
|
|
1365
|
+
{
|
|
1366
|
+
userId: "user-1",
|
|
1367
|
+
userType: "user",
|
|
1368
|
+
resourceType: "catalog.system",
|
|
1369
|
+
resourceId: "sys-1",
|
|
1370
|
+
action: "read",
|
|
1371
|
+
hasGlobalAccess: false,
|
|
1372
|
+
},
|
|
1373
|
+
{ context }
|
|
1374
|
+
);
|
|
1375
|
+
|
|
1376
|
+
expect(result.hasAccess).toBe(true);
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
it("allows access when resource has grants from multiple teams and user is in one of them", async () => {
|
|
1380
|
+
const mockDb = createMockDb();
|
|
1381
|
+
|
|
1382
|
+
// Grants exist for team-1 and team-2
|
|
1383
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1384
|
+
from: mock(() =>
|
|
1385
|
+
createChain([
|
|
1386
|
+
{
|
|
1387
|
+
teamId: "team-1",
|
|
1388
|
+
canRead: true,
|
|
1389
|
+
canManage: false,
|
|
1390
|
+
},
|
|
1391
|
+
{
|
|
1392
|
+
teamId: "team-2",
|
|
1393
|
+
canRead: true,
|
|
1394
|
+
canManage: true,
|
|
1395
|
+
},
|
|
1396
|
+
])
|
|
1397
|
+
),
|
|
1398
|
+
}));
|
|
1399
|
+
|
|
1400
|
+
// Settings query - teamOnly = true
|
|
1401
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1402
|
+
from: mock(() =>
|
|
1403
|
+
createChain([{ teamOnly: true, resourceId: "sys-1" }])
|
|
1404
|
+
),
|
|
1405
|
+
}));
|
|
1406
|
+
|
|
1407
|
+
// User is member of team-2 only
|
|
1408
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1409
|
+
from: mock(() => createChain([{ teamId: "team-2" }])),
|
|
1410
|
+
}));
|
|
1411
|
+
|
|
1412
|
+
const router = createAuthRouter(
|
|
1413
|
+
mockDb,
|
|
1414
|
+
mockRegistry,
|
|
1415
|
+
async () => {},
|
|
1416
|
+
mockConfigService,
|
|
1417
|
+
mockAccessRuleRegistry
|
|
1418
|
+
);
|
|
1419
|
+
|
|
1420
|
+
const context = createMockRpcContext({ user: mockServiceUser });
|
|
1421
|
+
const result = await call(
|
|
1422
|
+
router.checkResourceTeamAccess,
|
|
1423
|
+
{
|
|
1424
|
+
userId: "user-1",
|
|
1425
|
+
userType: "user",
|
|
1426
|
+
resourceType: "catalog.system",
|
|
1427
|
+
resourceId: "sys-1",
|
|
1428
|
+
action: "manage",
|
|
1429
|
+
hasGlobalAccess: false,
|
|
1430
|
+
},
|
|
1431
|
+
{ context }
|
|
1432
|
+
);
|
|
1433
|
+
|
|
1434
|
+
expect(result.hasAccess).toBe(true);
|
|
1435
|
+
});
|
|
1436
|
+
|
|
1437
|
+
it("allows access for application user with proper team grant", async () => {
|
|
1438
|
+
const mockDb = createMockDb();
|
|
1439
|
+
|
|
1440
|
+
// Grant exists for team-1
|
|
1441
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1442
|
+
from: mock(() =>
|
|
1443
|
+
createChain([
|
|
1444
|
+
{
|
|
1445
|
+
teamId: "team-1",
|
|
1446
|
+
canRead: true,
|
|
1447
|
+
canManage: false,
|
|
1448
|
+
},
|
|
1449
|
+
])
|
|
1450
|
+
),
|
|
1451
|
+
}));
|
|
1452
|
+
|
|
1453
|
+
// Settings query - returns empty (teamOnly = false by default)
|
|
1454
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1455
|
+
from: mock(() => createChain([])),
|
|
1456
|
+
}));
|
|
1457
|
+
|
|
1458
|
+
// Application is member of team-1
|
|
1459
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1460
|
+
from: mock(() => createChain([{ teamId: "team-1" }])),
|
|
1461
|
+
}));
|
|
1462
|
+
|
|
1463
|
+
const router = createAuthRouter(
|
|
1464
|
+
mockDb,
|
|
1465
|
+
mockRegistry,
|
|
1466
|
+
async () => {},
|
|
1467
|
+
mockConfigService,
|
|
1468
|
+
mockAccessRuleRegistry
|
|
1469
|
+
);
|
|
1470
|
+
|
|
1471
|
+
const context = createMockRpcContext({ user: mockServiceUser });
|
|
1472
|
+
const result = await call(
|
|
1473
|
+
router.checkResourceTeamAccess,
|
|
1474
|
+
{
|
|
1475
|
+
userId: "app-1",
|
|
1476
|
+
userType: "application",
|
|
1477
|
+
resourceType: "catalog.system",
|
|
1478
|
+
resourceId: "sys-1",
|
|
1479
|
+
action: "read",
|
|
1480
|
+
hasGlobalAccess: false,
|
|
1481
|
+
},
|
|
1482
|
+
{ context }
|
|
1483
|
+
);
|
|
1484
|
+
|
|
1485
|
+
expect(result.hasAccess).toBe(true);
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
it("denies access for application user when not in granted team", async () => {
|
|
1489
|
+
const mockDb = createMockDb();
|
|
1490
|
+
|
|
1491
|
+
// Grant exists for team-1
|
|
1492
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1493
|
+
from: mock(() =>
|
|
1494
|
+
createChain([
|
|
1495
|
+
{
|
|
1496
|
+
teamId: "team-1",
|
|
1497
|
+
canRead: true,
|
|
1498
|
+
canManage: false,
|
|
1499
|
+
},
|
|
1500
|
+
])
|
|
1501
|
+
),
|
|
1502
|
+
}));
|
|
1503
|
+
|
|
1504
|
+
// Settings query - teamOnly = true
|
|
1505
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1506
|
+
from: mock(() =>
|
|
1507
|
+
createChain([{ teamOnly: true, resourceId: "sys-1" }])
|
|
1508
|
+
),
|
|
1509
|
+
}));
|
|
1510
|
+
|
|
1511
|
+
// Application is member of team-2 (not team-1)
|
|
1512
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1513
|
+
from: mock(() => createChain([{ teamId: "team-2" }])),
|
|
1514
|
+
}));
|
|
1515
|
+
|
|
1516
|
+
const router = createAuthRouter(
|
|
1517
|
+
mockDb,
|
|
1518
|
+
mockRegistry,
|
|
1519
|
+
async () => {},
|
|
1520
|
+
mockConfigService,
|
|
1521
|
+
mockAccessRuleRegistry
|
|
1522
|
+
);
|
|
1523
|
+
|
|
1524
|
+
const context = createMockRpcContext({ user: mockServiceUser });
|
|
1525
|
+
const result = await call(
|
|
1526
|
+
router.checkResourceTeamAccess,
|
|
1527
|
+
{
|
|
1528
|
+
userId: "app-1",
|
|
1529
|
+
userType: "application",
|
|
1530
|
+
resourceType: "catalog.system",
|
|
1531
|
+
resourceId: "sys-1",
|
|
1532
|
+
action: "read",
|
|
1533
|
+
hasGlobalAccess: true,
|
|
1534
|
+
},
|
|
1535
|
+
{ context }
|
|
1536
|
+
);
|
|
1537
|
+
|
|
1538
|
+
expect(result.hasAccess).toBe(false);
|
|
1539
|
+
});
|
|
1540
|
+
|
|
1541
|
+
it("denies read access when user is in team but grant only has canManage (no canRead)", async () => {
|
|
1542
|
+
const mockDb = createMockDb();
|
|
1543
|
+
|
|
1544
|
+
// Grant exists for team-1 with only canManage (canRead = false)
|
|
1545
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1546
|
+
from: mock(() =>
|
|
1547
|
+
createChain([
|
|
1548
|
+
{
|
|
1549
|
+
teamId: "team-1",
|
|
1550
|
+
canRead: false,
|
|
1551
|
+
canManage: true,
|
|
1552
|
+
},
|
|
1553
|
+
])
|
|
1554
|
+
),
|
|
1555
|
+
}));
|
|
1556
|
+
|
|
1557
|
+
// Settings query - teamOnly = true
|
|
1558
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1559
|
+
from: mock(() =>
|
|
1560
|
+
createChain([{ teamOnly: true, resourceId: "sys-1" }])
|
|
1561
|
+
),
|
|
1562
|
+
}));
|
|
1563
|
+
|
|
1564
|
+
// User is member of team-1
|
|
1565
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1566
|
+
from: mock(() => createChain([{ teamId: "team-1" }])),
|
|
1567
|
+
}));
|
|
1568
|
+
|
|
1569
|
+
const router = createAuthRouter(
|
|
1570
|
+
mockDb,
|
|
1571
|
+
mockRegistry,
|
|
1572
|
+
async () => {},
|
|
1573
|
+
mockConfigService,
|
|
1574
|
+
mockAccessRuleRegistry
|
|
1575
|
+
);
|
|
1576
|
+
|
|
1577
|
+
const context = createMockRpcContext({ user: mockServiceUser });
|
|
1578
|
+
const result = await call(
|
|
1579
|
+
router.checkResourceTeamAccess,
|
|
1580
|
+
{
|
|
1581
|
+
userId: "user-1",
|
|
1582
|
+
userType: "user",
|
|
1583
|
+
resourceType: "catalog.system",
|
|
1584
|
+
resourceId: "sys-1",
|
|
1585
|
+
action: "read",
|
|
1586
|
+
hasGlobalAccess: false,
|
|
1587
|
+
},
|
|
1588
|
+
{ context }
|
|
1589
|
+
);
|
|
1590
|
+
|
|
1591
|
+
expect(result.hasAccess).toBe(false);
|
|
1592
|
+
});
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1595
|
+
describe("getAccessibleResourceIds (S2S)", () => {
|
|
1596
|
+
it("returns empty array for empty input", async () => {
|
|
1597
|
+
const mockDb = createMockDb();
|
|
1598
|
+
|
|
1599
|
+
const router = createAuthRouter(
|
|
1600
|
+
mockDb,
|
|
1601
|
+
mockRegistry,
|
|
1602
|
+
async () => {},
|
|
1603
|
+
mockConfigService,
|
|
1604
|
+
mockAccessRuleRegistry
|
|
1605
|
+
);
|
|
1606
|
+
|
|
1607
|
+
const context = createMockRpcContext({ user: mockServiceUser });
|
|
1608
|
+
const result = await call(
|
|
1609
|
+
router.getAccessibleResourceIds,
|
|
1610
|
+
{
|
|
1611
|
+
userId: "user-1",
|
|
1612
|
+
userType: "user",
|
|
1613
|
+
resourceType: "catalog.system",
|
|
1614
|
+
resourceIds: [],
|
|
1615
|
+
action: "read",
|
|
1616
|
+
hasGlobalAccess: true,
|
|
1617
|
+
},
|
|
1618
|
+
{ context }
|
|
1619
|
+
);
|
|
1620
|
+
|
|
1621
|
+
expect(result).toEqual([]);
|
|
1622
|
+
});
|
|
1623
|
+
|
|
1624
|
+
it("returns all resources when no grants exist and user has global access", async () => {
|
|
1625
|
+
const mockDb = createMockDb();
|
|
1626
|
+
|
|
1627
|
+
// No grants exist
|
|
1628
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1629
|
+
from: mock(() => createChain([])),
|
|
1630
|
+
}));
|
|
1631
|
+
|
|
1632
|
+
// User teams (not used when no grants)
|
|
1633
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1634
|
+
from: mock(() => createChain([{ teamId: "team-1" }])),
|
|
1635
|
+
}));
|
|
1636
|
+
|
|
1637
|
+
const router = createAuthRouter(
|
|
1638
|
+
mockDb,
|
|
1639
|
+
mockRegistry,
|
|
1640
|
+
async () => {},
|
|
1641
|
+
mockConfigService,
|
|
1642
|
+
mockAccessRuleRegistry
|
|
1643
|
+
);
|
|
1644
|
+
|
|
1645
|
+
const context = createMockRpcContext({ user: mockServiceUser });
|
|
1646
|
+
const result = await call(
|
|
1647
|
+
router.getAccessibleResourceIds,
|
|
1648
|
+
{
|
|
1649
|
+
userId: "user-1",
|
|
1650
|
+
userType: "user",
|
|
1651
|
+
resourceType: "catalog.system",
|
|
1652
|
+
resourceIds: ["sys-1", "sys-2", "sys-3"],
|
|
1653
|
+
action: "read",
|
|
1654
|
+
hasGlobalAccess: true,
|
|
1655
|
+
},
|
|
1656
|
+
{ context }
|
|
1657
|
+
);
|
|
1658
|
+
|
|
1659
|
+
expect(result).toEqual(["sys-1", "sys-2", "sys-3"]);
|
|
1660
|
+
});
|
|
1661
|
+
|
|
1662
|
+
it("filters resources based on team grants", async () => {
|
|
1663
|
+
const mockDb = createMockDb();
|
|
1664
|
+
|
|
1665
|
+
// Grants exist for sys-1 (team-1) and sys-2 (team-2)
|
|
1666
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1667
|
+
from: mock(() =>
|
|
1668
|
+
createChain([
|
|
1669
|
+
{
|
|
1670
|
+
resourceId: "sys-1",
|
|
1671
|
+
teamId: "team-1",
|
|
1672
|
+
canRead: true,
|
|
1673
|
+
canManage: false,
|
|
1674
|
+
},
|
|
1675
|
+
{
|
|
1676
|
+
resourceId: "sys-2",
|
|
1677
|
+
teamId: "team-2",
|
|
1678
|
+
canRead: true,
|
|
1679
|
+
canManage: false,
|
|
1680
|
+
},
|
|
1681
|
+
])
|
|
1682
|
+
),
|
|
1683
|
+
}));
|
|
1684
|
+
|
|
1685
|
+
// Settings query - both sys-1 and sys-2 are teamOnly
|
|
1686
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1687
|
+
from: mock(() =>
|
|
1688
|
+
createChain([
|
|
1689
|
+
{ resourceId: "sys-1", teamOnly: true },
|
|
1690
|
+
{ resourceId: "sys-2", teamOnly: true },
|
|
1691
|
+
])
|
|
1692
|
+
),
|
|
1693
|
+
}));
|
|
1694
|
+
|
|
1695
|
+
// User is member of team-1 only
|
|
1696
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1697
|
+
from: mock(() => createChain([{ teamId: "team-1" }])),
|
|
1698
|
+
}));
|
|
1699
|
+
|
|
1700
|
+
const router = createAuthRouter(
|
|
1701
|
+
mockDb,
|
|
1702
|
+
mockRegistry,
|
|
1703
|
+
async () => {},
|
|
1704
|
+
mockConfigService,
|
|
1705
|
+
mockAccessRuleRegistry
|
|
1706
|
+
);
|
|
1707
|
+
|
|
1708
|
+
const context = createMockRpcContext({ user: mockServiceUser });
|
|
1709
|
+
const result = await call(
|
|
1710
|
+
router.getAccessibleResourceIds,
|
|
1711
|
+
{
|
|
1712
|
+
userId: "user-1",
|
|
1713
|
+
userType: "user",
|
|
1714
|
+
resourceType: "catalog.system",
|
|
1715
|
+
resourceIds: ["sys-1", "sys-2", "sys-3"],
|
|
1716
|
+
action: "read",
|
|
1717
|
+
hasGlobalAccess: true,
|
|
1718
|
+
},
|
|
1719
|
+
{ context }
|
|
1720
|
+
);
|
|
1721
|
+
|
|
1722
|
+
// sys-1: user is in team-1, granted
|
|
1723
|
+
// sys-2: user is not in team-2, denied (teamOnly)
|
|
1724
|
+
// sys-3: no grants, allowed by global access
|
|
1725
|
+
expect(result).toContain("sys-1");
|
|
1726
|
+
expect(result).not.toContain("sys-2");
|
|
1727
|
+
expect(result).toContain("sys-3");
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1730
|
+
it("returns no resources when user lacks global access and has no grants", async () => {
|
|
1731
|
+
const mockDb = createMockDb();
|
|
1732
|
+
|
|
1733
|
+
// No grants exist
|
|
1734
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1735
|
+
from: mock(() => createChain([])),
|
|
1736
|
+
}));
|
|
1737
|
+
|
|
1738
|
+
// Settings query - empty
|
|
1739
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1740
|
+
from: mock(() => createChain([])),
|
|
1741
|
+
}));
|
|
1742
|
+
|
|
1743
|
+
// User teams - empty
|
|
1744
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1745
|
+
from: mock(() => createChain([])),
|
|
1746
|
+
}));
|
|
1747
|
+
|
|
1748
|
+
const router = createAuthRouter(
|
|
1749
|
+
mockDb,
|
|
1750
|
+
mockRegistry,
|
|
1751
|
+
async () => {},
|
|
1752
|
+
mockConfigService,
|
|
1753
|
+
mockAccessRuleRegistry
|
|
1754
|
+
);
|
|
1755
|
+
|
|
1756
|
+
const context = createMockRpcContext({ user: mockServiceUser });
|
|
1757
|
+
const result = await call(
|
|
1758
|
+
router.getAccessibleResourceIds,
|
|
1759
|
+
{
|
|
1760
|
+
userId: "user-1",
|
|
1761
|
+
userType: "user",
|
|
1762
|
+
resourceType: "catalog.system",
|
|
1763
|
+
resourceIds: ["sys-1", "sys-2", "sys-3"],
|
|
1764
|
+
action: "read",
|
|
1765
|
+
hasGlobalAccess: false,
|
|
1766
|
+
},
|
|
1767
|
+
{ context }
|
|
1768
|
+
);
|
|
1769
|
+
|
|
1770
|
+
expect(result).toEqual([]);
|
|
1771
|
+
});
|
|
1772
|
+
|
|
1773
|
+
it("filters manage action based on canManage grants", async () => {
|
|
1774
|
+
const mockDb = createMockDb();
|
|
1775
|
+
|
|
1776
|
+
// Grants exist - sys-1 has canManage, sys-2 only has canRead
|
|
1777
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1778
|
+
from: mock(() =>
|
|
1779
|
+
createChain([
|
|
1780
|
+
{
|
|
1781
|
+
resourceId: "sys-1",
|
|
1782
|
+
teamId: "team-1",
|
|
1783
|
+
canRead: true,
|
|
1784
|
+
canManage: true,
|
|
1785
|
+
},
|
|
1786
|
+
{
|
|
1787
|
+
resourceId: "sys-2",
|
|
1788
|
+
teamId: "team-1",
|
|
1789
|
+
canRead: true,
|
|
1790
|
+
canManage: false,
|
|
1791
|
+
},
|
|
1792
|
+
])
|
|
1793
|
+
),
|
|
1794
|
+
}));
|
|
1795
|
+
|
|
1796
|
+
// Settings query - both are teamOnly
|
|
1797
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1798
|
+
from: mock(() =>
|
|
1799
|
+
createChain([
|
|
1800
|
+
{ resourceId: "sys-1", teamOnly: true },
|
|
1801
|
+
{ resourceId: "sys-2", teamOnly: true },
|
|
1802
|
+
])
|
|
1803
|
+
),
|
|
1804
|
+
}));
|
|
1805
|
+
|
|
1806
|
+
// User is member of team-1
|
|
1807
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1808
|
+
from: mock(() => createChain([{ teamId: "team-1" }])),
|
|
1809
|
+
}));
|
|
1810
|
+
|
|
1811
|
+
const router = createAuthRouter(
|
|
1812
|
+
mockDb,
|
|
1813
|
+
mockRegistry,
|
|
1814
|
+
async () => {},
|
|
1815
|
+
mockConfigService,
|
|
1816
|
+
mockAccessRuleRegistry
|
|
1817
|
+
);
|
|
1818
|
+
|
|
1819
|
+
const context = createMockRpcContext({ user: mockServiceUser });
|
|
1820
|
+
const result = await call(
|
|
1821
|
+
router.getAccessibleResourceIds,
|
|
1822
|
+
{
|
|
1823
|
+
userId: "user-1",
|
|
1824
|
+
userType: "user",
|
|
1825
|
+
resourceType: "catalog.system",
|
|
1826
|
+
resourceIds: ["sys-1", "sys-2"],
|
|
1827
|
+
action: "manage",
|
|
1828
|
+
hasGlobalAccess: false,
|
|
1829
|
+
},
|
|
1830
|
+
{ context }
|
|
1831
|
+
);
|
|
1832
|
+
|
|
1833
|
+
// sys-1: has canManage, granted
|
|
1834
|
+
// sys-2: only canRead, denied for manage action
|
|
1835
|
+
expect(result).toContain("sys-1");
|
|
1836
|
+
expect(result).not.toContain("sys-2");
|
|
1837
|
+
});
|
|
1838
|
+
|
|
1839
|
+
it("filters resources for application user based on applicationTeam", async () => {
|
|
1840
|
+
const mockDb = createMockDb();
|
|
1841
|
+
|
|
1842
|
+
// Grants exist for sys-1 (team-1)
|
|
1843
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1844
|
+
from: mock(() =>
|
|
1845
|
+
createChain([
|
|
1846
|
+
{
|
|
1847
|
+
resourceId: "sys-1",
|
|
1848
|
+
teamId: "team-1",
|
|
1849
|
+
canRead: true,
|
|
1850
|
+
canManage: false,
|
|
1851
|
+
},
|
|
1852
|
+
])
|
|
1853
|
+
),
|
|
1854
|
+
}));
|
|
1855
|
+
|
|
1856
|
+
// Settings query - sys-1 is teamOnly
|
|
1857
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1858
|
+
from: mock(() =>
|
|
1859
|
+
createChain([{ resourceId: "sys-1", teamOnly: true }])
|
|
1860
|
+
),
|
|
1861
|
+
}));
|
|
1862
|
+
|
|
1863
|
+
// Application is member of team-1
|
|
1864
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1865
|
+
from: mock(() => createChain([{ teamId: "team-1" }])),
|
|
1866
|
+
}));
|
|
1867
|
+
|
|
1868
|
+
const router = createAuthRouter(
|
|
1869
|
+
mockDb,
|
|
1870
|
+
mockRegistry,
|
|
1871
|
+
async () => {},
|
|
1872
|
+
mockConfigService,
|
|
1873
|
+
mockAccessRuleRegistry
|
|
1874
|
+
);
|
|
1875
|
+
|
|
1876
|
+
const context = createMockRpcContext({ user: mockServiceUser });
|
|
1877
|
+
const result = await call(
|
|
1878
|
+
router.getAccessibleResourceIds,
|
|
1879
|
+
{
|
|
1880
|
+
userId: "app-1",
|
|
1881
|
+
userType: "application",
|
|
1882
|
+
resourceType: "catalog.system",
|
|
1883
|
+
resourceIds: ["sys-1", "sys-2"],
|
|
1884
|
+
action: "read",
|
|
1885
|
+
hasGlobalAccess: true,
|
|
1886
|
+
},
|
|
1887
|
+
{ context }
|
|
1888
|
+
);
|
|
1889
|
+
|
|
1890
|
+
// sys-1: application is in team-1, granted
|
|
1891
|
+
// sys-2: no grants, allowed by global access
|
|
1892
|
+
expect(result).toContain("sys-1");
|
|
1893
|
+
expect(result).toContain("sys-2");
|
|
1894
|
+
});
|
|
1895
|
+
|
|
1896
|
+
it("handles mixed teamOnly and non-teamOnly resources correctly", async () => {
|
|
1897
|
+
const mockDb = createMockDb();
|
|
1898
|
+
|
|
1899
|
+
// Grants exist for sys-1 (teamOnly) and sys-2 (not teamOnly)
|
|
1900
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1901
|
+
from: mock(() =>
|
|
1902
|
+
createChain([
|
|
1903
|
+
{
|
|
1904
|
+
resourceId: "sys-1",
|
|
1905
|
+
teamId: "team-1",
|
|
1906
|
+
canRead: true,
|
|
1907
|
+
canManage: false,
|
|
1908
|
+
},
|
|
1909
|
+
{
|
|
1910
|
+
resourceId: "sys-2",
|
|
1911
|
+
teamId: "team-1",
|
|
1912
|
+
canRead: true,
|
|
1913
|
+
canManage: false,
|
|
1914
|
+
},
|
|
1915
|
+
])
|
|
1916
|
+
),
|
|
1917
|
+
}));
|
|
1918
|
+
|
|
1919
|
+
// Settings query - only sys-1 is teamOnly
|
|
1920
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1921
|
+
from: mock(() =>
|
|
1922
|
+
createChain([{ resourceId: "sys-1", teamOnly: true }])
|
|
1923
|
+
),
|
|
1924
|
+
}));
|
|
1925
|
+
|
|
1926
|
+
// User is member of team-2 (NOT team-1)
|
|
1927
|
+
(mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
|
|
1928
|
+
from: mock(() => createChain([{ teamId: "team-2" }])),
|
|
1929
|
+
}));
|
|
1930
|
+
|
|
1931
|
+
const router = createAuthRouter(
|
|
1932
|
+
mockDb,
|
|
1933
|
+
mockRegistry,
|
|
1934
|
+
async () => {},
|
|
1935
|
+
mockConfigService,
|
|
1936
|
+
mockAccessRuleRegistry
|
|
1937
|
+
);
|
|
1938
|
+
|
|
1939
|
+
const context = createMockRpcContext({ user: mockServiceUser });
|
|
1940
|
+
const result = await call(
|
|
1941
|
+
router.getAccessibleResourceIds,
|
|
1942
|
+
{
|
|
1943
|
+
userId: "user-1",
|
|
1944
|
+
userType: "user",
|
|
1945
|
+
resourceType: "catalog.system",
|
|
1946
|
+
resourceIds: ["sys-1", "sys-2"],
|
|
1947
|
+
action: "read",
|
|
1948
|
+
hasGlobalAccess: true,
|
|
1949
|
+
},
|
|
1950
|
+
{ context }
|
|
1951
|
+
);
|
|
1952
|
+
|
|
1953
|
+
// sys-1: teamOnly=true, user not in team-1, denied
|
|
1954
|
+
// sys-2: teamOnly=false, user has global access, granted
|
|
1955
|
+
expect(result).not.toContain("sys-1");
|
|
1956
|
+
expect(result).toContain("sys-2");
|
|
1957
|
+
});
|
|
1958
|
+
});
|
|
1959
|
+
|
|
1960
|
+
describe("deleteResourceGrants (S2S)", () => {
|
|
1961
|
+
it("deletes all grants for a resource", async () => {
|
|
1962
|
+
const mockDb = createMockDb();
|
|
1963
|
+
|
|
1964
|
+
const router = createAuthRouter(
|
|
1965
|
+
mockDb,
|
|
1966
|
+
mockRegistry,
|
|
1967
|
+
async () => {},
|
|
1968
|
+
mockConfigService,
|
|
1969
|
+
mockAccessRuleRegistry
|
|
1970
|
+
);
|
|
1971
|
+
|
|
1972
|
+
const context = createMockRpcContext({ user: mockServiceUser });
|
|
1973
|
+
await call(
|
|
1974
|
+
router.deleteResourceGrants,
|
|
1975
|
+
{
|
|
1976
|
+
resourceType: "catalog.system",
|
|
1977
|
+
resourceId: "sys-1",
|
|
1978
|
+
},
|
|
1979
|
+
{ context }
|
|
1980
|
+
);
|
|
1981
|
+
|
|
1982
|
+
expect(mockDb.delete).toHaveBeenCalled();
|
|
1983
|
+
});
|
|
1984
|
+
});
|
|
1985
|
+
});
|