@cosmicdrift/kumiko-bundled-features 0.245.0 → 0.247.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/package.json +9 -9
- package/src/custom-fields/lib/define-or-resurrect.ts +2 -2
- package/src/folders/__tests__/folders-parent-visibility.integration.test.ts +238 -0
- package/src/folders/__tests__/folders.integration.test.ts +96 -36
- package/src/folders/changes.json +9 -1
- package/src/folders/handlers/clear-folder.write.ts +24 -0
- package/src/folders/handlers/set-folder.write.ts +24 -0
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +30 -6
- package/src/notes-history/handlers/add-note.write.ts +2 -3
- package/src/shared/index.ts +1 -0
- package/src/{notes-history → shared}/parent-visibility.ts +6 -4
- package/src/tags/__tests__/tags-parent-visibility.integration.test.ts +219 -0
- package/src/tags/__tests__/tags.integration.test.ts +112 -38
- package/src/tags/changes.json +9 -1
- package/src/tags/handlers/assign-tag.write.ts +24 -0
- package/src/tags/handlers/remove-tag.write.ts +24 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.247.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -130,12 +130,12 @@
|
|
|
130
130
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
131
131
|
},
|
|
132
132
|
"dependencies": {
|
|
133
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
133
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.247.0",
|
|
134
|
+
"@cosmicdrift/kumiko-framework": "0.247.0",
|
|
135
|
+
"@cosmicdrift/kumiko-headless": "0.247.0",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer": "0.247.0",
|
|
137
|
+
"@cosmicdrift/kumiko-renderer-web": "0.247.0",
|
|
138
|
+
"@cosmicdrift/kumiko-types": "0.247.0",
|
|
139
139
|
"@mollie/api-client": "^4.5.0",
|
|
140
140
|
"@node-rs/argon2": "^2.0.2",
|
|
141
141
|
"@types/mailparser": "^3.4.6",
|
|
@@ -164,7 +164,7 @@
|
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@testing-library/user-event": "^14.6.1",
|
|
166
166
|
"@types/qrcode": "^1.5.5",
|
|
167
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
168
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
167
|
+
"@cosmicdrift/kumiko-locale-de": "0.247.0",
|
|
168
|
+
"@cosmicdrift/kumiko-locale-es": "0.247.0"
|
|
169
169
|
}
|
|
170
170
|
}
|
|
@@ -20,8 +20,8 @@ type WriteResult = Awaited<ReturnType<typeof fieldDefinitionExecutor.create>>;
|
|
|
20
20
|
// restore-before-create matters: a create() version_conflict aborts the
|
|
21
21
|
// surrounding tx, so a follow-up restore()/update() on the same connection would
|
|
22
22
|
// fail with "current transaction is aborted". detail() (a read) + restore()
|
|
23
|
-
// (which sees soft-deleted rows via
|
|
24
|
-
// the tx clean until the single terminal write.
|
|
23
|
+
// (which sees soft-deleted rows via a tenant-scoped fetch and only writes on
|
|
24
|
+
// success) keep the tx clean until the single terminal write.
|
|
25
25
|
export async function defineOrResurrectFieldDefinition(
|
|
26
26
|
aggregateId: string,
|
|
27
27
|
columns: FieldDefinitionColumns,
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// set-folder/clear-folder previously took entityType/entityId straight from the
|
|
2
|
+
// client, so any dispatch-eligible tenant user could file — or unfile — an
|
|
3
|
+
// object they had no read access to. The folder-assignment aggregate-id is
|
|
4
|
+
// derived from the tenant, so this was never a cross-tenant hole; it was "file
|
|
5
|
+
// an object inside your own tenant that you aren't allowed to read", which also
|
|
6
|
+
// moves it out of whatever folder its own team had put it in.
|
|
7
|
+
//
|
|
8
|
+
// The check is unconditional (default-on, no opt-in): entityType must name a
|
|
9
|
+
// registered entity, and the host row must be visible through that entity's own
|
|
10
|
+
// read path (tenant scope plus its `access.read` ownership).
|
|
11
|
+
|
|
12
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
13
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
14
|
+
import {
|
|
15
|
+
createEntity,
|
|
16
|
+
createTextField,
|
|
17
|
+
defineFeature,
|
|
18
|
+
type EntityDefinition,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
21
|
+
import {
|
|
22
|
+
createTestUser,
|
|
23
|
+
setupTestStack,
|
|
24
|
+
type TestStack,
|
|
25
|
+
unsafeCreateEntityTable,
|
|
26
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
27
|
+
import { FoldersHandlers } from "../constants";
|
|
28
|
+
import { folderAssignmentEntity, folderEntity } from "../entity";
|
|
29
|
+
import { createFoldersFeature } from "../feature";
|
|
30
|
+
|
|
31
|
+
const PROJECT_TABLE = "folders_pv_test_projects";
|
|
32
|
+
|
|
33
|
+
// Team-scoped read ownership on the host entity itself — the caller sees a
|
|
34
|
+
// project row only if their `team` claim matches the row's team_id.
|
|
35
|
+
const projectOwnership: NonNullable<EntityDefinition["access"]> = {
|
|
36
|
+
read: {
|
|
37
|
+
TenantMember: {
|
|
38
|
+
kind: "where",
|
|
39
|
+
where: (user, ctx) => ({
|
|
40
|
+
sqlText: `${ctx.tableName}.team_id = $${ctx.paramStart}`,
|
|
41
|
+
params: [user.claims?.["team"] ?? null],
|
|
42
|
+
}),
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const projectEntity = createEntity({
|
|
48
|
+
table: PROJECT_TABLE,
|
|
49
|
+
fields: {
|
|
50
|
+
teamId: createTextField({ required: true, maxLength: 64 }),
|
|
51
|
+
name: createTextField({ required: true, maxLength: 64 }),
|
|
52
|
+
},
|
|
53
|
+
access: projectOwnership,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const hostFixturesFeature = defineFeature("folders-pv-test-fixtures", (r) => {
|
|
57
|
+
r.entity("project", projectEntity);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
type TestUser = ReturnType<typeof createTestUser>;
|
|
61
|
+
|
|
62
|
+
// Same tenant, different team claim.
|
|
63
|
+
const userA: TestUser = createTestUser({
|
|
64
|
+
id: 70,
|
|
65
|
+
roles: ["TenantMember"],
|
|
66
|
+
claims: { team: "team-a" },
|
|
67
|
+
});
|
|
68
|
+
const userB: TestUser = createTestUser({
|
|
69
|
+
id: 71,
|
|
70
|
+
roles: ["TenantMember"],
|
|
71
|
+
claims: { team: "team-b" },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const PROJECT_A = "c0000000-0000-4000-8000-000000000001";
|
|
75
|
+
// A second team-a project, dedicated to the clear-folder denial case so its
|
|
76
|
+
// membership stays independent of PROJECT_A's.
|
|
77
|
+
const PROJECT_A2 = "c0000000-0000-4000-8000-000000000002";
|
|
78
|
+
// Never filed anywhere — the "no assignment" half of the oracle check.
|
|
79
|
+
const PROJECT_A3 = "c0000000-0000-4000-8000-000000000003";
|
|
80
|
+
|
|
81
|
+
let stack: TestStack;
|
|
82
|
+
let folderId: string;
|
|
83
|
+
|
|
84
|
+
beforeAll(async () => {
|
|
85
|
+
stack = await setupTestStack({
|
|
86
|
+
features: [createFoldersFeature({ access: { openToAll: true } }), hostFixturesFeature],
|
|
87
|
+
});
|
|
88
|
+
await unsafeCreateEntityTable(stack.db, folderEntity);
|
|
89
|
+
await unsafeCreateEntityTable(stack.db, folderAssignmentEntity);
|
|
90
|
+
await unsafeCreateEntityTable(stack.db, projectEntity);
|
|
91
|
+
await createEventsTable(stack.db);
|
|
92
|
+
await asRawClient(stack.db).unsafe(
|
|
93
|
+
`INSERT INTO ${PROJECT_TABLE} (id, tenant_id, team_id, name) VALUES
|
|
94
|
+
($1, $2, 'team-a', 'Project A'),
|
|
95
|
+
($3, $2, 'team-a', 'Project A2'),
|
|
96
|
+
($4, $2, 'team-a', 'Project A3')`,
|
|
97
|
+
[PROJECT_A, userA.tenantId, PROJECT_A2, PROJECT_A3],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const folder = await stack.http.writeOk<{ id: string }>(
|
|
101
|
+
FoldersHandlers.createFolder,
|
|
102
|
+
{ name: "Inbox" },
|
|
103
|
+
userA,
|
|
104
|
+
);
|
|
105
|
+
folderId = folder.id;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
afterAll(async () => {
|
|
109
|
+
await stack.cleanup();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
async function activeAssignmentsFor(entityId: string): Promise<number> {
|
|
113
|
+
const rows = await asRawClient(stack.db).unsafe<{ n: number }>(
|
|
114
|
+
"SELECT count(*)::int AS n FROM read_folder_assignments WHERE entity_id = $1 AND is_deleted = FALSE",
|
|
115
|
+
[entityId],
|
|
116
|
+
);
|
|
117
|
+
return rows[0]?.n ?? 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function setFolder(entityType: string, entityId: string, user: TestUser) {
|
|
121
|
+
return stack.http.writeOk<{ id: string }>(
|
|
122
|
+
FoldersHandlers.setFolder,
|
|
123
|
+
{ folderId, entityType, entityId },
|
|
124
|
+
user,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function setFolderErr(entityType: string, entityId: string, user: TestUser) {
|
|
129
|
+
return stack.http.writeErr(FoldersHandlers.setFolder, { folderId, entityType, entityId }, user);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function clearFolder(entityType: string, entityId: string, user: TestUser) {
|
|
133
|
+
return stack.http.writeOk<{ id: string }>(
|
|
134
|
+
FoldersHandlers.clearFolder,
|
|
135
|
+
{ entityType, entityId },
|
|
136
|
+
user,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function clearFolderErr(entityType: string, entityId: string, user: TestUser) {
|
|
141
|
+
return stack.http.writeErr(FoldersHandlers.clearFolder, { entityType, entityId }, user);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
describe("folders integration — set-folder parent-visibility", () => {
|
|
145
|
+
test("a host row visible through the host entity's own read path can be filed", async () => {
|
|
146
|
+
const result = await setFolder("project", PROJECT_A, userA);
|
|
147
|
+
expect(result.id).toBeTruthy();
|
|
148
|
+
expect(await activeAssignmentsFor(PROJECT_A)).toBe(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("a host row from a foreign team is denied, and nothing is written", async () => {
|
|
152
|
+
const err = await setFolderErr("project", PROJECT_A2, userB);
|
|
153
|
+
expect(err.code).toBe("not_found");
|
|
154
|
+
expect(err.httpStatus).toBe(404);
|
|
155
|
+
expect(await activeAssignmentsFor(PROJECT_A2)).toBe(0);
|
|
156
|
+
|
|
157
|
+
// Positive control: PROJECT_A2 IS filable — by its own team — so the deny
|
|
158
|
+
// above is the team-claim check, not a row that doesn't exist at all.
|
|
159
|
+
const result = await setFolder("project", PROJECT_A2, userA);
|
|
160
|
+
expect(result.id).toBeTruthy();
|
|
161
|
+
expect(await activeAssignmentsFor(PROJECT_A2)).toBe(1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("the denial precedes the folder lookup: an unknown folderId reports the host row, not the folder", async () => {
|
|
165
|
+
// Gate first means a caller who can't see the host row learns nothing about
|
|
166
|
+
// which folder ids exist — both cases answer with the same not_found.
|
|
167
|
+
const unknownFolder = await stack.http.writeErr(
|
|
168
|
+
FoldersHandlers.setFolder,
|
|
169
|
+
{
|
|
170
|
+
folderId: "00000000-0000-4000-8000-00000000dead",
|
|
171
|
+
entityType: "project",
|
|
172
|
+
entityId: PROJECT_A3,
|
|
173
|
+
},
|
|
174
|
+
userB,
|
|
175
|
+
);
|
|
176
|
+
const knownFolder = await setFolderErr("project", PROJECT_A3, userB);
|
|
177
|
+
expect(unknownFolder.code).toBe(knownFolder.code);
|
|
178
|
+
expect(unknownFolder.httpStatus).toBe(knownFolder.httpStatus);
|
|
179
|
+
expect(knownFolder.code).toBe("not_found");
|
|
180
|
+
expect(await activeAssignmentsFor(PROJECT_A3)).toBe(0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("an entityType that is not a registered entity is denied", async () => {
|
|
184
|
+
const err = await setFolderErr("totally-unknown-entity", PROJECT_A, userA);
|
|
185
|
+
expect(err.code).toBe("not_found");
|
|
186
|
+
expect(err.httpStatus).toBe(404);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("a malformed entityId is denied cleanly, without poisoning the connection", async () => {
|
|
190
|
+
const err = await setFolderErr("project", "not-a-uuid", userA);
|
|
191
|
+
expect(err.code).toBe("not_found");
|
|
192
|
+
expect(err.httpStatus).toBe(404);
|
|
193
|
+
|
|
194
|
+
// The stack must still be usable — proves the malformed id was rejected
|
|
195
|
+
// before it reached a query, not caught after a tx-poisoning cast error.
|
|
196
|
+
expect((await setFolder("project", PROJECT_A, userA)).id).toBeTruthy();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("folders integration — clear-folder parent-visibility", () => {
|
|
201
|
+
test("a foreign team cannot unfile an existing membership; the row survives", async () => {
|
|
202
|
+
await setFolder("project", PROJECT_A2, userA);
|
|
203
|
+
expect(await activeAssignmentsFor(PROJECT_A2)).toBe(1);
|
|
204
|
+
|
|
205
|
+
const err = await clearFolderErr("project", PROJECT_A2, userB);
|
|
206
|
+
expect(err.code).toBe("not_found");
|
|
207
|
+
expect(err.httpStatus).toBe(404);
|
|
208
|
+
// Zero-write: the membership userA created is still active, not soft-deleted.
|
|
209
|
+
expect(await activeAssignmentsFor(PROJECT_A2)).toBe(1);
|
|
210
|
+
|
|
211
|
+
// Positive control: the owning team CAN unfile it.
|
|
212
|
+
await clearFolder("project", PROJECT_A2, userA);
|
|
213
|
+
expect(await activeAssignmentsFor(PROJECT_A2)).toBe(0);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("the denial carries no existence oracle: filed and unfiled rows answer alike", async () => {
|
|
217
|
+
await setFolder("project", PROJECT_A2, userA);
|
|
218
|
+
expect(await activeAssignmentsFor(PROJECT_A2)).toBe(1);
|
|
219
|
+
expect(await activeAssignmentsFor(PROJECT_A3)).toBe(0);
|
|
220
|
+
|
|
221
|
+
// clear-folder normally reports success for a never-filed entity, so without
|
|
222
|
+
// the gate running FIRST the two responses below would differ and leak
|
|
223
|
+
// whether the invisible host row sits in a folder at all.
|
|
224
|
+
const onFiled = await clearFolderErr("project", PROJECT_A2, userB);
|
|
225
|
+
const onUnfiled = await clearFolderErr("project", PROJECT_A3, userB);
|
|
226
|
+
expect(onFiled.code).toBe(onUnfiled.code);
|
|
227
|
+
expect(onFiled.httpStatus).toBe(onUnfiled.httpStatus);
|
|
228
|
+
expect(onFiled.code).toBe("not_found");
|
|
229
|
+
|
|
230
|
+
await clearFolder("project", PROJECT_A2, userA);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("an entityType that is not a registered entity is denied", async () => {
|
|
234
|
+
const err = await clearFolderErr("totally-unknown-entity", PROJECT_A, userA);
|
|
235
|
+
expect(err.code).toBe("not_found");
|
|
236
|
+
expect(err.httpStatus).toBe(404);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -8,9 +8,16 @@
|
|
|
8
8
|
// defining difference from tags' many-to-many
|
|
9
9
|
// - clear-folder unfiles; set → clear → set resurrects the deterministic stream
|
|
10
10
|
// - referential integrity (unknown folderId rejected) + multi-tenant isolation
|
|
11
|
+
//
|
|
12
|
+
// set-folder/clear-folder verify that entityType names a registered entity and
|
|
13
|
+
// that the host row is visible to the caller. This file's assertions are about
|
|
14
|
+
// membership mechanics, not parent ownership, so the `credit` fixture below
|
|
15
|
+
// stays unrestricted (no `access`) on purpose — every host row this file files
|
|
16
|
+
// is seeded in beforeAll.
|
|
11
17
|
|
|
12
18
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
13
19
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
20
|
+
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
21
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
15
22
|
import {
|
|
16
23
|
createTestUser,
|
|
@@ -18,6 +25,7 @@ import {
|
|
|
18
25
|
type TestStack,
|
|
19
26
|
unsafeCreateEntityTable,
|
|
20
27
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
28
|
+
import { v5 as uuidv5 } from "uuid";
|
|
21
29
|
import { folderAssignmentDeleteHook, folderDeleteHook } from "../../folders-user-data/hooks";
|
|
22
30
|
import { FoldersHandlers, FoldersQueries } from "../constants";
|
|
23
31
|
import { folderAssignmentEntity, folderEntity } from "../entity";
|
|
@@ -25,13 +33,65 @@ import { createFoldersFeature } from "../feature";
|
|
|
25
33
|
|
|
26
34
|
const foldersFeature = createFoldersFeature();
|
|
27
35
|
|
|
36
|
+
const CREDIT_TABLE = "folders_test_credits";
|
|
37
|
+
const creditEntity = createEntity({
|
|
38
|
+
table: CREDIT_TABLE,
|
|
39
|
+
fields: { name: createTextField({ required: true, maxLength: 64 }) },
|
|
40
|
+
});
|
|
41
|
+
const hostFixturesFeature = defineFeature("folders-test-host-fixtures", (r) => {
|
|
42
|
+
r.entity("credit", creditEntity);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// The parent entity's ids are uuid-shaped, but the readable labels below carry
|
|
46
|
+
// the intent of each case — derive one from the other instead of scattering
|
|
47
|
+
// opaque uuid literals through the assertions.
|
|
48
|
+
const HOST_FIXTURE_NAMESPACE = "2c8a5f13-7d64-4b90-ae52-0f3d6c9b81e4";
|
|
49
|
+
function hostId(label: string): string {
|
|
50
|
+
return uuidv5(label, HOST_FIXTURE_NAMESPACE);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const CREDIT_LABELS = [
|
|
54
|
+
"credit-1",
|
|
55
|
+
"credit-2",
|
|
56
|
+
"credit-3",
|
|
57
|
+
"credit-8",
|
|
58
|
+
"credit-never",
|
|
59
|
+
"credit-r",
|
|
60
|
+
"credit-x",
|
|
61
|
+
"credit-occupied",
|
|
62
|
+
"credit-occupied-2",
|
|
63
|
+
"credit-erase",
|
|
64
|
+
"c-y",
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
async function seedHostRows(
|
|
68
|
+
target: TestStack,
|
|
69
|
+
labels: readonly string[],
|
|
70
|
+
tenantId: string,
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
for (const label of labels) {
|
|
73
|
+
await asRawClient(target.db).unsafe(
|
|
74
|
+
`INSERT INTO ${CREDIT_TABLE} (id, tenant_id, name) VALUES ($1, $2, $3)`,
|
|
75
|
+
[hostId(label), tenantId, label],
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
28
80
|
let stack: TestStack;
|
|
29
81
|
|
|
82
|
+
const admin = createTestUser({ roles: ["TenantAdmin"] });
|
|
83
|
+
const otherTenant = createTestUser({
|
|
84
|
+
roles: ["TenantAdmin"],
|
|
85
|
+
tenantId: "00000000-0000-4000-8000-0000000000aa",
|
|
86
|
+
});
|
|
87
|
+
|
|
30
88
|
beforeAll(async () => {
|
|
31
|
-
stack = await setupTestStack({ features: [foldersFeature] });
|
|
89
|
+
stack = await setupTestStack({ features: [foldersFeature, hostFixturesFeature] });
|
|
32
90
|
await unsafeCreateEntityTable(stack.db, folderEntity);
|
|
33
91
|
await unsafeCreateEntityTable(stack.db, folderAssignmentEntity);
|
|
92
|
+
await unsafeCreateEntityTable(stack.db, creditEntity);
|
|
34
93
|
await createEventsTable(stack.db);
|
|
94
|
+
await seedHostRows(stack, CREDIT_LABELS, admin.tenantId);
|
|
35
95
|
});
|
|
36
96
|
|
|
37
97
|
afterAll(async () => {
|
|
@@ -44,12 +104,6 @@ beforeEach(async () => {
|
|
|
44
104
|
await asRawClient(stack.db).unsafe("DELETE FROM read_folder_assignments");
|
|
45
105
|
});
|
|
46
106
|
|
|
47
|
-
const admin = createTestUser({ roles: ["TenantAdmin"] });
|
|
48
|
-
const otherTenant = createTestUser({
|
|
49
|
-
roles: ["TenantAdmin"],
|
|
50
|
-
tenantId: "00000000-0000-4000-8000-0000000000aa",
|
|
51
|
-
});
|
|
52
|
-
|
|
53
107
|
async function createFolder(name: string, parentId?: string, user = admin): Promise<string> {
|
|
54
108
|
const payload = parentId === undefined ? { name } : { name, parentId };
|
|
55
109
|
const folder = await stack.http.writeOk<{ id: string }>(
|
|
@@ -126,9 +180,9 @@ describe("folders integration — catalog (tree)", () => {
|
|
|
126
180
|
describe("folders integration — single-membership set / move / clear", () => {
|
|
127
181
|
test("set-folder files an entity; assignment is queryable", async () => {
|
|
128
182
|
const f = await createFolder("Gruppe A");
|
|
129
|
-
await setFolder(f, "credit-1");
|
|
183
|
+
await setFolder(f, hostId("credit-1"));
|
|
130
184
|
|
|
131
|
-
const rows = await assignmentsOf("credit-1");
|
|
185
|
+
const rows = await assignmentsOf(hostId("credit-1"));
|
|
132
186
|
expect(rows).toHaveLength(1);
|
|
133
187
|
expect(rows[0]?.["folderId"]).toBe(f);
|
|
134
188
|
expect(rows[0]?.["entityType"]).toBe("credit");
|
|
@@ -137,56 +191,56 @@ describe("folders integration — single-membership set / move / clear", () => {
|
|
|
137
191
|
test("re-setting to a different folder MOVES (still exactly one row)", async () => {
|
|
138
192
|
const a = await createFolder("A");
|
|
139
193
|
const b = await createFolder("B");
|
|
140
|
-
await setFolder(a, "credit-1");
|
|
194
|
+
await setFolder(a, hostId("credit-1"));
|
|
141
195
|
expect(await countAssignments(admin.tenantId)).toBe(1);
|
|
142
196
|
|
|
143
|
-
await setFolder(b, "credit-1"); // MOVE, not a second assignment
|
|
197
|
+
await setFolder(b, hostId("credit-1")); // MOVE, not a second assignment
|
|
144
198
|
expect(await countAssignments(admin.tenantId)).toBe(1);
|
|
145
|
-
const rows = await assignmentsOf("credit-1");
|
|
199
|
+
const rows = await assignmentsOf(hostId("credit-1"));
|
|
146
200
|
expect(rows).toHaveLength(1);
|
|
147
201
|
expect(rows[0]?.["folderId"]).toBe(b);
|
|
148
202
|
});
|
|
149
203
|
|
|
150
204
|
test("set the same folder twice is an idempotent no-op (one row)", async () => {
|
|
151
205
|
const f = await createFolder("dup");
|
|
152
|
-
await setFolder(f, "credit-2");
|
|
153
|
-
await setFolder(f, "credit-2");
|
|
206
|
+
await setFolder(f, hostId("credit-2"));
|
|
207
|
+
await setFolder(f, hostId("credit-2"));
|
|
154
208
|
expect(await countAssignments(admin.tenantId)).toBe(1);
|
|
155
209
|
});
|
|
156
210
|
|
|
157
211
|
test("clear-folder unfiles the entity", async () => {
|
|
158
212
|
const f = await createFolder("temp");
|
|
159
|
-
await setFolder(f, "credit-3");
|
|
213
|
+
await setFolder(f, hostId("credit-3"));
|
|
160
214
|
expect(await countAssignments(admin.tenantId)).toBe(1);
|
|
161
215
|
|
|
162
|
-
await clearFolder("credit-3");
|
|
216
|
+
await clearFolder(hostId("credit-3"));
|
|
163
217
|
expect(await countAssignments(admin.tenantId)).toBe(0);
|
|
164
|
-
expect(await assignmentsOf("credit-3")).toHaveLength(0);
|
|
218
|
+
expect(await assignmentsOf(hostId("credit-3"))).toHaveLength(0);
|
|
165
219
|
});
|
|
166
220
|
|
|
167
221
|
test("clearing a never-filed entity succeeds (idempotent end-state)", async () => {
|
|
168
|
-
await clearFolder("credit-never");
|
|
222
|
+
await clearFolder(hostId("credit-never"));
|
|
169
223
|
expect(await countAssignments(admin.tenantId)).toBe(0);
|
|
170
224
|
});
|
|
171
225
|
|
|
172
226
|
test("set → clear → set resurrects the same deterministic stream", async () => {
|
|
173
227
|
const f = await createFolder("recurring");
|
|
174
|
-
await setFolder(f, "credit-r");
|
|
175
|
-
await clearFolder("credit-r");
|
|
228
|
+
await setFolder(f, hostId("credit-r"));
|
|
229
|
+
await clearFolder(hostId("credit-r"));
|
|
176
230
|
expect(await countAssignments(admin.tenantId)).toBe(0);
|
|
177
231
|
|
|
178
|
-
await setFolder(f, "credit-r"); // restore, not 409
|
|
232
|
+
await setFolder(f, hostId("credit-r")); // restore, not 409
|
|
179
233
|
expect(await countAssignments(admin.tenantId)).toBe(1);
|
|
180
|
-
expect((await assignmentsOf("credit-r"))[0]?.["folderId"]).toBe(f);
|
|
234
|
+
expect((await assignmentsOf(hostId("credit-r")))[0]?.["folderId"]).toBe(f);
|
|
181
235
|
});
|
|
182
236
|
|
|
183
237
|
test("set → clear → set into a DIFFERENT folder lands in the new folder", async () => {
|
|
184
238
|
const a = await createFolder("A");
|
|
185
239
|
const b = await createFolder("B");
|
|
186
|
-
await setFolder(a, "credit-x");
|
|
187
|
-
await clearFolder("credit-x");
|
|
188
|
-
await setFolder(b, "credit-x"); // restore + update folderId to b
|
|
189
|
-
const rows = await assignmentsOf("credit-x");
|
|
240
|
+
await setFolder(a, hostId("credit-x"));
|
|
241
|
+
await clearFolder(hostId("credit-x"));
|
|
242
|
+
await setFolder(b, hostId("credit-x")); // restore + update folderId to b
|
|
243
|
+
const rows = await assignmentsOf(hostId("credit-x"));
|
|
190
244
|
expect(rows).toHaveLength(1);
|
|
191
245
|
expect(rows[0]?.["folderId"]).toBe(b);
|
|
192
246
|
});
|
|
@@ -195,7 +249,7 @@ describe("folders integration — single-membership set / move / clear", () => {
|
|
|
195
249
|
describe("folders integration — delete blocks on live assignments (658/1)", () => {
|
|
196
250
|
test("deleting a folder that still holds an assignment is rejected, row survives", async () => {
|
|
197
251
|
const f = await createFolder("occupied");
|
|
198
|
-
await setFolder(f, "credit-occupied");
|
|
252
|
+
await setFolder(f, hostId("credit-occupied"));
|
|
199
253
|
|
|
200
254
|
const err = await stack.http.writeErr(FoldersHandlers.deleteFolder, { id: f }, admin);
|
|
201
255
|
expect(err.httpStatus).toBe(422);
|
|
@@ -210,8 +264,8 @@ describe("folders integration — delete blocks on live assignments (658/1)", ()
|
|
|
210
264
|
|
|
211
265
|
test("clearing the assignment first unblocks the delete", async () => {
|
|
212
266
|
const f = await createFolder("occupied-then-cleared");
|
|
213
|
-
await setFolder(f, "credit-occupied-2");
|
|
214
|
-
await clearFolder("credit-occupied-2");
|
|
267
|
+
await setFolder(f, hostId("credit-occupied-2"));
|
|
268
|
+
await clearFolder(hostId("credit-occupied-2"));
|
|
215
269
|
|
|
216
270
|
await stack.http.writeOk(FoldersHandlers.deleteFolder, { id: f }, admin);
|
|
217
271
|
expect((await listFolders()).some((row) => row["id"] === f)).toBe(false);
|
|
@@ -240,7 +294,11 @@ describe("folders integration — referential integrity", () => {
|
|
|
240
294
|
test("set-folder with an unknown folderId is rejected (no dangling assignment)", async () => {
|
|
241
295
|
const err = await stack.http.writeErr(
|
|
242
296
|
FoldersHandlers.setFolder,
|
|
243
|
-
{
|
|
297
|
+
{
|
|
298
|
+
folderId: "00000000-0000-4000-8000-00000000dead",
|
|
299
|
+
entityType: "credit",
|
|
300
|
+
entityId: hostId("c-y"),
|
|
301
|
+
},
|
|
244
302
|
admin,
|
|
245
303
|
);
|
|
246
304
|
expect(err.httpStatus).toBe(404);
|
|
@@ -251,10 +309,10 @@ describe("folders integration — referential integrity", () => {
|
|
|
251
309
|
describe("folders integration — multi-tenant isolation", () => {
|
|
252
310
|
test("tenant B sees neither tenant A's folders nor assignments", async () => {
|
|
253
311
|
const f = await createFolder("A-only", undefined, admin);
|
|
254
|
-
await setFolder(f, "credit-8", admin);
|
|
312
|
+
await setFolder(f, hostId("credit-8"), admin);
|
|
255
313
|
|
|
256
314
|
expect(await listFolders(otherTenant)).toHaveLength(0);
|
|
257
|
-
expect(await assignmentsOf("credit-8", otherTenant)).toHaveLength(0);
|
|
315
|
+
expect(await assignmentsOf(hostId("credit-8"), otherTenant)).toHaveLength(0);
|
|
258
316
|
|
|
259
317
|
expect(await listFolders(admin)).toHaveLength(1);
|
|
260
318
|
expect(await countAssignments(admin.tenantId)).toBe(1);
|
|
@@ -271,11 +329,13 @@ describe("folders integration — openToAll access model", () => {
|
|
|
271
329
|
|
|
272
330
|
beforeAll(async () => {
|
|
273
331
|
openStack = await setupTestStack({
|
|
274
|
-
features: [createFoldersFeature({ access: { openToAll: true } })],
|
|
332
|
+
features: [createFoldersFeature({ access: { openToAll: true } }), hostFixturesFeature],
|
|
275
333
|
});
|
|
276
334
|
await unsafeCreateEntityTable(openStack.db, folderEntity);
|
|
277
335
|
await unsafeCreateEntityTable(openStack.db, folderAssignmentEntity);
|
|
336
|
+
await unsafeCreateEntityTable(openStack.db, creditEntity);
|
|
278
337
|
await createEventsTable(openStack.db);
|
|
338
|
+
await seedHostRows(openStack, ["c-1"], unprivileged.tenantId);
|
|
279
339
|
});
|
|
280
340
|
|
|
281
341
|
afterAll(async () => {
|
|
@@ -290,7 +350,7 @@ describe("folders integration — openToAll access model", () => {
|
|
|
290
350
|
);
|
|
291
351
|
await openStack.http.writeOk(
|
|
292
352
|
FoldersHandlers.setFolder,
|
|
293
|
-
{ folderId: folder.id, entityType: "credit", entityId: "c-1" },
|
|
353
|
+
{ folderId: folder.id, entityType: "credit", entityId: hostId("c-1") },
|
|
294
354
|
unprivileged,
|
|
295
355
|
);
|
|
296
356
|
const folders = await openStack.http.queryOk<{ rows: unknown[] }>(
|
|
@@ -301,7 +361,7 @@ describe("folders integration — openToAll access model", () => {
|
|
|
301
361
|
expect(folders.rows).toHaveLength(1);
|
|
302
362
|
await openStack.http.writeOk(
|
|
303
363
|
FoldersHandlers.clearFolder,
|
|
304
|
-
{ entityType: "credit", entityId: "c-1" },
|
|
364
|
+
{ entityType: "credit", entityId: hostId("c-1") },
|
|
305
365
|
unprivileged,
|
|
306
366
|
);
|
|
307
367
|
});
|
|
@@ -324,7 +384,7 @@ describe("folders integration — openToAll access model", () => {
|
|
|
324
384
|
describe("folders-user-data — tenantScopedDelete hooks", () => {
|
|
325
385
|
async function seedOneFolderWithAssignment(): Promise<void> {
|
|
326
386
|
const f = await createFolder("to-be-erased");
|
|
327
|
-
await setFolder(f, "credit-erase");
|
|
387
|
+
await setFolder(f, hostId("credit-erase"));
|
|
328
388
|
}
|
|
329
389
|
|
|
330
390
|
test("multi-user tenant: no-op, rows survive", async () => {
|
package/src/folders/changes.json
CHANGED
|
@@ -1 +1,9 @@
|
|
|
1
|
-
[
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.246.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "set-folder and clear-folder now verify entityType/entityId against the host entity's own read path",
|
|
6
|
+
"detail": "Before this change, set-folder and clear-folder took entityType/entityId straight from the client with no check — any dispatch-eligible tenant user could file, move or unfile any object in their tenant, including one they could not otherwise read, and moving it out of whatever folder its owning team had put it in. The folder-assignment aggregate-id is derived from the tenant, so this was never a cross-tenant hole; it was filing inside your own tenant across a row-level ownership boundary. Both handlers now unconditionally verify that entityType names a registered entity and that the host row is visible to the caller through that entity's own read path (tenant scope plus its `access.read` ownership) before touching the membership; either check failing returns a not_found response, the same answer a genuinely missing row gets, so nothing doubles as an existence oracle. The check runs before the folder lookup, so a denied caller learns nothing about which folder ids exist. This mirrors the add-note gate shipped in 0.244.0 and shares its helper.",
|
|
7
|
+
"migration": "entityType must now name an entity registered in the same app (via r.entity(...) or a bundled feature), and the caller must be able to see the target row through that entity's own read path. Any consumer that called set-folder or clear-folder with an entityType that is NOT a registered entity name, or with an entityId the caller could not otherwise read, will now get a not_found error instead of a successful write — register the host entity (and, if it needs row-level ownership, its `access.read` rule) before upgrading. Note the consequence for hard-deleted host rows: once the host row is gone, its folder-assignment can no longer be cleared, and folder:delete keeps refusing while that assignment points at the folder."
|
|
8
|
+
}
|
|
9
|
+
]
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
import { parentRowIsVisible } from "../../shared";
|
|
2
4
|
import { folderAssignmentAggregateId } from "../aggregate-id";
|
|
3
5
|
import { DEFAULT_FOLDER_ACCESS } from "../constants";
|
|
4
6
|
import { folderAssignmentExecutor } from "../executor";
|
|
@@ -7,6 +9,10 @@ import { type ClearFolderPayload, clearFolderPayloadSchema } from "../schemas";
|
|
|
7
9
|
// clear-folder — removes a host entity from its folder (back to "unfiled").
|
|
8
10
|
// Idempotent: clearing an entity that isn't in any folder is already the
|
|
9
11
|
// requested end state, so we pre-check and return success without a delete.
|
|
12
|
+
//
|
|
13
|
+
// Host reference: entityType/entityId are client input, so the caller must be
|
|
14
|
+
// able to see the host row before unfiling it — see
|
|
15
|
+
// shared/parent-visibility.ts.
|
|
10
16
|
export function createClearFolderHandler(
|
|
11
17
|
access: AccessRule = DEFAULT_FOLDER_ACCESS,
|
|
12
18
|
): WriteHandlerDef {
|
|
@@ -18,6 +24,24 @@ export function createClearFolderHandler(
|
|
|
18
24
|
"Unfiles a host entity so it sits in no folder at all, reporting success when it was already unfiled; use it to take an entity out of its folder without touching the folder itself.",
|
|
19
25
|
handler: async (event, ctx) => {
|
|
20
26
|
const payload = event.payload as ClearFolderPayload; // @cast-boundary engine-payload
|
|
27
|
+
// entityType/entityId are client input: the caller must be able to see
|
|
28
|
+
// the host row through that entity's own read path (tenant scope plus its
|
|
29
|
+
// `access.read` ownership) before its assignment row may be written.
|
|
30
|
+
// Checked FIRST, ahead of every lookup below, so a denied caller can't tell
|
|
31
|
+
// an invisible parent apart from a missing assignment or an unknown folder —
|
|
32
|
+
// every path answers with the same NotFoundError.
|
|
33
|
+
if (
|
|
34
|
+
!(await parentRowIsVisible(
|
|
35
|
+
ctx.registry,
|
|
36
|
+
payload.entityType,
|
|
37
|
+
payload.entityId,
|
|
38
|
+
event.user,
|
|
39
|
+
ctx.db,
|
|
40
|
+
))
|
|
41
|
+
) {
|
|
42
|
+
return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
|
|
43
|
+
}
|
|
44
|
+
|
|
21
45
|
const id = folderAssignmentAggregateId(
|
|
22
46
|
event.user.tenantId,
|
|
23
47
|
payload.entityType,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
import { parentRowIsVisible } from "../../shared";
|
|
3
4
|
import { folderAssignmentAggregateId } from "../aggregate-id";
|
|
4
5
|
import { DEFAULT_FOLDER_ACCESS } from "../constants";
|
|
5
6
|
import { folderAssignmentExecutor, folderExecutor } from "../executor";
|
|
@@ -23,6 +24,11 @@ import { type SetFolderPayload, setFolderPayloadSchema } from "../schemas";
|
|
|
23
24
|
// Referential integrity: there is no FK (event-sourced, no JOIN), so we verify
|
|
24
25
|
// the target folder exists before writing — a malformed call with an unknown
|
|
25
26
|
// folderId would otherwise point an entity at a phantom folder.
|
|
27
|
+
//
|
|
28
|
+
// Host reference: entityType/entityId are never trusted client input either —
|
|
29
|
+
// the aggregate-id is tenant-scoped, so this is not a cross-tenant hole, but
|
|
30
|
+
// without the check any dispatch-eligible tenant user could file an object they
|
|
31
|
+
// are not allowed to read. See shared/parent-visibility.ts.
|
|
26
32
|
export function createSetFolderHandler(
|
|
27
33
|
access: AccessRule = DEFAULT_FOLDER_ACCESS,
|
|
28
34
|
): WriteHandlerDef {
|
|
@@ -34,6 +40,24 @@ export function createSetFolderHandler(
|
|
|
34
40
|
"Files a host entity into one existing folder and moves it out of whatever folder it was in, since an entity belongs to at most one folder; use it for both the first filing and every later move.",
|
|
35
41
|
handler: async (event, ctx) => {
|
|
36
42
|
const payload = event.payload as SetFolderPayload; // @cast-boundary engine-payload
|
|
43
|
+
// entityType/entityId are client input: the caller must be able to see
|
|
44
|
+
// the host row through that entity's own read path (tenant scope plus its
|
|
45
|
+
// `access.read` ownership) before its assignment row may be written.
|
|
46
|
+
// Checked FIRST, ahead of every lookup below, so a denied caller can't tell
|
|
47
|
+
// an invisible parent apart from a missing assignment or an unknown folder —
|
|
48
|
+
// every path answers with the same NotFoundError.
|
|
49
|
+
if (
|
|
50
|
+
!(await parentRowIsVisible(
|
|
51
|
+
ctx.registry,
|
|
52
|
+
payload.entityType,
|
|
53
|
+
payload.entityId,
|
|
54
|
+
event.user,
|
|
55
|
+
ctx.db,
|
|
56
|
+
))
|
|
57
|
+
) {
|
|
58
|
+
return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
|
|
59
|
+
}
|
|
60
|
+
|
|
37
61
|
const id = folderAssignmentAggregateId(
|
|
38
62
|
event.user.tenantId,
|
|
39
63
|
payload.entityType,
|