@cosmicdrift/kumiko-bundled-features 0.244.0 → 0.246.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.
Files changed (32) hide show
  1. package/package.json +9 -9
  2. package/src/auth-mfa-user-data/hooks.ts +2 -1
  3. package/src/custom-fields/lib/define-or-resurrect.ts +2 -2
  4. package/src/folders/__tests__/folders-parent-visibility.integration.test.ts +238 -0
  5. package/src/folders/__tests__/folders.integration.test.ts +96 -36
  6. package/src/folders/changes.json +9 -1
  7. package/src/folders/handlers/clear-folder.write.ts +24 -0
  8. package/src/folders/handlers/set-folder.write.ts +24 -0
  9. package/src/folders-user-data/__tests__/hooks.integration.test.ts +30 -6
  10. package/src/notes-history/__tests__/add-note-author-name-kms.integration.test.ts +30 -5
  11. package/src/notes-history/__tests__/notes-history-ownership.integration.test.ts +144 -14
  12. package/src/notes-history/__tests__/notes-history-parent-visibility.integration.test.ts +225 -0
  13. package/src/notes-history/__tests__/notes-history.integration.test.ts +71 -28
  14. package/src/notes-history/changes.json +7 -0
  15. package/src/notes-history/feature.ts +34 -6
  16. package/src/notes-history/handlers/add-note.write.ts +27 -1
  17. package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +29 -3
  18. package/src/shared/assert-erased.ts +15 -0
  19. package/src/shared/has-where-rule.ts +5 -8
  20. package/src/shared/index.ts +2 -0
  21. package/src/shared/parent-visibility.ts +48 -0
  22. package/src/tags/__tests__/tags-ownership.test.ts +6 -7
  23. package/src/tags/__tests__/tags-parent-visibility.integration.test.ts +219 -0
  24. package/src/tags/__tests__/tags.integration.test.ts +112 -38
  25. package/src/tags/changes.json +9 -1
  26. package/src/tags/feature.ts +3 -2
  27. package/src/tags/handlers/assign-tag.write.ts +24 -0
  28. package/src/tags/handlers/remove-tag.write.ts +24 -0
  29. package/src/user-data-rights-defaults/hooks/config-value.userdata-hook.ts +2 -1
  30. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +2 -1
  31. package/src/user-data-rights-defaults/hooks/notification-preference.userdata-hook.ts +2 -1
  32. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +2 -2
@@ -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,
@@ -3,8 +3,15 @@
3
3
  // via selectMany without an isDeleted filter — folderAssignmentEntity is
4
4
  // softDelete: true, so a cleared assignment (clear-folder) still had a row
5
5
  // in read_folder_assignments and rode along in the export.
6
+ //
7
+ // set-folder/clear-folder verify that entityType names a registered entity and
8
+ // that the host row is visible to the caller, so the two credits below are a
9
+ // real registered fixture. The assertion is about the export hook's isDeleted
10
+ // filter, not about ownership — the fixture is deliberately unrestricted.
6
11
 
7
12
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
14
+ import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
8
15
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
9
16
  import {
10
17
  createTestUser,
@@ -16,14 +23,31 @@ import { createFoldersFeature, FoldersHandlers } from "../../folders";
16
23
  import { folderAssignmentEntity, folderEntity } from "../../folders/entity";
17
24
  import { folderAssignmentExportHook } from "../hooks";
18
25
 
26
+ const CREDIT_TABLE = "folders_export_test_credits";
27
+ const creditEntity = createEntity({
28
+ table: CREDIT_TABLE,
29
+ fields: { name: createTextField({ required: true, maxLength: 64 }) },
30
+ });
31
+ const hostFixturesFeature = defineFeature("folders-export-test-host-fixtures", (r) => {
32
+ r.entity("credit", creditEntity);
33
+ });
34
+
35
+ const CREDIT_KEPT = "d0000000-0000-4000-8000-000000000001";
36
+ const CREDIT_CLEARED = "d0000000-0000-4000-8000-000000000002";
37
+
19
38
  let stack: TestStack;
20
39
  const admin = createTestUser({ roles: ["TenantAdmin"] });
21
40
 
22
41
  beforeAll(async () => {
23
- stack = await setupTestStack({ features: [createFoldersFeature()] });
42
+ stack = await setupTestStack({ features: [createFoldersFeature(), hostFixturesFeature] });
24
43
  await unsafeCreateEntityTable(stack.db, folderEntity);
25
44
  await unsafeCreateEntityTable(stack.db, folderAssignmentEntity);
45
+ await unsafeCreateEntityTable(stack.db, creditEntity);
26
46
  await createEventsTable(stack.db);
47
+ await asRawClient(stack.db).unsafe(
48
+ `INSERT INTO ${CREDIT_TABLE} (id, tenant_id, name) VALUES ($1, $3, 'kept'), ($2, $3, 'cleared')`,
49
+ [CREDIT_KEPT, CREDIT_CLEARED, admin.tenantId],
50
+ );
27
51
  });
28
52
 
29
53
  afterAll(async () => {
@@ -40,18 +64,18 @@ describe("folderAssignmentExportHook", () => {
40
64
 
41
65
  await stack.http.writeOk(
42
66
  FoldersHandlers.setFolder,
43
- { folderId: folder.id, entityType: "credit", entityId: "credit-kept" },
67
+ { folderId: folder.id, entityType: "credit", entityId: CREDIT_KEPT },
44
68
  admin,
45
69
  );
46
70
  await stack.http.writeOk(
47
71
  FoldersHandlers.setFolder,
48
- { folderId: folder.id, entityType: "credit", entityId: "credit-cleared" },
72
+ { folderId: folder.id, entityType: "credit", entityId: CREDIT_CLEARED },
49
73
  admin,
50
74
  );
51
75
  // clear-folder soft-deletes the assignment row (isDeleted: true).
52
76
  await stack.http.writeOk(
53
77
  FoldersHandlers.clearFolder,
54
- { entityType: "credit", entityId: "credit-cleared" },
78
+ { entityType: "credit", entityId: CREDIT_CLEARED },
55
79
  admin,
56
80
  );
57
81
 
@@ -64,7 +88,7 @@ describe("folderAssignmentExportHook", () => {
64
88
 
65
89
  expect(snippet).not.toBeNull();
66
90
  const entityIds = (snippet?.rows ?? []).map((r) => r["entityId"]);
67
- expect(entityIds).toContain("credit-kept");
68
- expect(entityIds).not.toContain("credit-cleared");
91
+ expect(entityIds).toContain(CREDIT_KEPT);
92
+ expect(entityIds).not.toContain(CREDIT_CLEARED);
69
93
  });
70
94
  });
@@ -5,15 +5,25 @@
5
5
  // KMS adapter so the round-trip is proven end-to-end, not assumed from a
6
6
  // no-op-encrypt test env — a later refactor that silently reintroduces the
7
7
  // raw UUID or gets stuck on the placeholder would fail this.
8
+ //
9
+ // fw#2627 made add-note's parent-visibility check unconditional, so a
10
+ // minimal `contact` fixture entity (PASS_CLAUSE) is mounted below and seeded
11
+ // with the one row this file writes a note to — orthogonal to what this file
12
+ // actually tests (authorName stamping).
8
13
 
9
14
  import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
10
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
15
+ import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
11
16
  import {
12
17
  configurePiiSubjectKms,
13
18
  InMemoryKmsAdapter,
14
19
  isPiiCiphertext,
15
20
  } from "@cosmicdrift/kumiko-framework/crypto";
16
- import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
21
+ import {
22
+ createEntity,
23
+ createTextField,
24
+ defineFeature,
25
+ type SessionUser,
26
+ } from "@cosmicdrift/kumiko-framework/engine";
17
27
  import {
18
28
  setupTestStack,
19
29
  type TestStack,
@@ -30,6 +40,16 @@ import { createNotesHistoryFeature } from "../feature";
30
40
  const notesHistoryFeature = createNotesHistoryFeature();
31
41
  const tenantId = testTenantId(1);
32
42
 
43
+ const CONTACT_TABLE = "notes_kms_test_contacts";
44
+ const contactEntity = createEntity({
45
+ table: CONTACT_TABLE,
46
+ fields: { name: createTextField({ required: true, maxLength: 64 }) },
47
+ });
48
+ const contactFixtureFeature = defineFeature("notes-kms-test-contact-fixture", (r) => {
49
+ r.entity("contact", contactEntity);
50
+ });
51
+ const CONTACT_KMS_AUTHOR = "40000000-0000-4000-8000-000000000001";
52
+
33
53
  let stack: TestStack;
34
54
 
35
55
  function memberUser(userId: string): SessionUser {
@@ -37,9 +57,14 @@ function memberUser(userId: string): SessionUser {
37
57
  }
38
58
 
39
59
  beforeAll(async () => {
40
- stack = await setupTestStack({ features: [notesHistoryFeature] });
60
+ stack = await setupTestStack({ features: [notesHistoryFeature, contactFixtureFeature] });
41
61
  await unsafeCreateEntityTable(stack.db, noteEntryEntity);
42
62
  await unsafeCreateEntityTable(stack.db, userEntity);
63
+ await unsafeCreateEntityTable(stack.db, contactEntity);
64
+ await asRawClient(stack.db).unsafe(
65
+ `INSERT INTO ${CONTACT_TABLE} (id, tenant_id, name) VALUES ($1, $2, $3)`,
66
+ [CONTACT_KMS_AUTHOR, tenantId, CONTACT_KMS_AUTHOR],
67
+ );
43
68
  });
44
69
 
45
70
  afterAll(async () => {
@@ -70,13 +95,13 @@ describe("add-note — authorName stamped from the writer's own user row", () =>
70
95
 
71
96
  const { id: noteId } = await stack.http.writeOk<{ id: string }>(
72
97
  NotesHistoryHandlers.addNote,
73
- { entityType: "contact", entityId: "contact-kms-author", body: "Called back." },
98
+ { entityType: "contact", entityId: CONTACT_KMS_AUTHOR, body: "Called back." },
74
99
  memberUser(userId),
75
100
  );
76
101
 
77
102
  const { rows } = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
78
103
  NotesHistoryQueries.noteList,
79
- { filter: { field: "entityId", op: "eq", value: "contact-kms-author" } },
104
+ { filter: { field: "entityId", op: "eq", value: CONTACT_KMS_AUTHOR } },
80
105
  memberUser(userId),
81
106
  );
82
107
 
@@ -9,10 +9,24 @@
9
9
  // stress shiftParams: the ownership fragment's `$N` placeholder must be
10
10
  // renumbered past the tenant-scope filter's already-consumed slots
11
11
  // (event-store-executor-read.ts), not just happen to work at `$1`.
12
+ //
13
+ // fw#2627 made add-note's parent-visibility check unconditional: entityType
14
+ // must now name a registered entity, and the row must exist and be visible
15
+ // to the caller. This file tests NOTE-level ownership, not parent-level, so
16
+ // the fixture `project` entity below is deliberately registered with NO
17
+ // `access` (PASS_CLAUSE) — any tenant member can write a note onto it — and
18
+ // every project row used below is inserted before its add-note call, so the
19
+ // parent-visibility check never interferes with what this file is actually
20
+ // proving.
12
21
 
13
22
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
14
23
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
15
- import type { EntityDefinition } from "@cosmicdrift/kumiko-framework/engine";
24
+ import {
25
+ createEntity,
26
+ createTextField,
27
+ defineFeature,
28
+ type EntityDefinition,
29
+ } from "@cosmicdrift/kumiko-framework/engine";
16
30
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
17
31
  import {
18
32
  createTestUser,
@@ -49,6 +63,23 @@ const teamOwnership: NonNullable<EntityDefinition["access"]> = {
49
63
  },
50
64
  };
51
65
 
66
+ // The `project` parent entity add-note now always checks for. No `access` —
67
+ // PASS_CLAUSE — so it never gates on its own; this file's team split is
68
+ // entirely on the note-entry side (teamOwnership above).
69
+ const PROJECT_TABLE = "notes_ownership_test_projects";
70
+ const projectEntity: EntityDefinition = createEntity({
71
+ table: PROJECT_TABLE,
72
+ fields: { name: createTextField({ required: true, maxLength: 64 }) },
73
+ });
74
+ const projectFixtureFeature = defineFeature("notes-ownership-test-project-fixture", (r) => {
75
+ r.entity("project", projectEntity);
76
+ });
77
+
78
+ const PROJ_1 = "20000000-0000-4000-8000-000000000001";
79
+ const PROJ_9 = "20000000-0000-4000-8000-000000000009";
80
+ const PROJ_2 = "20000000-0000-4000-8000-000000000002";
81
+ const PROJ_B = "20000000-0000-4000-8000-00000000000b";
82
+
52
83
  type TestUser = ReturnType<typeof createTestUser>;
53
84
 
54
85
  let scopedStack: TestStack;
@@ -67,23 +98,42 @@ const userB: TestUser = createTestUser({
67
98
  claims: { team: "team-b" },
68
99
  });
69
100
 
101
+ async function insertProjects(
102
+ stack: TestStack,
103
+ tenantId: string,
104
+ ids: readonly string[],
105
+ ): Promise<void> {
106
+ for (const id of ids) {
107
+ await asRawClient(stack.db).unsafe(
108
+ `INSERT INTO ${PROJECT_TABLE} (id, tenant_id, name) VALUES ($1, $2, $3)`,
109
+ [id, tenantId, id],
110
+ );
111
+ }
112
+ }
113
+
70
114
  beforeAll(async () => {
71
115
  scopedStack = await setupTestStack({
72
- features: [createNotesHistoryFeature({ ownership: teamOwnership })],
116
+ features: [createNotesHistoryFeature({ ownership: teamOwnership }), projectFixtureFeature],
73
117
  });
74
118
  await unsafeCreateEntityTable(scopedStack.db, createNoteEntryEntity(teamOwnership));
119
+ await unsafeCreateEntityTable(scopedStack.db, projectEntity);
75
120
  await createEventsTable(scopedStack.db);
76
121
  await asRawClient(scopedStack.db).unsafe(
77
122
  `CREATE TABLE IF NOT EXISTS ${TEAMS_TABLE} (entity_id text PRIMARY KEY, team_id text NOT NULL)`,
78
123
  );
124
+ await insertProjects(scopedStack, userA.tenantId, [PROJ_1, PROJ_9, PROJ_2]);
79
125
 
80
126
  // Regression control, dedicated stack (mirrors tags.integration.test.ts's
81
127
  // openStack/defaultStack): a plain (unscoped) mount of the SAME feature,
82
128
  // used to prove the 0-rows result below is the ownership rule doing real
83
129
  // work, not an empty table or a broken query.
84
- defaultStack = await setupTestStack({ features: [createNotesHistoryFeature()] });
130
+ defaultStack = await setupTestStack({
131
+ features: [createNotesHistoryFeature(), projectFixtureFeature],
132
+ });
85
133
  await unsafeCreateEntityTable(defaultStack.db, noteEntryEntity);
134
+ await unsafeCreateEntityTable(defaultStack.db, projectEntity);
86
135
  await createEventsTable(defaultStack.db);
136
+ await insertProjects(defaultStack, userA.tenantId, [PROJ_1]);
87
137
  });
88
138
 
89
139
  afterAll(async () => {
@@ -128,16 +178,16 @@ describe("notes-history integration — row ownership (where-rule)", () => {
128
178
  // weaker test would miss: a broken shiftParams (team_id compared against
129
179
  // the tenant UUID or nothing → everyone sees 0) and a trivially-true
130
180
  // subquery (EXISTS matches regardless of team_id → everyone sees both).
131
- await addNote(scopedStack, "proj-1", userA); // will be team-a
132
- await addNote(scopedStack, "proj-9", userA); // will be team-b — same author, different team
181
+ await addNote(scopedStack, PROJ_1, userA); // will be team-a
182
+ await addNote(scopedStack, PROJ_9, userA); // will be team-b — same author, different team
133
183
  await asRawClient(scopedStack.db).unsafe(
134
184
  `INSERT INTO ${TEAMS_TABLE} (entity_id, team_id) VALUES ($1, $2), ($3, $4)`,
135
- ["proj-1", "team-a", "proj-9", "team-b"],
185
+ [PROJ_1, "team-a", PROJ_9, "team-b"],
136
186
  );
137
187
 
138
188
  // User B (different team) sees nothing, even filtered down to the exact row.
139
189
  expect(
140
- await listNotes(scopedStack, userB, { field: "entityId", op: "eq", value: "proj-1" }),
190
+ await listNotes(scopedStack, userB, { field: "entityId", op: "eq", value: PROJ_1 }),
141
191
  ).toHaveLength(0);
142
192
 
143
193
  // User A (own team) sees it. This is the real shiftParams exercise: the
@@ -148,7 +198,7 @@ describe("notes-history integration — row ownership (where-rule)", () => {
148
198
  const ownRows = await listNotes(scopedStack, userA, {
149
199
  field: "entityId",
150
200
  op: "eq",
151
- value: "proj-1",
201
+ value: PROJ_1,
152
202
  });
153
203
  expect(ownRows).toHaveLength(1);
154
204
  expect(ownRows[0]?.["body"]).toBe("note");
@@ -158,17 +208,17 @@ describe("notes-history integration — row ownership (where-rule)", () => {
158
208
  // subquery (which would return both).
159
209
  const aRows = await listNotes(scopedStack, userA);
160
210
  expect(aRows).toHaveLength(1);
161
- expect(aRows[0]?.["entityId"]).toBe("proj-1");
211
+ expect(aRows[0]?.["entityId"]).toBe(PROJ_1);
162
212
 
163
213
  // B authored NEITHER note, yet must see exactly the team-b one — proves
164
214
  // the rule grants access by team membership, not by who wrote the row.
165
215
  const bRows = await listNotes(scopedStack, userB);
166
216
  expect(bRows).toHaveLength(1);
167
- expect(bRows[0]?.["entityId"]).toBe("proj-9");
217
+ expect(bRows[0]?.["entityId"]).toBe(PROJ_9);
168
218
  });
169
219
 
170
220
  test("the same data on an unscoped mount leaks across teams (regression control)", async () => {
171
- await addNote(defaultStack, "proj-1", userA);
221
+ await addNote(defaultStack, PROJ_1, userA);
172
222
  // No ownership option on this stack's feature — the pre-fix behavior.
173
223
  // Proves the 0-rows result above comes from the ownership rule, not
174
224
  // from an unrelated empty-table/broken-query artifact.
@@ -176,10 +226,10 @@ describe("notes-history integration — row ownership (where-rule)", () => {
176
226
  });
177
227
 
178
228
  test("no explicit filter — ownership fragment still shifts correctly against the bare tenant scope", async () => {
179
- await addNote(scopedStack, "proj-2", userA);
229
+ await addNote(scopedStack, PROJ_2, userA);
180
230
  await asRawClient(scopedStack.db).unsafe(
181
231
  `INSERT INTO ${TEAMS_TABLE} (entity_id, team_id) VALUES ($1, $2)`,
182
- ["proj-2", "team-a"],
232
+ [PROJ_2, "team-a"],
183
233
  );
184
234
 
185
235
  expect(await listNotes(scopedStack, userB)).toHaveLength(0);
@@ -187,8 +237,88 @@ describe("notes-history integration — row ownership (where-rule)", () => {
187
237
  });
188
238
  });
189
239
 
240
+ describe("unqualified where-rule fails closed, not open (fw#2639)", () => {
241
+ // Same shape as `teamOwnership` above, but the subquery's inner reference
242
+ // is left unqualified (`entity_id` instead of `t.entity_id`/`${ctx.tableName}.entity_id`).
243
+ // Postgres binds the unqualified name to the innermost table (`t`) first,
244
+ // so `t.entity_id = entity_id` silently becomes `t.entity_id = t.entity_id`
245
+ // — a self-join tautology that matches every row regardless of team.
246
+ const unqualifiedOwnership: NonNullable<EntityDefinition["access"]> = {
247
+ read: {
248
+ TenantMember: {
249
+ kind: "where",
250
+ where: (user, ctx) => {
251
+ const team = user.claims?.["team"];
252
+ // Boot probe has no claims and bails out here; the runtime lint in
253
+ // ruleToFragment is what must catch the unqualified reference.
254
+ if (typeof team !== "string") throw new Error("team claim required");
255
+ return {
256
+ sqlText: `EXISTS (SELECT 1 FROM ${TEAMS_TABLE} t WHERE t.entity_id = entity_id AND t.team_id = $${ctx.paramStart})`,
257
+ params: [team],
258
+ };
259
+ },
260
+ },
261
+ },
262
+ };
263
+
264
+ let unqualifiedStack: TestStack;
265
+
266
+ beforeAll(async () => {
267
+ // Reaching this point at all proves the boot guard doesn't false-positive
268
+ // on this rule: PROBE_USER carries no claims, so `where()` throws before
269
+ // the lint ever sees the bad SQL, and boot-validator/ownership.ts's
270
+ // probeWhereRule swallows that and moves on.
271
+ //
272
+ // add-note's parent-visibility check is unconditional since fw#2627, so
273
+ // this stack also needs the PASS_CLAUSE project fixture mounted — this
274
+ // block otherwise stays focused on the note-entry ownership rule.
275
+ unqualifiedStack = await setupTestStack({
276
+ features: [
277
+ createNotesHistoryFeature({ ownership: unqualifiedOwnership }),
278
+ projectFixtureFeature,
279
+ ],
280
+ });
281
+ await unsafeCreateEntityTable(unqualifiedStack.db, createNoteEntryEntity(unqualifiedOwnership));
282
+ await unsafeCreateEntityTable(unqualifiedStack.db, projectEntity);
283
+ await createEventsTable(unqualifiedStack.db);
284
+ await asRawClient(unqualifiedStack.db).unsafe(
285
+ `CREATE TABLE IF NOT EXISTS ${TEAMS_TABLE} (entity_id text PRIMARY KEY, team_id text NOT NULL)`,
286
+ );
287
+ await insertProjects(unqualifiedStack, userB.tenantId, [PROJ_B]);
288
+ });
289
+
290
+ afterAll(async () => {
291
+ await unqualifiedStack.cleanup();
292
+ });
293
+
294
+ test("list() fails closed instead of leaking userB's row to userA", async () => {
295
+ await addNote(unqualifiedStack, PROJ_B, userB);
296
+ await asRawClient(unqualifiedStack.db).unsafe(
297
+ `INSERT INTO ${TEAMS_TABLE} (entity_id, team_id) VALUES ($1, $2)`,
298
+ [PROJ_B, "team-b"],
299
+ );
300
+
301
+ // Pre-fix: this query returned HTTP 200 with userB's row (the tautology
302
+ // matches every row) — queryErr would throw "Expected query to fail but
303
+ // it succeeded". Post-fix: the runtime lint in ruleToFragment throws
304
+ // before the query ever runs, so the request fails loud instead of
305
+ // leaking. Asserting the concrete status (not just "isSuccess: false")
306
+ // documents exactly how it fails: an uncaught Error auto-wraps into
307
+ // InternalError (500), not a handled 4xx.
308
+ const error = await unqualifiedStack.http.queryErr(NotesHistoryQueries.noteList, {}, userA);
309
+ expect(error.httpStatus).toBe(500);
310
+ expect(error.code).toBe("internal_error");
311
+ });
312
+
313
+ // Green-path twin: `scopedStack` above (built with the correctly-qualified
314
+ // `teamOwnership` rule) already proves the lint doesn't reject every
315
+ // subquery rule — its first test asserts userA sees exactly its own
316
+ // team's row via HTTP 200 and never userB's. Relying on that coverage
317
+ // instead of duplicating a second correctly-qualified stack here.
318
+ });
319
+
190
320
  describe("notes-history — boot guard rejects a where-rule in ownership.write", () => {
191
- test("createNotesHistoryFeature throws instead of shipping a create()-time landmine", () => {
321
+ test("createNotesHistoryFeature throws instead of shipping a deny-only ownership map", () => {
192
322
  expect(() =>
193
323
  createNotesHistoryFeature({
194
324
  ownership: {
@@ -0,0 +1,225 @@
1
+ // fw#2627 — add-note previously trusted entityType/entityId straight from the
2
+ // client, so any dispatch-eligible tenant user could attach a note to a
3
+ // parent object they had no read access to. The parent-visibility check is
4
+ // unconditional (default-on, no opt-in): entityType must name a registered
5
+ // entity, and the row must be visible through that entity's own read path
6
+ // (tenant scope plus its `access.read` ownership) before add-note accepts
7
+ // the write — on every mount, `parents` or not. `parents`, when set, only
8
+ // narrows FURTHER to an allowlist of permitted parent entity names; it is
9
+ // not what turns the check on (see `openStack` tests below, which mount
10
+ // notes-history with no `parents` at all and still enforce the check).
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 { NotesHistoryHandlers } from "../constants";
28
+ import { createNoteEntryEntity } from "../entity";
29
+ import { createNotesHistoryFeature } from "../feature";
30
+
31
+ const PROJECT_TABLE = "notes_pv_test_projects";
32
+
33
+ // Team-scoped read ownership on the parent 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
+ function createProjectEntity(access?: EntityDefinition["access"]): EntityDefinition {
48
+ return createEntity({
49
+ table: PROJECT_TABLE,
50
+ fields: {
51
+ teamId: createTextField({ required: true, maxLength: 64 }),
52
+ name: createTextField({ required: true, maxLength: 64 }),
53
+ },
54
+ access,
55
+ });
56
+ }
57
+
58
+ const guardedProjectEntity = createProjectEntity(projectOwnership);
59
+
60
+ // A second registered entity, deliberately left OUT of the `parents`
61
+ // allowlist below — proves the allowlist rejects a real, registered entity
62
+ // too, not just made-up names.
63
+ const contactEntity = createEntity({
64
+ table: "notes_pv_test_contacts",
65
+ fields: { name: createTextField({ required: true, maxLength: 64 }) },
66
+ });
67
+
68
+ const fixturesFeature = defineFeature("notes-pv-test-fixtures", (r) => {
69
+ r.entity("project", guardedProjectEntity);
70
+ r.entity("contact", contactEntity);
71
+ });
72
+
73
+ type TestUser = ReturnType<typeof createTestUser>;
74
+
75
+ // Same tenant, different team claim — mirrors the ownership integration test.
76
+ const userA: TestUser = createTestUser({
77
+ id: 40,
78
+ roles: ["TenantMember"],
79
+ claims: { team: "team-a" },
80
+ });
81
+ const userB: TestUser = createTestUser({
82
+ id: 41,
83
+ roles: ["TenantMember"],
84
+ claims: { team: "team-b" },
85
+ });
86
+
87
+ const PROJECT_A = "a0000000-0000-4000-8000-000000000001";
88
+ // A second team-a project, dedicated to the cross-team-denial case so its
89
+ // note-count stays independent of PROJECT_A's.
90
+ const PROJECT_A2 = "a0000000-0000-4000-8000-000000000002";
91
+ // openStack-only team-a project row — never reused as PROJECT_A2 so the two
92
+ // stacks' tests stay order-independent.
93
+ const PROJECT_OPEN = "a0000000-0000-4000-8000-000000000009";
94
+
95
+ let guardedStack: TestStack;
96
+ let openStack: TestStack;
97
+
98
+ beforeAll(async () => {
99
+ guardedStack = await setupTestStack({
100
+ features: [createNotesHistoryFeature({ parents: ["project"] }), fixturesFeature],
101
+ });
102
+ await unsafeCreateEntityTable(guardedStack.db, guardedProjectEntity);
103
+ await unsafeCreateEntityTable(guardedStack.db, createNoteEntryEntity());
104
+ await createEventsTable(guardedStack.db);
105
+ await asRawClient(guardedStack.db).unsafe(
106
+ `INSERT INTO ${PROJECT_TABLE} (id, tenant_id, team_id, name) VALUES
107
+ ($1, $2, 'team-a', 'Project A'),
108
+ ($3, $2, 'team-a', 'Project A2')`,
109
+ [PROJECT_A, userA.tenantId, PROJECT_A2],
110
+ );
111
+
112
+ // Unguarded mount: the SAME feature with no `parents` at all — proves the
113
+ // parent-visibility check itself is default-on, not something `parents`
114
+ // switches on.
115
+ openStack = await setupTestStack({
116
+ features: [createNotesHistoryFeature(), fixturesFeature],
117
+ });
118
+ await unsafeCreateEntityTable(openStack.db, guardedProjectEntity);
119
+ await unsafeCreateEntityTable(openStack.db, createNoteEntryEntity());
120
+ await createEventsTable(openStack.db);
121
+ await asRawClient(openStack.db).unsafe(
122
+ `INSERT INTO ${PROJECT_TABLE} (id, tenant_id, team_id, name) VALUES ($1, $2, 'team-a', 'Project Open')`,
123
+ [PROJECT_OPEN, userA.tenantId],
124
+ );
125
+ });
126
+
127
+ afterAll(async () => {
128
+ await guardedStack.cleanup();
129
+ await openStack.cleanup();
130
+ });
131
+
132
+ async function addNote(
133
+ stack: TestStack,
134
+ entityType: string,
135
+ entityId: string,
136
+ user: TestUser,
137
+ ): Promise<{ id: string }> {
138
+ return stack.http.writeOk<{ id: string }>(
139
+ NotesHistoryHandlers.addNote,
140
+ { entityType, entityId, body: "note" },
141
+ user,
142
+ );
143
+ }
144
+
145
+ async function addNoteErr(stack: TestStack, entityType: string, entityId: string, user: TestUser) {
146
+ return stack.http.writeErr(
147
+ NotesHistoryHandlers.addNote,
148
+ { entityType, entityId, body: "note" },
149
+ user,
150
+ );
151
+ }
152
+
153
+ async function noteCountFor(stack: TestStack, entityId: string): Promise<number> {
154
+ const rows = await asRawClient(stack.db).unsafe<{ count: string }>(
155
+ `SELECT count(*)::text AS count FROM read_note_entries WHERE entity_id = $1`,
156
+ [entityId],
157
+ );
158
+ return Number(rows[0]?.count ?? "0");
159
+ }
160
+
161
+ describe("notes-history integration — add-note parent-visibility (parents option)", () => {
162
+ test("caller may note a parent row visible through the parent entity's own read path", async () => {
163
+ const result = await addNote(guardedStack, "project", PROJECT_A, userA);
164
+ expect(result.id).toBeTruthy();
165
+ expect(await noteCountFor(guardedStack, PROJECT_A)).toBe(1);
166
+ });
167
+
168
+ test("caller is denied noting a parent row from a foreign team while the same row is writable by its own team", async () => {
169
+ const err = await addNoteErr(guardedStack, "project", PROJECT_A2, userB);
170
+ expect(err.code).toBe("not_found");
171
+ expect(err.httpStatus).toBe(404);
172
+ expect(await noteCountFor(guardedStack, PROJECT_A2)).toBe(0);
173
+
174
+ // Positive control: PROJECT_A2 itself is writable — by its own team — so
175
+ // the deny above is the team-claim check, not some other rejection (e.g.
176
+ // a row that doesn't exist at all).
177
+ const result = await addNote(guardedStack, "project", PROJECT_A2, userA);
178
+ expect(result.id).toBeTruthy();
179
+ expect(await noteCountFor(guardedStack, PROJECT_A2)).toBe(1);
180
+ });
181
+
182
+ test("entityType outside the allowlist is denied even when it is a registered entity", async () => {
183
+ const err = await addNoteErr(
184
+ guardedStack,
185
+ "contact",
186
+ "c0000000-0000-4000-8000-000000000003",
187
+ userA,
188
+ );
189
+ expect(err.code).toBe("not_found");
190
+ });
191
+
192
+ test("entityType that is not registered anywhere is denied", async () => {
193
+ const err = await addNoteErr(guardedStack, "totally-unknown-entity", "x", userA);
194
+ expect(err.code).toBe("not_found");
195
+ });
196
+
197
+ test("a malformed entityId is denied cleanly, without poisoning the connection for later writes", async () => {
198
+ const err = await addNoteErr(guardedStack, "project", "not-a-uuid", userA);
199
+ expect(err.code).toBe("not_found");
200
+ expect(err.httpStatus).toBe(404);
201
+
202
+ // The stack must still be usable afterwards — proves the malformed id was
203
+ // rejected before it ever reached a query, not caught after a failed
204
+ // (and tx-poisoning) cast.
205
+ const result = await addNote(guardedStack, "project", PROJECT_A, userA);
206
+ expect(result.id).toBeTruthy();
207
+ });
208
+
209
+ test("on an unguarded mount (no `parents`), an unregistered entityType is still denied — the check is default-on", async () => {
210
+ const err = await addNoteErr(openStack, "totally-unknown-entity", "y", userA);
211
+ expect(err.code).toBe("not_found");
212
+ });
213
+
214
+ test("on an unguarded mount, a registered and visible parent still succeeds", async () => {
215
+ const result = await addNote(openStack, "project", PROJECT_OPEN, userA);
216
+ expect(result.id).toBeTruthy();
217
+ expect(await noteCountFor(openStack, PROJECT_OPEN)).toBe(1);
218
+ });
219
+
220
+ test("on an unguarded mount, a registered parent from a foreign team is still denied", async () => {
221
+ const err = await addNoteErr(openStack, "project", PROJECT_OPEN, userB);
222
+ expect(err.code).toBe("not_found");
223
+ expect(err.httpStatus).toBe(404);
224
+ });
225
+ });