@kindgi/platform 0.1.3 → 0.1.4-rc.1
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/README.md +31 -0
- package/dist/in-memory/index.d.ts +1 -0
- package/dist/in-memory/index.d.ts.map +1 -1
- package/dist/in-memory/org-binding.d.ts.map +1 -1
- package/dist/in-memory/org-binding.js +33 -16
- package/dist/in-memory/org-binding.js.map +1 -1
- package/dist/in-memory/project-binding.d.ts +26 -1
- package/dist/in-memory/project-binding.d.ts.map +1 -1
- package/dist/in-memory/project-binding.js +47 -8
- package/dist/in-memory/project-binding.js.map +1 -1
- package/dist/in-memory/team-binding.d.ts +2 -1
- package/dist/in-memory/team-binding.d.ts.map +1 -1
- package/dist/in-memory/team-binding.js +31 -7
- package/dist/in-memory/team-binding.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/org-binding.d.ts +56 -12
- package/dist/org-binding.d.ts.map +1 -1
- package/dist/project-binding.d.ts +76 -12
- package/dist/project-binding.d.ts.map +1 -1
- package/dist/team-binding.d.ts +70 -12
- package/dist/team-binding.d.ts.map +1 -1
- package/dist/tenant-hierarchy-binding.d.ts +31 -5
- package/dist/tenant-hierarchy-binding.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/in-memory/index.ts +1 -0
- package/src/in-memory/org-binding.ts +41 -19
- package/src/in-memory/project-binding.ts +85 -16
- package/src/in-memory/team-binding.ts +48 -12
- package/src/index.ts +14 -1
- package/src/org-binding.ts +60 -12
- package/src/project-binding.ts +88 -12
- package/src/team-binding.ts +82 -12
- package/src/tenant-hierarchy-binding.ts +21 -6
- package/tests/in-memory-project-binding.test.ts +13 -1
- package/tests/in-memory-team-binding.test.ts +8 -1
- package/tests/org-binding.conformance.ts +93 -18
- package/tests/project-binding.conformance.ts +221 -37
- package/tests/team-binding.conformance.ts +120 -28
|
@@ -10,8 +10,15 @@
|
|
|
10
10
|
* - CRUD happy paths.
|
|
11
11
|
* - `getDefault` returns the tenant's Default row when `isDefault=true`.
|
|
12
12
|
* - `isDefault` uniqueness guardrail — creating a second Default in
|
|
13
|
-
* the same tenant
|
|
14
|
-
* time; Postgres impl enforces
|
|
13
|
+
* the same tenant resolves to `project-default-already-exists`
|
|
14
|
+
* (in-memory adapter enforces at write time; Postgres impl enforces
|
|
15
|
+
* via partial-unique-index).
|
|
16
|
+
* - Slug uniqueness within an org (and, for projects without an org,
|
|
17
|
+
* among those): `create` and `update` (a new slug, or a move to
|
|
18
|
+
* another org) resolve to `slug-conflict`; another org, or another
|
|
19
|
+
* tenant, may reuse the slug.
|
|
20
|
+
* - `project-not-found` / `project-membership-not-found` outcomes from
|
|
21
|
+
* writes on a missing row.
|
|
15
22
|
* - `listForUser` aggregates memberships across projects.
|
|
16
23
|
* - Cross-tenant isolation on every reachable read.
|
|
17
24
|
*/
|
|
@@ -22,6 +29,7 @@ import type { OrgId, ProjectId, TeamId, TenantId, UserId } from '@kindgi/types';
|
|
|
22
29
|
|
|
23
30
|
import type { ProjectBinding, ProjectMembershipBinding } from '../src/project-binding.js';
|
|
24
31
|
import type { TeamProjectGrantBinding } from '../src/team-project-grant-binding.js';
|
|
32
|
+
import type { ProjectSpec } from '../src/types.js';
|
|
25
33
|
|
|
26
34
|
const T1 = 'tenant-1' as TenantId;
|
|
27
35
|
const T2 = 'tenant-2' as TenantId;
|
|
@@ -30,6 +38,25 @@ const U2 = 'user-2' as UserId;
|
|
|
30
38
|
const TEAM_A = 'team-A' as TeamId;
|
|
31
39
|
const TEAM_B = 'team-B' as TeamId;
|
|
32
40
|
const ORG_A = 'org-A' as OrgId;
|
|
41
|
+
const ORG_B = 'org-B' as OrgId;
|
|
42
|
+
/**
|
|
43
|
+
* An org the binding doesn't know (T220), for the `checksOrgs` cases: a
|
|
44
|
+
* binding that checks orgs is made so this one isn't the tenant's.
|
|
45
|
+
*/
|
|
46
|
+
export const CONFORMANCE_MISSING_ORG = 'org-missing' as OrgId;
|
|
47
|
+
|
|
48
|
+
/** Create a project the test expects to succeed; its id. */
|
|
49
|
+
async function createProject(
|
|
50
|
+
projects: ProjectBinding,
|
|
51
|
+
tenantId: TenantId,
|
|
52
|
+
spec: ProjectSpec,
|
|
53
|
+
): Promise<ProjectId> {
|
|
54
|
+
const outcome = await projects.create(tenantId, spec);
|
|
55
|
+
if (outcome.kind !== 'ok') {
|
|
56
|
+
throw new Error(`expected the project to be created, got ${outcome.kind}`);
|
|
57
|
+
}
|
|
58
|
+
return outcome.projectId;
|
|
59
|
+
}
|
|
33
60
|
|
|
34
61
|
export function runProjectBindingConformance(
|
|
35
62
|
makeBinding: () => {
|
|
@@ -38,11 +65,19 @@ export function runProjectBindingConformance(
|
|
|
38
65
|
readonly grants: TeamProjectGrantBinding;
|
|
39
66
|
},
|
|
40
67
|
label = 'ProjectBinding',
|
|
68
|
+
options: {
|
|
69
|
+
/**
|
|
70
|
+
* The binding checks that an org exists (`CONFORMANCE_MISSING_ORG`
|
|
71
|
+
* doesn't): a project created in or moved to a missing org is
|
|
72
|
+
* `org-not-found`.
|
|
73
|
+
*/
|
|
74
|
+
readonly checksOrgs?: boolean;
|
|
75
|
+
} = {},
|
|
41
76
|
): void {
|
|
42
77
|
describe(`${label} — project CRUD`, () => {
|
|
43
78
|
it('creates + gets a project', async () => {
|
|
44
79
|
const { projects } = makeBinding();
|
|
45
|
-
const id = await projects
|
|
80
|
+
const id = await createProject(projects, T1, { name: 'Matter #1', slug: 'm1' });
|
|
46
81
|
const got = await projects.get(T1, id);
|
|
47
82
|
expect(got?.name).toBe('Matter #1');
|
|
48
83
|
expect(got?.isDefault).toBe(false);
|
|
@@ -50,8 +85,8 @@ export function runProjectBindingConformance(
|
|
|
50
85
|
|
|
51
86
|
it('lists projects filtered by orgId', async () => {
|
|
52
87
|
const { projects } = makeBinding();
|
|
53
|
-
await projects
|
|
54
|
-
await projects
|
|
88
|
+
await createProject(projects, T1, { name: 'A', slug: 'a', orgId: ORG_A });
|
|
89
|
+
await createProject(projects, T1, { name: 'B', slug: 'b' });
|
|
55
90
|
const inOrg = await projects.list(T1, { orgId: ORG_A });
|
|
56
91
|
expect(inOrg.items).toHaveLength(1);
|
|
57
92
|
expect(inOrg.items[0]?.name).toBe('A');
|
|
@@ -59,15 +94,15 @@ export function runProjectBindingConformance(
|
|
|
59
94
|
|
|
60
95
|
it('updates a project', async () => {
|
|
61
96
|
const { projects } = makeBinding();
|
|
62
|
-
const id = await projects
|
|
63
|
-
await projects.update(T1, id, { name: 'A-updated' });
|
|
97
|
+
const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
|
|
98
|
+
expect(await projects.update(T1, id, { name: 'A-updated' })).toEqual({ kind: 'ok' });
|
|
64
99
|
const got = await projects.get(T1, id);
|
|
65
100
|
expect(got?.name).toBe('A-updated');
|
|
66
101
|
});
|
|
67
102
|
|
|
68
103
|
it('deletes a project + cascades memberships + grants', async () => {
|
|
69
104
|
const { projects, memberships, grants } = makeBinding();
|
|
70
|
-
const id = await projects
|
|
105
|
+
const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
|
|
71
106
|
await memberships.add(T1, { projectId: id, userId: U1, role: 'editor' });
|
|
72
107
|
await grants.add(T1, { teamId: TEAM_A, projectId: id, role: 'editor' });
|
|
73
108
|
await projects.delete(T1, id);
|
|
@@ -86,7 +121,7 @@ export function runProjectBindingConformance(
|
|
|
86
121
|
|
|
87
122
|
it('getDefault returns the isDefault=true row', async () => {
|
|
88
123
|
const { projects } = makeBinding();
|
|
89
|
-
const id = await projects
|
|
124
|
+
const id = await createProject(projects, T1, {
|
|
90
125
|
name: 'Default',
|
|
91
126
|
slug: 'default',
|
|
92
127
|
isDefault: true,
|
|
@@ -97,18 +132,28 @@ export function runProjectBindingConformance(
|
|
|
97
132
|
expect(got?.isDefault).toBe(true);
|
|
98
133
|
});
|
|
99
134
|
|
|
100
|
-
it('isDefault uniqueness — a second Default in the same tenant
|
|
135
|
+
it('isDefault uniqueness — a second Default in the same tenant → project-default-already-exists', async () => {
|
|
101
136
|
const { projects } = makeBinding();
|
|
102
|
-
await projects
|
|
103
|
-
await
|
|
104
|
-
|
|
105
|
-
)
|
|
137
|
+
await createProject(projects, T1, { name: 'D1', slug: 'd1', isDefault: true });
|
|
138
|
+
expect(await projects.create(T1, { name: 'D2', slug: 'd2', isDefault: true })).toEqual({
|
|
139
|
+
kind: 'project-default-already-exists',
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('isDefault with only the slug taken → slug-conflict', async () => {
|
|
144
|
+
const { projects } = makeBinding();
|
|
145
|
+
await createProject(projects, T1, { name: 'P', slug: 'p' });
|
|
146
|
+
expect(await projects.create(T1, { name: 'D', slug: 'p', isDefault: true })).toEqual({
|
|
147
|
+
kind: 'slug-conflict',
|
|
148
|
+
slug: 'p',
|
|
149
|
+
});
|
|
150
|
+
expect(await projects.getDefault(T1)).toBeUndefined();
|
|
106
151
|
});
|
|
107
152
|
|
|
108
153
|
it('a Default in tenant A does NOT block a Default in tenant B', async () => {
|
|
109
154
|
const { projects } = makeBinding();
|
|
110
|
-
await projects
|
|
111
|
-
const d2 = await projects
|
|
155
|
+
await createProject(projects, T1, { name: 'D1', slug: 'd1', isDefault: true });
|
|
156
|
+
const d2 = await createProject(projects, T2, {
|
|
112
157
|
name: 'D2',
|
|
113
158
|
slug: 'd2',
|
|
114
159
|
isDefault: true,
|
|
@@ -121,28 +166,45 @@ export function runProjectBindingConformance(
|
|
|
121
166
|
describe(`${label} — cross-tenant isolation`, () => {
|
|
122
167
|
it('project get across tenants returns undefined', async () => {
|
|
123
168
|
const { projects } = makeBinding();
|
|
124
|
-
const id = await projects
|
|
169
|
+
const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
|
|
125
170
|
expect(await projects.get(T2, id)).toBeUndefined();
|
|
126
171
|
});
|
|
127
172
|
|
|
128
173
|
it('project list across tenants does not leak', async () => {
|
|
129
174
|
const { projects } = makeBinding();
|
|
130
|
-
await projects
|
|
175
|
+
await createProject(projects, T1, { name: 'A', slug: 'a' });
|
|
131
176
|
const page = await projects.list(T2, {});
|
|
132
177
|
expect(page.items).toEqual([]);
|
|
133
178
|
});
|
|
134
179
|
|
|
135
|
-
it('project-membership add under wrong tenant
|
|
180
|
+
it('project-membership add under wrong tenant → project-not-found', async () => {
|
|
136
181
|
const { projects, memberships } = makeBinding();
|
|
137
|
-
const id = await projects
|
|
138
|
-
await
|
|
139
|
-
|
|
140
|
-
)
|
|
182
|
+
const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
|
|
183
|
+
expect(await memberships.add(T2, { projectId: id, userId: U1, role: 'editor' })).toEqual({
|
|
184
|
+
kind: 'project-not-found',
|
|
185
|
+
});
|
|
186
|
+
expect((await memberships.list(T1, id, {})).items).toEqual([]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('project update under wrong tenant → project-not-found', async () => {
|
|
190
|
+
const { projects } = makeBinding();
|
|
191
|
+
const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
|
|
192
|
+
expect(await projects.update(T2, id, { name: 'B' })).toEqual({ kind: 'project-not-found' });
|
|
193
|
+
expect((await projects.get(T1, id))?.name).toBe('A');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('membership updateRole under wrong tenant → project-not-found', async () => {
|
|
197
|
+
const { projects, memberships } = makeBinding();
|
|
198
|
+
const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
|
|
199
|
+
await memberships.add(T1, { projectId: id, userId: U1, role: 'editor' });
|
|
200
|
+
expect(await memberships.updateRole(T2, id, U1, 'owner')).toEqual({
|
|
201
|
+
kind: 'project-not-found',
|
|
202
|
+
});
|
|
141
203
|
});
|
|
142
204
|
|
|
143
205
|
it('team-project grant add under wrong tenant errors', async () => {
|
|
144
206
|
const { projects, grants } = makeBinding();
|
|
145
|
-
const id = await projects
|
|
207
|
+
const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
|
|
146
208
|
await expect(
|
|
147
209
|
grants.add(T2, { teamId: TEAM_A, projectId: id, role: 'editor' }),
|
|
148
210
|
).rejects.toThrow();
|
|
@@ -150,8 +212,8 @@ export function runProjectBindingConformance(
|
|
|
150
212
|
|
|
151
213
|
it('listForUser across tenants only returns tenant-scoped rows', async () => {
|
|
152
214
|
const { projects, memberships } = makeBinding();
|
|
153
|
-
const p1 = await projects
|
|
154
|
-
const p2 = await projects
|
|
215
|
+
const p1 = await createProject(projects, T1, { name: 'A', slug: 'a' });
|
|
216
|
+
const p2 = await createProject(projects, T2, { name: 'B', slug: 'b' });
|
|
155
217
|
await memberships.add(T1, { projectId: p1, userId: U1, role: 'editor' });
|
|
156
218
|
await memberships.add(T2, { projectId: p2, userId: U1, role: 'editor' });
|
|
157
219
|
const t1Page = await memberships.listForUser(T1, U1, {});
|
|
@@ -163,17 +225,37 @@ export function runProjectBindingConformance(
|
|
|
163
225
|
describe(`${label} — membership + grant idempotency`, () => {
|
|
164
226
|
it('project-membership add is idempotent (does not overwrite role)', async () => {
|
|
165
227
|
const { projects, memberships } = makeBinding();
|
|
166
|
-
const id = await projects
|
|
167
|
-
await memberships.add(T1, { projectId: id, userId: U1, role: 'owner' })
|
|
168
|
-
|
|
228
|
+
const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
|
|
229
|
+
expect(await memberships.add(T1, { projectId: id, userId: U1, role: 'owner' })).toEqual({
|
|
230
|
+
kind: 'ok',
|
|
231
|
+
});
|
|
232
|
+
expect(await memberships.add(T1, { projectId: id, userId: U1, role: 'viewer' })).toEqual({
|
|
233
|
+
kind: 'ok',
|
|
234
|
+
});
|
|
169
235
|
const page = await memberships.list(T1, id, {});
|
|
170
236
|
expect(page.items).toHaveLength(1);
|
|
171
237
|
expect(page.items[0]?.role).toBe('owner');
|
|
172
238
|
});
|
|
173
239
|
|
|
240
|
+
it('project-membership updateRole mutates the role', async () => {
|
|
241
|
+
const { projects, memberships } = makeBinding();
|
|
242
|
+
const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
|
|
243
|
+
await memberships.add(T1, { projectId: id, userId: U1, role: 'editor' });
|
|
244
|
+
expect(await memberships.updateRole(T1, id, U1, 'owner')).toEqual({ kind: 'ok' });
|
|
245
|
+
expect((await memberships.list(T1, id, {})).items[0]?.role).toBe('owner');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('project-membership updateRole on a missing membership → project-membership-not-found', async () => {
|
|
249
|
+
const { projects, memberships } = makeBinding();
|
|
250
|
+
const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
|
|
251
|
+
expect(await memberships.updateRole(T1, id, U1, 'owner')).toEqual({
|
|
252
|
+
kind: 'project-membership-not-found',
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
174
256
|
it('grant add is idempotent', async () => {
|
|
175
257
|
const { projects, grants } = makeBinding();
|
|
176
|
-
const id = await projects
|
|
258
|
+
const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
|
|
177
259
|
await grants.add(T1, { teamId: TEAM_A, projectId: id, role: 'owner' });
|
|
178
260
|
await grants.add(T1, { teamId: TEAM_A, projectId: id, role: 'viewer' });
|
|
179
261
|
const page = await grants.listForProject(T1, id, {});
|
|
@@ -183,7 +265,7 @@ export function runProjectBindingConformance(
|
|
|
183
265
|
|
|
184
266
|
it('grant updateRole mutates the role', async () => {
|
|
185
267
|
const { projects, grants } = makeBinding();
|
|
186
|
-
const id = await projects
|
|
268
|
+
const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
|
|
187
269
|
await grants.add(T1, { teamId: TEAM_A, projectId: id, role: 'editor' });
|
|
188
270
|
await grants.updateRole(T1, TEAM_A, id, 'owner');
|
|
189
271
|
const page = await grants.listForTeam(T1, TEAM_A, {});
|
|
@@ -194,9 +276,9 @@ export function runProjectBindingConformance(
|
|
|
194
276
|
describe(`${label} — listForUser aggregates across projects`, () => {
|
|
195
277
|
it('returns every direct-grant project the user has', async () => {
|
|
196
278
|
const { projects, memberships } = makeBinding();
|
|
197
|
-
const a = await projects
|
|
198
|
-
const b = await projects
|
|
199
|
-
const c = await projects
|
|
279
|
+
const a = await createProject(projects, T1, { name: 'A', slug: 'a' });
|
|
280
|
+
const b = await createProject(projects, T1, { name: 'B', slug: 'b' });
|
|
281
|
+
const c = await createProject(projects, T1, { name: 'C', slug: 'c' });
|
|
200
282
|
await memberships.add(T1, { projectId: a, userId: U1, role: 'editor' });
|
|
201
283
|
await memberships.add(T1, { projectId: b, userId: U1, role: 'viewer' });
|
|
202
284
|
await memberships.add(T1, { projectId: c, userId: U2, role: 'editor' });
|
|
@@ -211,7 +293,7 @@ export function runProjectBindingConformance(
|
|
|
211
293
|
describe(`${label} — grant listForProject + listForTeam`, () => {
|
|
212
294
|
it('listForProject returns every team-grant on the project', async () => {
|
|
213
295
|
const { projects, grants } = makeBinding();
|
|
214
|
-
const id = await projects
|
|
296
|
+
const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
|
|
215
297
|
await grants.add(T1, { teamId: TEAM_A, projectId: id, role: 'editor' });
|
|
216
298
|
await grants.add(T1, { teamId: TEAM_B, projectId: id, role: 'viewer' });
|
|
217
299
|
const page = await grants.listForProject(T1, id, {});
|
|
@@ -220,12 +302,114 @@ export function runProjectBindingConformance(
|
|
|
220
302
|
|
|
221
303
|
it('listForTeam returns every project the team has a grant on', async () => {
|
|
222
304
|
const { projects, grants } = makeBinding();
|
|
223
|
-
const a = await projects
|
|
224
|
-
const b = await projects
|
|
305
|
+
const a = await createProject(projects, T1, { name: 'A', slug: 'a' });
|
|
306
|
+
const b = await createProject(projects, T1, { name: 'B', slug: 'b' });
|
|
225
307
|
await grants.add(T1, { teamId: TEAM_A, projectId: a, role: 'editor' });
|
|
226
308
|
await grants.add(T1, { teamId: TEAM_A, projectId: b, role: 'viewer' });
|
|
227
309
|
const page = await grants.listForTeam(T1, TEAM_A, {});
|
|
228
310
|
expect(page.items).toHaveLength(2);
|
|
229
311
|
});
|
|
230
312
|
});
|
|
313
|
+
|
|
314
|
+
describe(`${label} — slug uniqueness`, () => {
|
|
315
|
+
it('create with a slug a project without an org already has → slug-conflict', async () => {
|
|
316
|
+
const { projects } = makeBinding();
|
|
317
|
+
await createProject(projects, T1, { name: 'Matter', slug: 'm1' });
|
|
318
|
+
expect(await projects.create(T1, { name: 'Matter again', slug: 'm1' })).toEqual({
|
|
319
|
+
kind: 'slug-conflict',
|
|
320
|
+
slug: 'm1',
|
|
321
|
+
});
|
|
322
|
+
expect((await projects.list(T1, {})).items).toHaveLength(1);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('another tenant may use the same slug', async () => {
|
|
326
|
+
const { projects } = makeBinding();
|
|
327
|
+
await createProject(projects, T1, { name: 'Matter', slug: 'm1' });
|
|
328
|
+
expect((await projects.create(T2, { name: 'Matter', slug: 'm1' })).kind).toBe('ok');
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("update to another project's slug → slug-conflict; the project is unchanged", async () => {
|
|
332
|
+
const { projects } = makeBinding();
|
|
333
|
+
await createProject(projects, T1, { name: 'A', slug: 'a' });
|
|
334
|
+
const id = await createProject(projects, T1, { name: 'B', slug: 'b' });
|
|
335
|
+
expect(await projects.update(T1, id, { slug: 'a' })).toEqual({
|
|
336
|
+
kind: 'slug-conflict',
|
|
337
|
+
slug: 'a',
|
|
338
|
+
});
|
|
339
|
+
expect((await projects.get(T1, id))?.slug).toBe('b');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('two orgs may each have a project with the same slug', async () => {
|
|
343
|
+
const { projects } = makeBinding();
|
|
344
|
+
await createProject(projects, T1, { name: 'A', slug: 'shared', orgId: ORG_A });
|
|
345
|
+
await createProject(projects, T1, { name: 'B', slug: 'shared', orgId: ORG_B });
|
|
346
|
+
// And a project without an org may have it too.
|
|
347
|
+
await createProject(projects, T1, { name: 'C', slug: 'shared' });
|
|
348
|
+
expect((await projects.list(T1, {})).items.map((p) => p.slug)).toEqual([
|
|
349
|
+
'shared',
|
|
350
|
+
'shared',
|
|
351
|
+
'shared',
|
|
352
|
+
]);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("create with a slug the org's project already has → slug-conflict", async () => {
|
|
356
|
+
const { projects } = makeBinding();
|
|
357
|
+
await createProject(projects, T1, { name: 'A', slug: 'a', orgId: ORG_A });
|
|
358
|
+
expect(await projects.create(T1, { name: 'A again', slug: 'a', orgId: ORG_A })).toEqual({
|
|
359
|
+
kind: 'slug-conflict',
|
|
360
|
+
slug: 'a',
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('a move to an org that has the slug → slug-conflict; the project stays where it was', async () => {
|
|
365
|
+
const { projects } = makeBinding();
|
|
366
|
+
await createProject(projects, T1, { name: 'In A', slug: 's', orgId: ORG_A });
|
|
367
|
+
const id = await createProject(projects, T1, { name: 'In B', slug: 's', orgId: ORG_B });
|
|
368
|
+
expect(await projects.update(T1, id, { orgId: ORG_A })).toEqual({
|
|
369
|
+
kind: 'slug-conflict',
|
|
370
|
+
slug: 's',
|
|
371
|
+
});
|
|
372
|
+
expect((await projects.get(T1, id))?.orgId).toBe(ORG_B);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('a move out of its org, where a project without one has the slug → slug-conflict', async () => {
|
|
376
|
+
const { projects } = makeBinding();
|
|
377
|
+
await createProject(projects, T1, { name: 'Org-less', slug: 's' });
|
|
378
|
+
const id = await createProject(projects, T1, { name: 'In A', slug: 's', orgId: ORG_A });
|
|
379
|
+
expect(await projects.update(T1, id, { orgId: null })).toEqual({
|
|
380
|
+
kind: 'slug-conflict',
|
|
381
|
+
slug: 's',
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('a move to an org without the slug, and a rename to a slug only another org has: ok', async () => {
|
|
386
|
+
const { projects } = makeBinding();
|
|
387
|
+
await createProject(projects, T1, { name: 'In A', slug: 'a', orgId: ORG_A });
|
|
388
|
+
const id = await createProject(projects, T1, { name: 'In B', slug: 'b', orgId: ORG_B });
|
|
389
|
+
expect(await projects.update(T1, id, { slug: 'a' })).toEqual({ kind: 'ok' });
|
|
390
|
+
expect(await projects.update(T1, id, { slug: 'c', orgId: ORG_A })).toEqual({ kind: 'ok' });
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
if (options.checksOrgs === true) {
|
|
395
|
+
describe(`${label} — an org that isn't the tenant's (T220)`, () => {
|
|
396
|
+
it('creating a project in it: org-not-found, and nothing is created', async () => {
|
|
397
|
+
const { projects } = makeBinding();
|
|
398
|
+
expect(
|
|
399
|
+
await projects.create(T1, { name: 'P', slug: 'p', orgId: CONFORMANCE_MISSING_ORG }),
|
|
400
|
+
).toEqual({ kind: 'org-not-found', orgId: CONFORMANCE_MISSING_ORG });
|
|
401
|
+
expect((await projects.list(T1, { orgId: CONFORMANCE_MISSING_ORG })).items).toEqual([]);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('moving a project to it: org-not-found, and the project stays where it was', async () => {
|
|
405
|
+
const { projects } = makeBinding();
|
|
406
|
+
const id = await createProject(projects, T1, { name: 'P', slug: 'p', orgId: ORG_A });
|
|
407
|
+
expect(await projects.update(T1, id, { orgId: CONFORMANCE_MISSING_ORG })).toEqual({
|
|
408
|
+
kind: 'org-not-found',
|
|
409
|
+
orgId: CONFORMANCE_MISSING_ORG,
|
|
410
|
+
});
|
|
411
|
+
expect((await projects.get(T1, id))?.orgId).toBe(ORG_A);
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
}
|
|
231
415
|
}
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* `updateRole`).
|
|
13
13
|
* - `listForUser` returns memberships across teams within a tenant.
|
|
14
14
|
* - Cross-tenant isolation on every reachable read.
|
|
15
|
+
* - Outcomes: `team-not-found` and `team-membership-not-found` from
|
|
16
|
+
* writes on a missing row, `slug-conflict` from a slug the tenant
|
|
17
|
+
* already has (another tenant may reuse it).
|
|
15
18
|
*/
|
|
16
19
|
|
|
17
20
|
import { describe, expect, it } from 'vitest';
|
|
@@ -19,12 +22,23 @@ import { describe, expect, it } from 'vitest';
|
|
|
19
22
|
import type { OrgId, TeamId, TenantId, UserId } from '@kindgi/types';
|
|
20
23
|
|
|
21
24
|
import type { TeamBinding, TeamMembershipBinding } from '../src/team-binding.js';
|
|
25
|
+
import type { TeamSpec } from '../src/types.js';
|
|
22
26
|
|
|
23
27
|
const T1 = 'tenant-1' as TenantId;
|
|
24
28
|
const T2 = 'tenant-2' as TenantId;
|
|
25
29
|
const U1 = 'user-1' as UserId;
|
|
26
30
|
const U2 = 'user-2' as UserId;
|
|
27
31
|
const ORG_A = 'org-a' as OrgId;
|
|
32
|
+
/** An org the binding doesn't know (T220), for the `checksOrgs` cases. */
|
|
33
|
+
export const CONFORMANCE_MISSING_ORG = 'org-missing' as OrgId;
|
|
34
|
+
|
|
35
|
+
/** Create a team the test expects to succeed; its id. */
|
|
36
|
+
async function createTeam(teams: TeamBinding, tenantId: TenantId, spec: TeamSpec): Promise<TeamId> {
|
|
37
|
+
const outcome = await teams.create(tenantId, spec);
|
|
38
|
+
if (outcome.kind !== 'ok')
|
|
39
|
+
throw new Error(`expected the team to be created, got ${outcome.kind}`);
|
|
40
|
+
return outcome.teamId;
|
|
41
|
+
}
|
|
28
42
|
|
|
29
43
|
export function runTeamBindingConformance(
|
|
30
44
|
makeBinding: () => {
|
|
@@ -32,11 +46,15 @@ export function runTeamBindingConformance(
|
|
|
32
46
|
readonly memberships: TeamMembershipBinding;
|
|
33
47
|
},
|
|
34
48
|
label = 'TeamBinding',
|
|
49
|
+
options: {
|
|
50
|
+
/** The binding checks that an org exists (`CONFORMANCE_MISSING_ORG` doesn't). */
|
|
51
|
+
readonly checksOrgs?: boolean;
|
|
52
|
+
} = {},
|
|
35
53
|
): void {
|
|
36
54
|
describe(`${label} — team CRUD`, () => {
|
|
37
55
|
it('creates + gets a team with orgId', async () => {
|
|
38
56
|
const { teams } = makeBinding();
|
|
39
|
-
const id = await teams
|
|
57
|
+
const id = await createTeam(teams, T1, {
|
|
40
58
|
name: 'Litigation',
|
|
41
59
|
slug: 'lit',
|
|
42
60
|
orgId: ORG_A,
|
|
@@ -49,27 +67,27 @@ export function runTeamBindingConformance(
|
|
|
49
67
|
|
|
50
68
|
it('creates a cross-org team (no orgId)', async () => {
|
|
51
69
|
const { teams } = makeBinding();
|
|
52
|
-
const id = await teams
|
|
70
|
+
const id = await createTeam(teams, T1, { name: 'Cross', slug: 'cross' });
|
|
53
71
|
const got = await teams.get(T1, id);
|
|
54
72
|
expect(got?.orgId).toBeUndefined();
|
|
55
73
|
});
|
|
56
74
|
|
|
57
75
|
it('updates a team; orgId=null clears the FK', async () => {
|
|
58
76
|
const { teams } = makeBinding();
|
|
59
|
-
const id = await teams
|
|
77
|
+
const id = await createTeam(teams, T1, {
|
|
60
78
|
name: 'Litigation',
|
|
61
79
|
slug: 'lit',
|
|
62
80
|
orgId: ORG_A,
|
|
63
81
|
});
|
|
64
|
-
await teams.update(T1, id, { orgId: null });
|
|
82
|
+
expect(await teams.update(T1, id, { orgId: null })).toEqual({ kind: 'ok' });
|
|
65
83
|
const got = await teams.get(T1, id);
|
|
66
84
|
expect(got?.orgId).toBeUndefined();
|
|
67
85
|
});
|
|
68
86
|
|
|
69
87
|
it('lists teams filtered by orgId', async () => {
|
|
70
88
|
const { teams } = makeBinding();
|
|
71
|
-
await teams
|
|
72
|
-
await teams
|
|
89
|
+
await createTeam(teams, T1, { name: 'A', slug: 'a', orgId: ORG_A });
|
|
90
|
+
await createTeam(teams, T1, { name: 'B', slug: 'b' });
|
|
73
91
|
const inOrg = await teams.list(T1, { orgId: ORG_A });
|
|
74
92
|
expect(inOrg.items).toHaveLength(1);
|
|
75
93
|
expect(inOrg.items[0]?.name).toBe('A');
|
|
@@ -77,7 +95,7 @@ export function runTeamBindingConformance(
|
|
|
77
95
|
|
|
78
96
|
it('deletes a team + cascades memberships', async () => {
|
|
79
97
|
const { teams, memberships } = makeBinding();
|
|
80
|
-
const id = await teams
|
|
98
|
+
const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
|
|
81
99
|
await memberships.add(T1, { teamId: id, userId: U1, role: 'member' });
|
|
82
100
|
await teams.delete(T1, id);
|
|
83
101
|
const page = await memberships.listForUser(T1, U1, {});
|
|
@@ -88,29 +106,37 @@ export function runTeamBindingConformance(
|
|
|
88
106
|
describe(`${label} — cross-tenant isolation`, () => {
|
|
89
107
|
it('team get across tenants returns undefined', async () => {
|
|
90
108
|
const { teams } = makeBinding();
|
|
91
|
-
const id = await teams
|
|
109
|
+
const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
|
|
92
110
|
expect(await teams.get(T2, id)).toBeUndefined();
|
|
93
111
|
});
|
|
94
112
|
|
|
95
113
|
it('team list across tenants does not leak', async () => {
|
|
96
114
|
const { teams } = makeBinding();
|
|
97
|
-
await teams
|
|
115
|
+
await createTeam(teams, T1, { name: 'X', slug: 'x' });
|
|
98
116
|
const page = await teams.list(T2, {});
|
|
99
117
|
expect(page.items).toEqual([]);
|
|
100
118
|
});
|
|
101
119
|
|
|
102
|
-
it('adding a membership under wrong tenant
|
|
120
|
+
it('adding a membership under wrong tenant → team-not-found', async () => {
|
|
103
121
|
const { teams, memberships } = makeBinding();
|
|
104
|
-
const id = await teams
|
|
105
|
-
await
|
|
106
|
-
|
|
107
|
-
)
|
|
122
|
+
const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
|
|
123
|
+
expect(await memberships.add(T2, { teamId: id, userId: U1, role: 'member' })).toEqual({
|
|
124
|
+
kind: 'team-not-found',
|
|
125
|
+
});
|
|
126
|
+
expect((await memberships.list(T1, id, {})).items).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('update under wrong tenant → team-not-found', async () => {
|
|
130
|
+
const { teams } = makeBinding();
|
|
131
|
+
const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
|
|
132
|
+
expect(await teams.update(T2, id, { name: 'Y' })).toEqual({ kind: 'team-not-found' });
|
|
133
|
+
expect((await teams.get(T1, id))?.name).toBe('X');
|
|
108
134
|
});
|
|
109
135
|
|
|
110
136
|
it('listForUser across tenants only returns tenant-scoped memberships', async () => {
|
|
111
137
|
const { teams, memberships } = makeBinding();
|
|
112
|
-
const t1Team = await teams
|
|
113
|
-
const t2Team = await teams
|
|
138
|
+
const t1Team = await createTeam(teams, T1, { name: 'A', slug: 'a' });
|
|
139
|
+
const t2Team = await createTeam(teams, T2, { name: 'B', slug: 'b' });
|
|
114
140
|
await memberships.add(T1, { teamId: t1Team, userId: U1, role: 'member' });
|
|
115
141
|
await memberships.add(T2, { teamId: t2Team, userId: U1, role: 'member' });
|
|
116
142
|
const t1Page = await memberships.listForUser(T1, U1, {});
|
|
@@ -125,10 +151,14 @@ export function runTeamBindingConformance(
|
|
|
125
151
|
describe(`${label} — membership idempotency`, () => {
|
|
126
152
|
it('add on existing membership is a no-op (does NOT overwrite role)', async () => {
|
|
127
153
|
const { teams, memberships } = makeBinding();
|
|
128
|
-
const id = await teams
|
|
129
|
-
await memberships.add(T1, { teamId: id, userId: U1, role: 'admin' })
|
|
154
|
+
const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
|
|
155
|
+
expect(await memberships.add(T1, { teamId: id, userId: U1, role: 'admin' })).toEqual({
|
|
156
|
+
kind: 'ok',
|
|
157
|
+
});
|
|
130
158
|
// Re-add with a different role — should be a no-op, not a mutation.
|
|
131
|
-
await memberships.add(T1, { teamId: id, userId: U1, role: 'member' })
|
|
159
|
+
expect(await memberships.add(T1, { teamId: id, userId: U1, role: 'member' })).toEqual({
|
|
160
|
+
kind: 'ok',
|
|
161
|
+
});
|
|
132
162
|
const page = await memberships.list(T1, id, {});
|
|
133
163
|
expect(page.items).toHaveLength(1);
|
|
134
164
|
expect(page.items[0]?.role).toBe('admin');
|
|
@@ -136,21 +166,32 @@ export function runTeamBindingConformance(
|
|
|
136
166
|
|
|
137
167
|
it('remove on missing membership is a no-op', async () => {
|
|
138
168
|
const { teams, memberships } = makeBinding();
|
|
139
|
-
const id = await teams
|
|
169
|
+
const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
|
|
140
170
|
await expect(memberships.remove(T1, id, U1)).resolves.toBeUndefined();
|
|
141
171
|
});
|
|
142
172
|
|
|
143
|
-
it('updateRole on missing membership
|
|
173
|
+
it('updateRole on missing membership → team-membership-not-found', async () => {
|
|
144
174
|
const { teams, memberships } = makeBinding();
|
|
145
|
-
const id = await teams
|
|
146
|
-
await
|
|
175
|
+
const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
|
|
176
|
+
expect(await memberships.updateRole(T1, id, U1, 'admin')).toEqual({
|
|
177
|
+
kind: 'team-membership-not-found',
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('updateRole on a team in another tenant → team-not-found', async () => {
|
|
182
|
+
const { teams, memberships } = makeBinding();
|
|
183
|
+
const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
|
|
184
|
+
await memberships.add(T1, { teamId: id, userId: U1, role: 'member' });
|
|
185
|
+
expect(await memberships.updateRole(T2, id, U1, 'admin')).toEqual({
|
|
186
|
+
kind: 'team-not-found',
|
|
187
|
+
});
|
|
147
188
|
});
|
|
148
189
|
|
|
149
190
|
it('updateRole on existing membership mutates the role', async () => {
|
|
150
191
|
const { teams, memberships } = makeBinding();
|
|
151
|
-
const id = await teams
|
|
192
|
+
const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
|
|
152
193
|
await memberships.add(T1, { teamId: id, userId: U1, role: 'member' });
|
|
153
|
-
await memberships.updateRole(T1, id, U1, 'admin');
|
|
194
|
+
expect(await memberships.updateRole(T1, id, U1, 'admin')).toEqual({ kind: 'ok' });
|
|
154
195
|
const page = await memberships.list(T1, id, {});
|
|
155
196
|
expect(page.items[0]?.role).toBe('admin');
|
|
156
197
|
});
|
|
@@ -159,9 +200,9 @@ export function runTeamBindingConformance(
|
|
|
159
200
|
describe(`${label} — listForUser across teams`, () => {
|
|
160
201
|
it('returns every team the user belongs to in the tenant', async () => {
|
|
161
202
|
const { teams, memberships } = makeBinding();
|
|
162
|
-
const a = await teams
|
|
163
|
-
const b = await teams
|
|
164
|
-
const c = await teams
|
|
203
|
+
const a = await createTeam(teams, T1, { name: 'A', slug: 'a' });
|
|
204
|
+
const b = await createTeam(teams, T1, { name: 'B', slug: 'b' });
|
|
205
|
+
const c = await createTeam(teams, T1, { name: 'C', slug: 'c' });
|
|
165
206
|
await memberships.add(T1, { teamId: a, userId: U1, role: 'member' });
|
|
166
207
|
await memberships.add(T1, { teamId: b, userId: U1, role: 'admin' });
|
|
167
208
|
await memberships.add(T1, { teamId: c, userId: U2, role: 'member' });
|
|
@@ -172,4 +213,55 @@ export function runTeamBindingConformance(
|
|
|
172
213
|
expect(teamIds.has(b)).toBe(true);
|
|
173
214
|
});
|
|
174
215
|
});
|
|
216
|
+
|
|
217
|
+
describe(`${label} — slug uniqueness`, () => {
|
|
218
|
+
it('create with a slug the tenant already has → slug-conflict', async () => {
|
|
219
|
+
const { teams } = makeBinding();
|
|
220
|
+
await createTeam(teams, T1, { name: 'Litigation', slug: 'lit' });
|
|
221
|
+
expect(await teams.create(T1, { name: 'Litigation 2', slug: 'lit' })).toEqual({
|
|
222
|
+
kind: 'slug-conflict',
|
|
223
|
+
slug: 'lit',
|
|
224
|
+
});
|
|
225
|
+
expect((await teams.list(T1, {})).items).toHaveLength(1);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('another tenant may use the same slug', async () => {
|
|
229
|
+
const { teams } = makeBinding();
|
|
230
|
+
await createTeam(teams, T1, { name: 'Litigation', slug: 'lit' });
|
|
231
|
+
expect((await teams.create(T2, { name: 'Litigation', slug: 'lit' })).kind).toBe('ok');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("update to another team's slug → slug-conflict; the team is unchanged", async () => {
|
|
235
|
+
const { teams } = makeBinding();
|
|
236
|
+
await createTeam(teams, T1, { name: 'A', slug: 'a' });
|
|
237
|
+
const id = await createTeam(teams, T1, { name: 'B', slug: 'b' });
|
|
238
|
+
expect(await teams.update(T1, id, { slug: 'a' })).toEqual({
|
|
239
|
+
kind: 'slug-conflict',
|
|
240
|
+
slug: 'a',
|
|
241
|
+
});
|
|
242
|
+
expect((await teams.get(T1, id))?.slug).toBe('b');
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
if (options.checksOrgs === true) {
|
|
247
|
+
describe(`${label} — an org that isn't the tenant's (T220)`, () => {
|
|
248
|
+
it('creating a team in it: org-not-found, and nothing is created', async () => {
|
|
249
|
+
const { teams } = makeBinding();
|
|
250
|
+
expect(
|
|
251
|
+
await teams.create(T1, { name: 'T', slug: 't', orgId: CONFORMANCE_MISSING_ORG }),
|
|
252
|
+
).toEqual({ kind: 'org-not-found', orgId: CONFORMANCE_MISSING_ORG });
|
|
253
|
+
expect((await teams.list(T1, { orgId: CONFORMANCE_MISSING_ORG })).items).toEqual([]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('moving a team to it: org-not-found, and the team stays where it was', async () => {
|
|
257
|
+
const { teams } = makeBinding();
|
|
258
|
+
const id = await createTeam(teams, T1, { name: 'T', slug: 't', orgId: ORG_A });
|
|
259
|
+
expect(await teams.update(T1, id, { orgId: CONFORMANCE_MISSING_ORG })).toEqual({
|
|
260
|
+
kind: 'org-not-found',
|
|
261
|
+
orgId: CONFORMANCE_MISSING_ORG,
|
|
262
|
+
});
|
|
263
|
+
expect((await teams.get(T1, id))?.orgId).toBe(ORG_A);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
}
|
|
175
267
|
}
|