@cosmicdrift/kumiko-bundled-features 0.245.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.
- 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
|
@@ -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:
|
|
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:
|
|
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:
|
|
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(
|
|
68
|
-
expect(entityIds).not.toContain(
|
|
91
|
+
expect(entityIds).toContain(CREDIT_KEPT);
|
|
92
|
+
expect(entityIds).not.toContain(CREDIT_CLEARED);
|
|
69
93
|
});
|
|
70
94
|
});
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { fetchOne, runInSavepointIfSupported } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
-
import { decryptStoredPii } from "../../shared";
|
|
4
|
+
import { decryptStoredPii, parentRowIsVisible } from "../../shared";
|
|
5
5
|
import { userTable } from "../../user";
|
|
6
6
|
import { DEFAULT_NOTES_HISTORY_ACCESS } from "../constants";
|
|
7
7
|
import { noteEntryExecutor } from "../executor";
|
|
8
|
-
import { parentRowIsVisible } from "../parent-visibility";
|
|
9
8
|
import { type AddNotePayload, addNotePayloadSchema } from "../schemas";
|
|
10
9
|
|
|
11
10
|
// add-note — appends a note-entry to (entityType, entityId). authorId is
|
|
@@ -17,7 +16,7 @@ import { type AddNotePayload, addNotePayloadSchema } from "../schemas";
|
|
|
17
16
|
// entityType/entityId are never trusted client input: entityType must name a
|
|
18
17
|
// registered entity, and the row must be visible to the caller through that
|
|
19
18
|
// entity's own read path (tenant scope plus its `access.read` ownership) —
|
|
20
|
-
// see parent-visibility.ts. `parents`, when set, is an ADDITIONAL allowlist
|
|
19
|
+
// see shared/parent-visibility.ts. `parents`, when set, is an ADDITIONAL allowlist
|
|
21
20
|
// narrowing which registered entities may be a note's parent at all; it is
|
|
22
21
|
// not what turns the check on.
|
|
23
22
|
export function createAddNoteHandler(
|
package/src/shared/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ export { isWithinGracePeriod } from "./grace-period";
|
|
|
17
17
|
export { hasWhereRule } from "./has-where-rule";
|
|
18
18
|
export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
|
|
19
19
|
export { mapWithConcurrency } from "./map-with-concurrency";
|
|
20
|
+
export { parentRowIsVisible } from "./parent-visibility";
|
|
20
21
|
export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
|
|
21
22
|
export { sessionField } from "./session-field";
|
|
22
23
|
export { sessionLocaleField } from "./session-locale-field";
|
|
@@ -15,10 +15,12 @@ function idShapeMatchesEntity(entity: EntityDefinition, entityId: string): boole
|
|
|
15
15
|
return entity.idType === "serial" ? /^\d+$/.test(entityId) : isUuid(entityId);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
// Checks whether a
|
|
19
|
-
// caller can see via
|
|
20
|
-
//
|
|
21
|
-
//
|
|
18
|
+
// Checks whether a client-supplied (entityType, entityId) host reference is one
|
|
19
|
+
// the caller can see via that entity's own read path — tenant scope plus its
|
|
20
|
+
// `access.read` ownership. Shared by every handler that attaches something to a
|
|
21
|
+
// host entity the client names (add-note, assign-tag, remove-tag, set-folder,
|
|
22
|
+
// clear-folder); those handlers deny with NotFoundError so the response never
|
|
23
|
+
// doubles as an existence oracle.
|
|
22
24
|
export async function parentRowIsVisible(
|
|
23
25
|
registry: Registry,
|
|
24
26
|
entityType: string,
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// assign-tag/remove-tag previously took entityType/entityId straight from the
|
|
2
|
+
// client, so any dispatch-eligible tenant user could attach a catalog tag to —
|
|
3
|
+
// or detach one from — an object they had no read access to. The tag-assignment
|
|
4
|
+
// aggregate-id is derived from the tenant, so this was never a cross-tenant
|
|
5
|
+
// hole; it was "tag an object inside your own tenant that you aren't allowed to
|
|
6
|
+
// read", which also leaks through the assignment list of anyone who can see it.
|
|
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 { TagsHandlers } from "../constants";
|
|
28
|
+
import { tagAssignmentEntity, tagEntity } from "../entity";
|
|
29
|
+
import { createTagsFeature } from "../feature";
|
|
30
|
+
|
|
31
|
+
const PROJECT_TABLE = "tags_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("tags-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: 60,
|
|
65
|
+
roles: ["TenantMember"],
|
|
66
|
+
claims: { team: "team-a" },
|
|
67
|
+
});
|
|
68
|
+
const userB: TestUser = createTestUser({
|
|
69
|
+
id: 61,
|
|
70
|
+
roles: ["TenantMember"],
|
|
71
|
+
claims: { team: "team-b" },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const PROJECT_A = "b0000000-0000-4000-8000-000000000001";
|
|
75
|
+
// A second team-a project, dedicated to the remove-tag denial case so its
|
|
76
|
+
// assignment count stays independent of PROJECT_A's.
|
|
77
|
+
const PROJECT_A2 = "b0000000-0000-4000-8000-000000000002";
|
|
78
|
+
// Never assigned anything — the "no assignment" half of the oracle check.
|
|
79
|
+
const PROJECT_A3 = "b0000000-0000-4000-8000-000000000003";
|
|
80
|
+
|
|
81
|
+
let stack: TestStack;
|
|
82
|
+
let tagId: string;
|
|
83
|
+
|
|
84
|
+
beforeAll(async () => {
|
|
85
|
+
stack = await setupTestStack({
|
|
86
|
+
features: [createTagsFeature({ access: { openToAll: true } }), hostFixturesFeature],
|
|
87
|
+
});
|
|
88
|
+
await unsafeCreateEntityTable(stack.db, tagEntity);
|
|
89
|
+
await unsafeCreateEntityTable(stack.db, tagAssignmentEntity);
|
|
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 tag = await stack.http.writeOk<{ id: string }>(
|
|
101
|
+
TagsHandlers.createTag,
|
|
102
|
+
{ name: "VIP" },
|
|
103
|
+
userA,
|
|
104
|
+
);
|
|
105
|
+
tagId = tag.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_tag_assignments WHERE entity_id = $1 AND is_deleted = FALSE",
|
|
115
|
+
[entityId],
|
|
116
|
+
);
|
|
117
|
+
return rows[0]?.n ?? 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function assign(entityType: string, entityId: string, user: TestUser) {
|
|
121
|
+
return stack.http.writeOk<{ id: string }>(
|
|
122
|
+
TagsHandlers.assignTag,
|
|
123
|
+
{ tagId, entityType, entityId },
|
|
124
|
+
user,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function assignErr(entityType: string, entityId: string, user: TestUser) {
|
|
129
|
+
return stack.http.writeErr(TagsHandlers.assignTag, { tagId, entityType, entityId }, user);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function remove(entityType: string, entityId: string, user: TestUser) {
|
|
133
|
+
return stack.http.writeOk<{ id: string }>(
|
|
134
|
+
TagsHandlers.removeTag,
|
|
135
|
+
{ tagId, entityType, entityId },
|
|
136
|
+
user,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function removeErr(entityType: string, entityId: string, user: TestUser) {
|
|
141
|
+
return stack.http.writeErr(TagsHandlers.removeTag, { tagId, entityType, entityId }, user);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
describe("tags integration — assign-tag parent-visibility", () => {
|
|
145
|
+
test("a host row visible through the host entity's own read path can be tagged", async () => {
|
|
146
|
+
const result = await assign("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 assignErr("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 taggable — 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 assign("project", PROJECT_A2, userA);
|
|
160
|
+
expect(result.id).toBeTruthy();
|
|
161
|
+
expect(await activeAssignmentsFor(PROJECT_A2)).toBe(1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("an entityType that is not a registered entity is denied", async () => {
|
|
165
|
+
const err = await assignErr("totally-unknown-entity", PROJECT_A, userA);
|
|
166
|
+
expect(err.code).toBe("not_found");
|
|
167
|
+
expect(err.httpStatus).toBe(404);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("a malformed entityId is denied cleanly, without poisoning the connection", async () => {
|
|
171
|
+
const err = await assignErr("project", "not-a-uuid", userA);
|
|
172
|
+
expect(err.code).toBe("not_found");
|
|
173
|
+
expect(err.httpStatus).toBe(404);
|
|
174
|
+
|
|
175
|
+
// The stack must still be usable — proves the malformed id was rejected
|
|
176
|
+
// before it reached a query, not caught after a tx-poisoning cast error.
|
|
177
|
+
expect((await assign("project", PROJECT_A, userA)).id).toBeTruthy();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("tags integration — remove-tag parent-visibility", () => {
|
|
182
|
+
test("a foreign team cannot detach an existing assignment; the row survives", async () => {
|
|
183
|
+
await assign("project", PROJECT_A2, userA);
|
|
184
|
+
expect(await activeAssignmentsFor(PROJECT_A2)).toBe(1);
|
|
185
|
+
|
|
186
|
+
const err = await removeErr("project", PROJECT_A2, userB);
|
|
187
|
+
expect(err.code).toBe("not_found");
|
|
188
|
+
expect(err.httpStatus).toBe(404);
|
|
189
|
+
// Zero-write: the assignment userA made is still active, not soft-deleted.
|
|
190
|
+
expect(await activeAssignmentsFor(PROJECT_A2)).toBe(1);
|
|
191
|
+
|
|
192
|
+
// Positive control: the owning team CAN detach it.
|
|
193
|
+
await remove("project", PROJECT_A2, userA);
|
|
194
|
+
expect(await activeAssignmentsFor(PROJECT_A2)).toBe(0);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("the denial carries no existence oracle: assigned and unassigned rows answer alike", async () => {
|
|
198
|
+
await assign("project", PROJECT_A2, userA);
|
|
199
|
+
expect(await activeAssignmentsFor(PROJECT_A2)).toBe(1);
|
|
200
|
+
expect(await activeAssignmentsFor(PROJECT_A3)).toBe(0);
|
|
201
|
+
|
|
202
|
+
// remove-tag normally reports success for a never-assigned pair, so without
|
|
203
|
+
// the gate running FIRST the two responses below would differ and leak
|
|
204
|
+
// whether the invisible host row carries this tag.
|
|
205
|
+
const onAssigned = await removeErr("project", PROJECT_A2, userB);
|
|
206
|
+
const onUnassigned = await removeErr("project", PROJECT_A3, userB);
|
|
207
|
+
expect(onAssigned.code).toBe(onUnassigned.code);
|
|
208
|
+
expect(onAssigned.httpStatus).toBe(onUnassigned.httpStatus);
|
|
209
|
+
expect(onAssigned.code).toBe("not_found");
|
|
210
|
+
|
|
211
|
+
await remove("project", PROJECT_A2, userA);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("an entityType that is not a registered entity is denied", async () => {
|
|
215
|
+
const err = await removeErr("totally-unknown-entity", PROJECT_A, userA);
|
|
216
|
+
expect(err.code).toBe("not_found");
|
|
217
|
+
expect(err.httpStatus).toBe(404);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -7,9 +7,16 @@
|
|
|
7
7
|
// - read-layer composition both directions (tags of an entity / entities of a tag)
|
|
8
8
|
// - assign + remove are idempotent (re-assign = one row, remove-missing = ok)
|
|
9
9
|
// - multi-tenant isolation
|
|
10
|
+
//
|
|
11
|
+
// assign-tag/remove-tag verify that entityType names a registered entity and
|
|
12
|
+
// that the host row is visible to the caller. This file's assertions are about
|
|
13
|
+
// assignment mechanics, not parent ownership, so the `credit`/`note` fixtures
|
|
14
|
+
// below stay unrestricted (no `access`) on purpose — every host row this file
|
|
15
|
+
// tags is seeded in beforeAll.
|
|
10
16
|
|
|
11
17
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
12
18
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
19
|
+
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
20
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
14
21
|
import {
|
|
15
22
|
createTestUser,
|
|
@@ -17,19 +24,77 @@ import {
|
|
|
17
24
|
type TestStack,
|
|
18
25
|
unsafeCreateEntityTable,
|
|
19
26
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
27
|
+
import { v5 as uuidv5 } from "uuid";
|
|
20
28
|
import { TagsHandlers, TagsQueries } from "../constants";
|
|
21
29
|
import { tagAssignmentEntity, tagEntity } from "../entity";
|
|
22
30
|
import { createTagsFeature } from "../feature";
|
|
23
31
|
|
|
24
32
|
const tagsFeature = createTagsFeature();
|
|
25
33
|
|
|
34
|
+
const CREDIT_TABLE = "tags_test_credits";
|
|
35
|
+
const NOTE_TABLE = "tags_test_notes";
|
|
36
|
+
const hostFields = { name: createTextField({ required: true, maxLength: 64 }) };
|
|
37
|
+
const creditEntity = createEntity({ table: CREDIT_TABLE, fields: hostFields });
|
|
38
|
+
const noteEntity = createEntity({ table: NOTE_TABLE, fields: hostFields });
|
|
39
|
+
const hostFixturesFeature = defineFeature("tags-test-host-fixtures", (r) => {
|
|
40
|
+
r.entity("credit", creditEntity);
|
|
41
|
+
r.entity("note", noteEntity);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// The parent entity's ids are uuid-shaped, but the readable labels below carry
|
|
45
|
+
// the intent of each case — derive one from the other instead of scattering
|
|
46
|
+
// opaque uuid literals through the assertions.
|
|
47
|
+
const HOST_FIXTURE_NAMESPACE = "6f1c4d2a-9b3e-4a7c-8d5f-1e2b3c4d5e6f";
|
|
48
|
+
function hostId(label: string): string {
|
|
49
|
+
return uuidv5(label, HOST_FIXTURE_NAMESPACE);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const CREDIT_LABELS = [
|
|
53
|
+
"credit-1",
|
|
54
|
+
"credit-2",
|
|
55
|
+
"credit-3",
|
|
56
|
+
"credit-4",
|
|
57
|
+
"credit-5",
|
|
58
|
+
"credit-6",
|
|
59
|
+
"credit-7",
|
|
60
|
+
"credit-8",
|
|
61
|
+
"credit-d1",
|
|
62
|
+
"credit-r",
|
|
63
|
+
"credit-x",
|
|
64
|
+
...Array.from({ length: 20 }, (_, i) => `credit-race-${i}`),
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
async function seedHostRows(
|
|
68
|
+
target: TestStack,
|
|
69
|
+
table: string,
|
|
70
|
+
labels: readonly string[],
|
|
71
|
+
tenantId: string,
|
|
72
|
+
): Promise<void> {
|
|
73
|
+
for (const label of labels) {
|
|
74
|
+
await asRawClient(target.db).unsafe(
|
|
75
|
+
`INSERT INTO ${table} (id, tenant_id, name) VALUES ($1, $2, $3)`,
|
|
76
|
+
[hostId(label), tenantId, label],
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
26
81
|
let stack: TestStack;
|
|
27
82
|
|
|
83
|
+
const admin = createTestUser({ roles: ["TenantAdmin"] });
|
|
84
|
+
const otherTenant = createTestUser({
|
|
85
|
+
roles: ["TenantAdmin"],
|
|
86
|
+
tenantId: "00000000-0000-4000-8000-0000000000aa",
|
|
87
|
+
});
|
|
88
|
+
|
|
28
89
|
beforeAll(async () => {
|
|
29
|
-
stack = await setupTestStack({ features: [tagsFeature] });
|
|
90
|
+
stack = await setupTestStack({ features: [tagsFeature, hostFixturesFeature] });
|
|
30
91
|
await unsafeCreateEntityTable(stack.db, tagEntity);
|
|
31
92
|
await unsafeCreateEntityTable(stack.db, tagAssignmentEntity);
|
|
93
|
+
await unsafeCreateEntityTable(stack.db, creditEntity);
|
|
94
|
+
await unsafeCreateEntityTable(stack.db, noteEntity);
|
|
32
95
|
await createEventsTable(stack.db);
|
|
96
|
+
await seedHostRows(stack, CREDIT_TABLE, CREDIT_LABELS, admin.tenantId);
|
|
97
|
+
await seedHostRows(stack, NOTE_TABLE, ["note-d1"], admin.tenantId);
|
|
33
98
|
});
|
|
34
99
|
|
|
35
100
|
afterAll(async () => {
|
|
@@ -42,12 +107,6 @@ beforeEach(async () => {
|
|
|
42
107
|
await asRawClient(stack.db).unsafe("DELETE FROM read_tag_assignments");
|
|
43
108
|
});
|
|
44
109
|
|
|
45
|
-
const admin = createTestUser({ roles: ["TenantAdmin"] });
|
|
46
|
-
const otherTenant = createTestUser({
|
|
47
|
-
roles: ["TenantAdmin"],
|
|
48
|
-
tenantId: "00000000-0000-4000-8000-0000000000aa",
|
|
49
|
-
});
|
|
50
|
-
|
|
51
110
|
async function createTag(name: string, user = admin): Promise<string> {
|
|
52
111
|
const tag = await stack.http.writeOk<{ id: string }>(TagsHandlers.createTag, { name }, user);
|
|
53
112
|
return tag.id;
|
|
@@ -111,10 +170,14 @@ describe("tags integration — catalog + assignment roundtrip", () => {
|
|
|
111
170
|
|
|
112
171
|
test("assign-tag → assignment queryable both composition directions", async () => {
|
|
113
172
|
const tagId = await createTag("VIP");
|
|
114
|
-
await assign(tagId, "credit", "credit-1");
|
|
173
|
+
await assign(tagId, "credit", hostId("credit-1"));
|
|
115
174
|
|
|
116
175
|
// tags of an entity
|
|
117
|
-
const byEntity = await listAssignments({
|
|
176
|
+
const byEntity = await listAssignments({
|
|
177
|
+
field: "entityId",
|
|
178
|
+
op: "eq",
|
|
179
|
+
value: hostId("credit-1"),
|
|
180
|
+
});
|
|
118
181
|
expect(byEntity).toHaveLength(1);
|
|
119
182
|
expect(byEntity[0]?.["tagId"]).toBe(tagId);
|
|
120
183
|
expect(byEntity[0]?.["entityType"]).toBe("credit");
|
|
@@ -122,17 +185,17 @@ describe("tags integration — catalog + assignment roundtrip", () => {
|
|
|
122
185
|
// entities carrying a tag
|
|
123
186
|
const byTag = await listAssignments({ field: "tagId", op: "eq", value: tagId });
|
|
124
187
|
expect(byTag).toHaveLength(1);
|
|
125
|
-
expect(byTag[0]?.["entityId"]).toBe("credit-1");
|
|
188
|
+
expect(byTag[0]?.["entityId"]).toBe(hostId("credit-1"));
|
|
126
189
|
});
|
|
127
190
|
|
|
128
191
|
test("remove-tag deletes the assignment", async () => {
|
|
129
192
|
const tagId = await createTag("temp");
|
|
130
|
-
await assign(tagId, "credit", "credit-2");
|
|
193
|
+
await assign(tagId, "credit", hostId("credit-2"));
|
|
131
194
|
expect(await countAssignments(admin.tenantId)).toBe(1);
|
|
132
195
|
|
|
133
|
-
await remove(tagId, "credit", "credit-2");
|
|
196
|
+
await remove(tagId, "credit", hostId("credit-2"));
|
|
134
197
|
expect(await countAssignments(admin.tenantId)).toBe(0);
|
|
135
|
-
const left = await listAssignments({ field: "entityId", op: "eq", value: "credit-2" });
|
|
198
|
+
const left = await listAssignments({ field: "entityId", op: "eq", value: hostId("credit-2") });
|
|
136
199
|
expect(left).toHaveLength(0);
|
|
137
200
|
});
|
|
138
201
|
});
|
|
@@ -231,9 +294,9 @@ describe("tags integration — delete-tag cascade", () => {
|
|
|
231
294
|
const target = await createTag("Sammelmappe");
|
|
232
295
|
const other = await createTag("bleibt");
|
|
233
296
|
// target spans two entityTypes; other tag stays attached to prove scoping
|
|
234
|
-
await assign(target, "credit", "credit-d1");
|
|
235
|
-
await assign(target, "note", "note-d1");
|
|
236
|
-
await assign(other, "credit", "credit-d1");
|
|
297
|
+
await assign(target, "credit", hostId("credit-d1"));
|
|
298
|
+
await assign(target, "note", hostId("note-d1"));
|
|
299
|
+
await assign(other, "credit", hostId("credit-d1"));
|
|
237
300
|
expect(await countAssignments(admin.tenantId)).toBe(3);
|
|
238
301
|
|
|
239
302
|
await deleteTag(target);
|
|
@@ -257,52 +320,54 @@ describe("tags integration — many-to-many composition", () => {
|
|
|
257
320
|
test("one entity carries multiple tags", async () => {
|
|
258
321
|
const a = await createTag("rot");
|
|
259
322
|
const b = await createTag("wasser");
|
|
260
|
-
await assign(a, "credit", "credit-3");
|
|
261
|
-
await assign(b, "credit", "credit-3");
|
|
323
|
+
await assign(a, "credit", hostId("credit-3"));
|
|
324
|
+
await assign(b, "credit", hostId("credit-3"));
|
|
262
325
|
|
|
263
|
-
const tags = await listAssignments({ field: "entityId", op: "eq", value: "credit-3" });
|
|
326
|
+
const tags = await listAssignments({ field: "entityId", op: "eq", value: hostId("credit-3") });
|
|
264
327
|
expect(tags.map((r) => r["tagId"]).sort()).toEqual([a, b].sort());
|
|
265
328
|
});
|
|
266
329
|
|
|
267
330
|
test("one tag spans multiple entities", async () => {
|
|
268
331
|
const tagId = await createTag("Mappe-2026");
|
|
269
|
-
await assign(tagId, "credit", "credit-4");
|
|
270
|
-
await assign(tagId, "credit", "credit-5");
|
|
332
|
+
await assign(tagId, "credit", hostId("credit-4"));
|
|
333
|
+
await assign(tagId, "credit", hostId("credit-5"));
|
|
271
334
|
|
|
272
335
|
const entities = await listAssignments({ field: "tagId", op: "eq", value: tagId });
|
|
273
|
-
expect(entities.map((r) => r["entityId"]).sort()).toEqual(
|
|
336
|
+
expect(entities.map((r) => r["entityId"]).sort()).toEqual(
|
|
337
|
+
[hostId("credit-4"), hostId("credit-5")].sort(),
|
|
338
|
+
);
|
|
274
339
|
});
|
|
275
340
|
});
|
|
276
341
|
|
|
277
342
|
describe("tags integration — idempotency", () => {
|
|
278
343
|
test("re-assigning the same (tag, entity) keeps exactly one row", async () => {
|
|
279
344
|
const tagId = await createTag("dup");
|
|
280
|
-
await assign(tagId, "credit", "credit-6");
|
|
281
|
-
await assign(tagId, "credit", "credit-6"); // re-assign: must be a no-op success
|
|
345
|
+
await assign(tagId, "credit", hostId("credit-6"));
|
|
346
|
+
await assign(tagId, "credit", hostId("credit-6")); // re-assign: must be a no-op success
|
|
282
347
|
|
|
283
348
|
expect(await countAssignments(admin.tenantId)).toBe(1);
|
|
284
|
-
const rows = await listAssignments({ field: "entityId", op: "eq", value: "credit-6" });
|
|
349
|
+
const rows = await listAssignments({ field: "entityId", op: "eq", value: hostId("credit-6") });
|
|
285
350
|
expect(rows).toHaveLength(1);
|
|
286
351
|
});
|
|
287
352
|
|
|
288
353
|
test("removing a never-assigned (tag, entity) succeeds (no error, no row)", async () => {
|
|
289
354
|
const tagId = await createTag("ghost");
|
|
290
355
|
// never assigned — remove must still succeed (idempotent end-state)
|
|
291
|
-
await remove(tagId, "credit", "credit-7");
|
|
356
|
+
await remove(tagId, "credit", hostId("credit-7"));
|
|
292
357
|
expect(await countAssignments(admin.tenantId)).toBe(0);
|
|
293
358
|
});
|
|
294
359
|
|
|
295
360
|
test("assign → remove → assign-again resurrects the same deterministic stream", async () => {
|
|
296
361
|
const tagId = await createTag("recurring");
|
|
297
|
-
await assign(tagId, "credit", "credit-r");
|
|
298
|
-
await remove(tagId, "credit", "credit-r");
|
|
362
|
+
await assign(tagId, "credit", hostId("credit-r"));
|
|
363
|
+
await remove(tagId, "credit", hostId("credit-r"));
|
|
299
364
|
expect(await countAssignments(admin.tenantId)).toBe(0);
|
|
300
365
|
|
|
301
366
|
// Re-attaching the same (tag, entity) must succeed (restore), not 409 — the
|
|
302
367
|
// deterministic aggregate-id reuses the removed stream.
|
|
303
|
-
await assign(tagId, "credit", "credit-r");
|
|
368
|
+
await assign(tagId, "credit", hostId("credit-r"));
|
|
304
369
|
expect(await countAssignments(admin.tenantId)).toBe(1);
|
|
305
|
-
const rows = await listAssignments({ field: "entityId", op: "eq", value: "credit-r" });
|
|
370
|
+
const rows = await listAssignments({ field: "entityId", op: "eq", value: hostId("credit-r") });
|
|
306
371
|
expect(rows).toHaveLength(1);
|
|
307
372
|
expect(rows[0]?.["tagId"]).toBe(tagId);
|
|
308
373
|
});
|
|
@@ -319,7 +384,7 @@ describe("tags integration — idempotency", () => {
|
|
|
319
384
|
// The handler must converge that into success instead of surfacing a 409
|
|
320
385
|
// for what is, from the caller's perspective, an idempotent operation.
|
|
321
386
|
const tagId = await createTag(`racy-${i}`);
|
|
322
|
-
const entityId = `credit-race-${i}
|
|
387
|
+
const entityId = hostId(`credit-race-${i}`);
|
|
323
388
|
const [a, b] = await Promise.all([
|
|
324
389
|
assign(tagId, "credit", entityId),
|
|
325
390
|
assign(tagId, "credit", entityId),
|
|
@@ -336,7 +401,11 @@ describe("tags integration — referential integrity", () => {
|
|
|
336
401
|
test("assigning an unknown tagId is rejected (no dangling assignment)", async () => {
|
|
337
402
|
const err = await stack.http.writeErr(
|
|
338
403
|
TagsHandlers.assignTag,
|
|
339
|
-
{
|
|
404
|
+
{
|
|
405
|
+
tagId: "00000000-0000-4000-8000-00000000dead",
|
|
406
|
+
entityType: "credit",
|
|
407
|
+
entityId: hostId("credit-x"),
|
|
408
|
+
},
|
|
340
409
|
admin,
|
|
341
410
|
);
|
|
342
411
|
expect(err.httpStatus).toBe(404);
|
|
@@ -347,11 +416,14 @@ describe("tags integration — referential integrity", () => {
|
|
|
347
416
|
describe("tags integration — multi-tenant isolation", () => {
|
|
348
417
|
test("tenant B sees neither tenant A's tags nor assignments", async () => {
|
|
349
418
|
const tagId = await createTag("A-only", admin);
|
|
350
|
-
await assign(tagId, "credit", "credit-8", admin);
|
|
419
|
+
await assign(tagId, "credit", hostId("credit-8"), admin);
|
|
351
420
|
|
|
352
421
|
expect(await listTags(otherTenant)).toHaveLength(0);
|
|
353
422
|
expect(
|
|
354
|
-
await listAssignments(
|
|
423
|
+
await listAssignments(
|
|
424
|
+
{ field: "entityId", op: "eq", value: hostId("credit-8") },
|
|
425
|
+
otherTenant,
|
|
426
|
+
),
|
|
355
427
|
).toHaveLength(0);
|
|
356
428
|
|
|
357
429
|
// tenant A still sees its own
|
|
@@ -378,11 +450,13 @@ describe("tags integration — openToAll access model", () => {
|
|
|
378
450
|
|
|
379
451
|
beforeAll(async () => {
|
|
380
452
|
openStack = await setupTestStack({
|
|
381
|
-
features: [createTagsFeature({ access: { openToAll: true } })],
|
|
453
|
+
features: [createTagsFeature({ access: { openToAll: true } }), hostFixturesFeature],
|
|
382
454
|
});
|
|
383
455
|
await unsafeCreateEntityTable(openStack.db, tagEntity);
|
|
384
456
|
await unsafeCreateEntityTable(openStack.db, tagAssignmentEntity);
|
|
457
|
+
await unsafeCreateEntityTable(openStack.db, creditEntity);
|
|
385
458
|
await createEventsTable(openStack.db);
|
|
459
|
+
await seedHostRows(openStack, CREDIT_TABLE, ["c-1"], unprivileged.tenantId);
|
|
386
460
|
|
|
387
461
|
defaultStack = await setupTestStack({ features: [tagsFeature] });
|
|
388
462
|
await unsafeCreateEntityTable(defaultStack.db, tagEntity);
|
|
@@ -403,7 +477,7 @@ describe("tags integration — openToAll access model", () => {
|
|
|
403
477
|
);
|
|
404
478
|
await openStack.http.writeOk(
|
|
405
479
|
TagsHandlers.assignTag,
|
|
406
|
-
{ tagId: tag.id, entityType: "credit", entityId: "c-1" },
|
|
480
|
+
{ tagId: tag.id, entityType: "credit", entityId: hostId("c-1") },
|
|
407
481
|
unprivileged,
|
|
408
482
|
);
|
|
409
483
|
|
|
@@ -416,14 +490,14 @@ describe("tags integration — openToAll access model", () => {
|
|
|
416
490
|
|
|
417
491
|
const assigned = await openStack.http.queryOk<{ rows: unknown[] }>(
|
|
418
492
|
TagsQueries.assignmentList,
|
|
419
|
-
{ filter: { field: "entityId", op: "eq", value: "c-1" } },
|
|
493
|
+
{ filter: { field: "entityId", op: "eq", value: hostId("c-1") } },
|
|
420
494
|
unprivileged,
|
|
421
495
|
);
|
|
422
496
|
expect(assigned.rows).toHaveLength(1);
|
|
423
497
|
|
|
424
498
|
await openStack.http.writeOk(
|
|
425
499
|
TagsHandlers.removeTag,
|
|
426
|
-
{ tagId: tag.id, entityType: "credit", entityId: "c-1" },
|
|
500
|
+
{ tagId: tag.id, entityType: "credit", entityId: hostId("c-1") },
|
|
427
501
|
unprivileged,
|
|
428
502
|
);
|
|
429
503
|
});
|
package/src/tags/changes.json
CHANGED
|
@@ -1 +1,9 @@
|
|
|
1
|
-
[
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.246.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "assign-tag and remove-tag now verify entityType/entityId against the host entity's own read path",
|
|
6
|
+
"detail": "Before this change, assign-tag and remove-tag took entityType/entityId straight from the client with no check — any dispatch-eligible tenant user could attach a catalog tag to, or detach one from, any object in their tenant, including one they could not otherwise read. The tag-assignment aggregate-id is derived from the tenant, so this was never a cross-tenant hole; it was tagging 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 assignment; 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 assignment and tag lookups, so a denied caller cannot tell an invisible host row apart from a missing assignment. 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 assign-tag or remove-tag 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 assignment can no longer be removed through remove-tag; delete-tag's cascade still detaches it."
|
|
8
|
+
}
|
|
9
|
+
]
|