@cosmicdrift/kumiko-bundled-features 0.265.0 → 0.268.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/auth-email-password/__tests__/query-as-member.integration.test.ts +747 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +16 -6
- package/src/auth-email-password/handlers/invite-accept.write.ts +19 -6
- package/src/auth-email-password/handlers/invite-create.write.ts +1 -2
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +21 -7
- package/src/auth-email-password/handlers/login.write.ts +3 -1
- package/src/auth-email-password/handlers/signup-confirm.write.ts +9 -5
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +10 -2
- package/src/auth-mfa/handlers/enable-start-preauth.write.ts +11 -1
- package/src/auth-mfa/handlers/verify.write.ts +10 -2
- package/src/auth-mfa/mfa-code-verifier.ts +5 -1
- package/src/auth-mfa/mfa-status-checker.ts +7 -1
- package/src/billing-foundation/handlers/list-subscriptions.query.ts +1 -3
- package/src/cap-counter/stock-cap-guard.ts +6 -1
- package/src/crypto-shredding/handlers/forget-subject.write.ts +43 -27
- package/src/custom-fields/db/queries/__tests__/unsafe-read-retrying.test.ts +10 -5
- package/src/custom-fields/db/queries/field-access.ts +3 -1
- package/src/custom-fields/db/queries/quota.ts +3 -1
- package/src/custom-fields/handlers/clear-custom-field.write.ts +6 -0
- package/src/custom-fields/handlers/define-tenant-field.write.ts +6 -0
- package/src/custom-fields/handlers/set-custom-field.write.ts +6 -0
- package/src/delivery/jobs.ts +6 -5
- package/src/feature-toggles/global-feature-state-table.ts +15 -11
- package/src/feature-toggles/handlers/list.query.ts +1 -2
- package/src/feature-toggles/handlers/registered.query.ts +1 -2
- package/src/form-draft/handlers/discard.write.ts +7 -1
- package/src/form-draft/handlers/save.write.ts +11 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +3 -1
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +16 -6
- package/src/inbound-mail-foundation/handlers/list-accounts.query.ts +1 -3
- package/src/inbound-mail-foundation/handlers/list-messages.query.ts +1 -2
- package/src/jobs/handlers/projection-rebuild.job.ts +4 -4
- package/src/jobs/handlers/reindex-entity.job.ts +5 -2
- package/src/ledger/__tests__/ledger-reports-tenant-isolation.integration.test.ts +148 -0
- package/src/ledger/handlers/confirm-schedule-period.write.ts +2 -3
- package/src/ledger/handlers/reports.query.ts +2 -3
- package/src/ledger/handlers/reverse-transaction.write.ts +1 -2
- package/src/managed-pages/handlers/set.write.ts +13 -1
- package/src/notes-history/handlers/add-note.write.ts +17 -8
- package/src/personal-access-tokens/handlers/create.write.ts +2 -1
- package/src/secrets/handlers/list.query.ts +1 -3
- package/src/sessions/handlers/revoke-all-for-user.write.ts +9 -2
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +11 -1
- package/src/tenant-lifecycle/handlers/request-destruction.write.ts +10 -3
- package/src/tier-engine/feature.ts +11 -19
- package/src/tier-engine/handlers/get-tenant-tier.query.ts +7 -6
- package/src/tier-engine/handlers/set-tenant-tier.write.ts +6 -2
- package/src/user/__tests__/user-global-tenancy.integration.test.ts +97 -0
- package/src/user/handlers/update-roles.ts +14 -3
- package/src/user/handlers/update.write.ts +14 -1
- package/src/user/principal-status.ts +22 -1
- package/src/user/schema/user.ts +3 -0
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +3 -1
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +11 -10
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +7 -5
- package/src/user-data-rights/handlers/deletion-grace-period.ts +11 -10
- package/src/user-data-rights/handlers/download-by-job.query.ts +17 -10
- package/src/user-data-rights/handlers/download-by-token.query.ts +21 -12
- package/src/user-data-rights/handlers/export-status.query.ts +8 -4
- package/src/user-data-rights/handlers/lift-restriction.write.ts +21 -9
- package/src/user-data-rights/handlers/my-audit-log.query.ts +7 -4
- package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +13 -8
- package/src/user-data-rights/handlers/request-deletion.write.ts +2 -1
- package/src/user-data-rights/handlers/request-export.write.ts +13 -8
- package/src/user-data-rights/handlers/restrict-account.write.ts +18 -13
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +9 -5
- package/src/user-data-rights/lib/update-user-lifecycle.ts +2 -2
- package/src/user-data-rights/run-forget-cleanup.ts +2 -2
- package/src/user-data-rights/schema/export-job.ts +2 -4
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
// ctx.queryAsMember in Handler-, Hook- and Job-Context. Real HTTP + a real
|
|
2
|
+
// BullMQ worker via setupTestStack — never createTestDispatcher.
|
|
3
|
+
|
|
4
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import type { HandlerContext, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import {
|
|
8
|
+
createEntity,
|
|
9
|
+
createTextField,
|
|
10
|
+
defineEntityQueryHandler,
|
|
11
|
+
defineEntityWriteHandler,
|
|
12
|
+
defineFeature,
|
|
13
|
+
from,
|
|
14
|
+
HookPhases,
|
|
15
|
+
SYSTEM_ROLE,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
18
|
+
import {
|
|
19
|
+
setupTestStack,
|
|
20
|
+
type TestStack,
|
|
21
|
+
TestUsers,
|
|
22
|
+
testTenantId,
|
|
23
|
+
unsafeCreateEntityTable,
|
|
24
|
+
unsafePushTables,
|
|
25
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
26
|
+
import {
|
|
27
|
+
createTestEnvelopeCipher,
|
|
28
|
+
resetTestTables,
|
|
29
|
+
updateRows,
|
|
30
|
+
waitFor,
|
|
31
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
32
|
+
import { z } from "zod";
|
|
33
|
+
import {
|
|
34
|
+
createComplianceProfilesFeature,
|
|
35
|
+
tenantComplianceProfileEntity,
|
|
36
|
+
tenantComplianceProfileTable,
|
|
37
|
+
} from "../../compliance-profiles";
|
|
38
|
+
import { createConfigFeature } from "../../config";
|
|
39
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
40
|
+
import { configValuesTable } from "../../config/table";
|
|
41
|
+
import { hashPassword } from "../../shared";
|
|
42
|
+
import {
|
|
43
|
+
createTenantFeature,
|
|
44
|
+
TenantHandlers,
|
|
45
|
+
type TenantLifecycleStatus,
|
|
46
|
+
tenantMembershipsTable,
|
|
47
|
+
} from "../../tenant";
|
|
48
|
+
import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
|
|
49
|
+
import { seedTenantMembership } from "../../tenant/testing";
|
|
50
|
+
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
51
|
+
import { resetTenantLifecycleGateCacheForTests } from "../../tenant-lifecycle/lifecycle-gate";
|
|
52
|
+
import { createUserFeature, USER_STATUS, UserHandlers, userEntity, userTable } from "../../user";
|
|
53
|
+
import { AuthHandlers } from "../constants";
|
|
54
|
+
import { createAuthEmailPasswordFeature } from "../feature";
|
|
55
|
+
|
|
56
|
+
// ── Test fixture: an owner-scoped entity + probe handlers/hooks/job ────────
|
|
57
|
+
|
|
58
|
+
const noteEntity = createEntity({
|
|
59
|
+
table: "qam_notes",
|
|
60
|
+
fields: {
|
|
61
|
+
ownerId: createTextField({ personal: false, reason: "test_fixture" }),
|
|
62
|
+
body: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
63
|
+
},
|
|
64
|
+
access: {
|
|
65
|
+
read: { Admin: "all", User: from("user:id", "ownerId") },
|
|
66
|
+
write: { Admin: "all", User: from("user:id", "ownerId") },
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const hookNoteAEntity = createEntity({
|
|
71
|
+
table: "qam_hook_note_a",
|
|
72
|
+
fields: { label: createTextField({ personal: false, reason: "test_fixture", required: true }) },
|
|
73
|
+
});
|
|
74
|
+
const hookNoteBEntity = createEntity({
|
|
75
|
+
table: "qam_hook_note_b",
|
|
76
|
+
fields: { label: createTextField({ personal: false, reason: "test_fixture", required: true }) },
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
function errorReason(details: unknown): string | undefined {
|
|
80
|
+
if (!details || typeof details !== "object" || !("reason" in details)) return undefined;
|
|
81
|
+
return typeof details.reason === "string" ? details.reason : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const authClaimsCalls: string[] = [];
|
|
85
|
+
// Shared slot is safe: tests within this file run serially (bun:test default).
|
|
86
|
+
let hookTargetUserId = "";
|
|
87
|
+
const hookNoHatchOutcomes: Array<{ readonly threw: boolean }> = [];
|
|
88
|
+
const hookWithHatchResults: unknown[] = [];
|
|
89
|
+
const jobQueryAsMemberResults: unknown[] = [];
|
|
90
|
+
|
|
91
|
+
const WHOAMI_QN = "queryasmemberprobe:query:whoami";
|
|
92
|
+
const NOTE_LIST_QN = "queryasmemberprobe:query:note:list";
|
|
93
|
+
const TRIES_WRITE_QN = "queryasmemberprobe:query:tries-write";
|
|
94
|
+
const TRIES_APPEND_EVENT_QN = "queryasmemberprobe:query:tries-append-event";
|
|
95
|
+
const TRIES_FETCH_FOR_WRITING_QN = "queryasmemberprobe:query:tries-fetch-for-writing";
|
|
96
|
+
const TRIES_JOB_RUNNER_QN = "queryasmemberprobe:query:tries-job-runner";
|
|
97
|
+
const SYSTEM_SCOPE_READ_AS_MEMBER_QN = "queryasmembersystemscope:write:read-as-member";
|
|
98
|
+
|
|
99
|
+
const probeFeature = defineFeature("queryasmemberprobe", (r) => {
|
|
100
|
+
r.entity("note", noteEntity);
|
|
101
|
+
r.entity("hookNoteA", hookNoteAEntity);
|
|
102
|
+
r.entity("hookNoteB", hookNoteBEntity);
|
|
103
|
+
|
|
104
|
+
r.writeHandler(
|
|
105
|
+
defineEntityWriteHandler("note:create", noteEntity, { access: { roles: ["Admin"] } }),
|
|
106
|
+
);
|
|
107
|
+
r.queryHandler(
|
|
108
|
+
defineEntityQueryHandler("note:list", noteEntity, { access: { roles: ["Admin", "User"] } }),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
r.queryHandler(
|
|
112
|
+
"whoami",
|
|
113
|
+
z.object({}),
|
|
114
|
+
async (query) => ({
|
|
115
|
+
id: query.user.id,
|
|
116
|
+
roles: query.user.roles,
|
|
117
|
+
claims: query.user.claims ?? null,
|
|
118
|
+
sid: query.user.sid ?? null,
|
|
119
|
+
origin: query.user.origin ?? null,
|
|
120
|
+
}),
|
|
121
|
+
{ access: { roles: ["Admin", "User"] } },
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Target for the read-only-enforcement tests: a query handler reached via
|
|
125
|
+
// ctx.queryAsMember that tries to write.
|
|
126
|
+
r.queryHandler(
|
|
127
|
+
"tries-write",
|
|
128
|
+
z.object({}),
|
|
129
|
+
async (_query, ctx) => ctx.write("queryasmemberprobe:write:note:create", { body: "nope" }),
|
|
130
|
+
{ access: { roles: ["Admin", "User"] } },
|
|
131
|
+
);
|
|
132
|
+
r.queryHandler(
|
|
133
|
+
"tries-append-event",
|
|
134
|
+
z.object({}),
|
|
135
|
+
async (_query, ctx) => {
|
|
136
|
+
await ctx.unsafeAppendEvent({
|
|
137
|
+
aggregateId: crypto.randomUUID(),
|
|
138
|
+
aggregateType: "qam-probe",
|
|
139
|
+
type: "queryasmemberprobe:event:nope",
|
|
140
|
+
payload: {},
|
|
141
|
+
});
|
|
142
|
+
return { ok: true };
|
|
143
|
+
},
|
|
144
|
+
{ access: { roles: ["Admin", "User"] } },
|
|
145
|
+
);
|
|
146
|
+
r.queryHandler(
|
|
147
|
+
"tries-fetch-for-writing",
|
|
148
|
+
z.object({}),
|
|
149
|
+
async (_query, ctx) => {
|
|
150
|
+
const handle = await ctx.fetchForWriting({
|
|
151
|
+
aggregateId: crypto.randomUUID(),
|
|
152
|
+
aggregateType: "qam-probe",
|
|
153
|
+
});
|
|
154
|
+
await handle.appendOne({ type: "queryasmemberprobe:event:nope", payload: {} });
|
|
155
|
+
return { ok: true };
|
|
156
|
+
},
|
|
157
|
+
{ access: { roles: ["Admin", "User"] } },
|
|
158
|
+
);
|
|
159
|
+
r.queryHandler(
|
|
160
|
+
"tries-job-runner",
|
|
161
|
+
z.object({}),
|
|
162
|
+
async (_query, ctx) => {
|
|
163
|
+
const jobRunner = ctx["jobRunner"] as
|
|
164
|
+
| { dispatch: (name: string, payload: Record<string, unknown>) => Promise<string> }
|
|
165
|
+
| undefined;
|
|
166
|
+
if (!jobRunner) return { ran: false };
|
|
167
|
+
await jobRunner.dispatch("queryasmemberprobe:job:noop", {});
|
|
168
|
+
return { ran: true };
|
|
169
|
+
},
|
|
170
|
+
{ access: { roles: ["Admin", "User"] } },
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// Caller-facing write handler that reads as a member — WITH and WITHOUT
|
|
174
|
+
// escapeHatch (systemIdentitySwitchDenied gate).
|
|
175
|
+
r.writeHandler(
|
|
176
|
+
"read-as-member",
|
|
177
|
+
z.object({
|
|
178
|
+
userId: z.string(),
|
|
179
|
+
targetQn: z.string(),
|
|
180
|
+
payload: z.record(z.string(), z.unknown()).default({}),
|
|
181
|
+
}),
|
|
182
|
+
async (event, ctx) => {
|
|
183
|
+
const data = await ctx.queryAsMember(
|
|
184
|
+
event.payload.userId,
|
|
185
|
+
event.payload.targetQn,
|
|
186
|
+
event.payload.payload,
|
|
187
|
+
);
|
|
188
|
+
return { isSuccess: true as const, data };
|
|
189
|
+
},
|
|
190
|
+
{ access: { roles: ["Admin"] }, escapeHatch: { reason: "test: reads as a stored member" } },
|
|
191
|
+
);
|
|
192
|
+
r.writeHandler(
|
|
193
|
+
"read-as-member-no-hatch",
|
|
194
|
+
z.object({
|
|
195
|
+
userId: z.string(),
|
|
196
|
+
targetQn: z.string(),
|
|
197
|
+
payload: z.record(z.string(), z.unknown()).default({}),
|
|
198
|
+
}),
|
|
199
|
+
async (event, ctx) => {
|
|
200
|
+
const data = await ctx.queryAsMember(
|
|
201
|
+
event.payload.userId,
|
|
202
|
+
event.payload.targetQn,
|
|
203
|
+
event.payload.payload,
|
|
204
|
+
);
|
|
205
|
+
return { isSuccess: true as const, data };
|
|
206
|
+
},
|
|
207
|
+
{ access: { roles: ["Admin"] } },
|
|
208
|
+
);
|
|
209
|
+
r.writeHandler(
|
|
210
|
+
"read-as-member-twice",
|
|
211
|
+
z.object({ userId: z.string() }),
|
|
212
|
+
async (event, ctx) => {
|
|
213
|
+
await ctx.queryAsMember(event.payload.userId, WHOAMI_QN, {});
|
|
214
|
+
await ctx.queryAsMember(event.payload.userId, WHOAMI_QN, {});
|
|
215
|
+
return { isSuccess: true as const, data: { ok: true } };
|
|
216
|
+
},
|
|
217
|
+
{ access: { roles: ["Admin"] }, escapeHatch: { reason: "test: cache check" } },
|
|
218
|
+
);
|
|
219
|
+
r.writeHandler(
|
|
220
|
+
"read-as-member-two-users",
|
|
221
|
+
z.object({ userIdA: z.string(), userIdB: z.string() }),
|
|
222
|
+
async (event, ctx) => {
|
|
223
|
+
await ctx.queryAsMember(event.payload.userIdA, WHOAMI_QN, {});
|
|
224
|
+
await ctx.queryAsMember(event.payload.userIdB, WHOAMI_QN, {});
|
|
225
|
+
return { isSuccess: true as const, data: { ok: true } };
|
|
226
|
+
},
|
|
227
|
+
{ access: { roles: ["Admin"] }, escapeHatch: { reason: "test: two-user cache check" } },
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// authClaims — counter parity with cache + real-login-claims check.
|
|
231
|
+
r.authClaims(async (user) => {
|
|
232
|
+
authClaimsCalls.push(user.id);
|
|
233
|
+
return { marker: user.id };
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// A hook's own escapeHatch grants independently of the handler it fires on, in both directions.
|
|
237
|
+
r.writeHandler({
|
|
238
|
+
...defineEntityWriteHandler("hook-note-a:create", hookNoteAEntity, {
|
|
239
|
+
access: { roles: ["Admin"] },
|
|
240
|
+
}),
|
|
241
|
+
escapeHatch: { reason: "test: handler has its own grant" },
|
|
242
|
+
});
|
|
243
|
+
r.writeHandler(
|
|
244
|
+
defineEntityWriteHandler("hook-note-b:create", hookNoteBEntity, {
|
|
245
|
+
access: { roles: ["Admin"] },
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
r.hook(
|
|
250
|
+
"postSave",
|
|
251
|
+
"hook-note-a:create",
|
|
252
|
+
async (_result, ctx) => {
|
|
253
|
+
const handlerCtx = ctx as unknown as HandlerContext; // @cast-boundary: hook ctx is AppContext at the type level, but the dispatcher hands it a real HandlerContext at runtime
|
|
254
|
+
try {
|
|
255
|
+
await handlerCtx.queryAsMember(hookTargetUserId, WHOAMI_QN, {});
|
|
256
|
+
hookNoHatchOutcomes.push({ threw: false });
|
|
257
|
+
} catch (err) {
|
|
258
|
+
hookNoHatchOutcomes.push({ threw: true });
|
|
259
|
+
throw err;
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
{ phase: HookPhases.inTransaction },
|
|
263
|
+
);
|
|
264
|
+
r.hook(
|
|
265
|
+
"postSave",
|
|
266
|
+
"hook-note-b:create",
|
|
267
|
+
async (_result, ctx) => {
|
|
268
|
+
const handlerCtx = ctx as unknown as HandlerContext; // @cast-boundary: same as the hook-note-a probe above
|
|
269
|
+
const res = await handlerCtx.queryAsMember(hookTargetUserId, WHOAMI_QN, {});
|
|
270
|
+
hookWithHatchResults.push(res);
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
phase: HookPhases.inTransaction,
|
|
274
|
+
escapeHatch: { reason: "test: hook needs to read as a member" },
|
|
275
|
+
},
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
// Jobs stay ungated.
|
|
279
|
+
r.job("noop", { trigger: { manual: true }, retries: 0 }, async () => {});
|
|
280
|
+
r.job("query-as-member-job", { trigger: { manual: true }, retries: 0 }, async (payload, ctx) => {
|
|
281
|
+
const result = await ctx.queryAsMember(payload["userId"] as string, WHOAMI_QN, {}); // @cast-boundary dynamic-key
|
|
282
|
+
jobQueryAsMemberResults.push(result);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// A whole-feature r.systemScope() grants queryAsMember without an
|
|
287
|
+
// escapeHatch — isSystem alone sets allowSystemIdentity.
|
|
288
|
+
const systemScopeProbeFeature = defineFeature("queryasmembersystemscope", (r) => {
|
|
289
|
+
r.systemScope();
|
|
290
|
+
r.writeHandler(
|
|
291
|
+
"read-as-member",
|
|
292
|
+
z.object({ userId: z.string() }),
|
|
293
|
+
async (event, ctx) => {
|
|
294
|
+
const data = await ctx.queryAsMember(event.payload.userId, WHOAMI_QN, {});
|
|
295
|
+
return { isSuccess: true as const, data };
|
|
296
|
+
},
|
|
297
|
+
{ access: { roles: ["Admin"] } },
|
|
298
|
+
);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
let stack: TestStack;
|
|
302
|
+
|
|
303
|
+
const TENANT_A: TenantId = testTenantId(1);
|
|
304
|
+
const TENANT_B: TenantId = testTenantId(2);
|
|
305
|
+
|
|
306
|
+
beforeAll(async () => {
|
|
307
|
+
const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
|
|
308
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
309
|
+
|
|
310
|
+
stack = await setupTestStack({
|
|
311
|
+
features: [
|
|
312
|
+
createConfigFeature(),
|
|
313
|
+
createUserFeature(),
|
|
314
|
+
createTenantFeature(),
|
|
315
|
+
createComplianceProfilesFeature(),
|
|
316
|
+
createTenantLifecycleFeature(),
|
|
317
|
+
createAuthEmailPasswordFeature(),
|
|
318
|
+
probeFeature,
|
|
319
|
+
systemScopeProbeFeature,
|
|
320
|
+
],
|
|
321
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
322
|
+
authConfig: {
|
|
323
|
+
membershipQuery: "tenant:query:memberships",
|
|
324
|
+
loginHandler: AuthHandlers.login,
|
|
325
|
+
},
|
|
326
|
+
jobs: { consumerLane: "worker" },
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
330
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
331
|
+
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
332
|
+
await unsafeCreateEntityTable(stack.db, noteEntity);
|
|
333
|
+
await unsafeCreateEntityTable(stack.db, hookNoteAEntity);
|
|
334
|
+
await unsafeCreateEntityTable(stack.db, hookNoteBEntity);
|
|
335
|
+
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
afterAll(async () => {
|
|
339
|
+
await stack.cleanup();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
beforeEach(async () => {
|
|
343
|
+
resetTenantLifecycleGateCacheForTests();
|
|
344
|
+
await resetTestTables(stack.db, [
|
|
345
|
+
userTable,
|
|
346
|
+
tenantTable,
|
|
347
|
+
tenantComplianceProfileTable,
|
|
348
|
+
tenantMembershipsTable,
|
|
349
|
+
eventsTable,
|
|
350
|
+
]);
|
|
351
|
+
authClaimsCalls.length = 0;
|
|
352
|
+
hookNoHatchOutcomes.length = 0;
|
|
353
|
+
hookWithHatchResults.length = 0;
|
|
354
|
+
jobQueryAsMemberResults.length = 0;
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
async function createTenant(id: TenantId): Promise<void> {
|
|
360
|
+
await stack.http.writeOk(
|
|
361
|
+
TenantHandlers.create,
|
|
362
|
+
{ id, key: `t-${id.slice(-8)}`, name: "Tenant" },
|
|
363
|
+
TestUsers.systemAdmin,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function setTenantStatus(id: TenantId, status: TenantLifecycleStatus): Promise<void> {
|
|
368
|
+
await updateRows(stack.db, tenantTable, { status }, { id });
|
|
369
|
+
resetTenantLifecycleGateCacheForTests();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function createUser(email: string, password: string): Promise<string> {
|
|
373
|
+
const hash = await hashPassword(password);
|
|
374
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
375
|
+
UserHandlers.create,
|
|
376
|
+
{ email, passwordHash: hash, displayName: email.split("@")[0] ?? "user" },
|
|
377
|
+
TestUsers.systemAdmin,
|
|
378
|
+
);
|
|
379
|
+
return created.id;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function addMembership(
|
|
383
|
+
userId: string,
|
|
384
|
+
tenantId: TenantId,
|
|
385
|
+
roles: readonly string[] = ["User"],
|
|
386
|
+
): Promise<void> {
|
|
387
|
+
await seedTenantMembership(stack.db, { userId, tenantId, roles });
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const admin: SessionUser = { ...TestUsers.admin, tenantId: TENANT_A };
|
|
391
|
+
|
|
392
|
+
async function readAsMember(
|
|
393
|
+
userId: string,
|
|
394
|
+
targetQn: string,
|
|
395
|
+
payload: Record<string, unknown> = {},
|
|
396
|
+
) {
|
|
397
|
+
return stack.http.writeOk<unknown>(
|
|
398
|
+
"queryasmemberprobe:write:read-as-member",
|
|
399
|
+
{ userId, targetQn, payload },
|
|
400
|
+
admin,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function readAsMemberErr(
|
|
405
|
+
userId: string,
|
|
406
|
+
targetQn: string,
|
|
407
|
+
payload: Record<string, unknown> = {},
|
|
408
|
+
) {
|
|
409
|
+
return stack.http.writeErr(
|
|
410
|
+
"queryasmemberprobe:write:read-as-member",
|
|
411
|
+
{ userId, targetQn, payload },
|
|
412
|
+
admin,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function createNote(ownerId: string, body: string): Promise<void> {
|
|
417
|
+
await stack.http.writeOk("queryasmemberprobe:write:note:create", { ownerId, body }, admin);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ── 1. Ownership — reading as a member applies THEIR row filters ──────────
|
|
421
|
+
|
|
422
|
+
describe("ctx.queryAsMember — ownership rules of the resolved member apply", () => {
|
|
423
|
+
test("reading as user A returns only A's rows, reading as B only B's", async () => {
|
|
424
|
+
await createTenant(TENANT_A);
|
|
425
|
+
const userA = await createUser("owner-a@example.com", "pw-long-enough-1");
|
|
426
|
+
const userB = await createUser("owner-b@example.com", "pw-long-enough-2");
|
|
427
|
+
await addMembership(userA, TENANT_A);
|
|
428
|
+
await addMembership(userB, TENANT_A);
|
|
429
|
+
await createNote(userA, "a-note-1");
|
|
430
|
+
await createNote(userA, "a-note-2");
|
|
431
|
+
await createNote(userB, "b-note-1");
|
|
432
|
+
|
|
433
|
+
const asA = (await readAsMember(userA, NOTE_LIST_QN)) as {
|
|
434
|
+
rows: Array<{ ownerId: string }>;
|
|
435
|
+
};
|
|
436
|
+
expect(asA.rows).toHaveLength(2);
|
|
437
|
+
expect(asA.rows.every((row) => row.ownerId === userA)).toBe(true);
|
|
438
|
+
|
|
439
|
+
const asB = (await readAsMember(userB, NOTE_LIST_QN)) as {
|
|
440
|
+
rows: Array<{ ownerId: string }>;
|
|
441
|
+
};
|
|
442
|
+
expect(asB.rows).toHaveLength(1);
|
|
443
|
+
expect(asB.rows[0]?.ownerId).toBe(userB);
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
// ── 2. whoami parity with a fresh interactive login ────────────────────────
|
|
448
|
+
|
|
449
|
+
describe("ctx.queryAsMember — resolved SessionUser matches an interactive login", () => {
|
|
450
|
+
test("no sid, origin member-resolution, roles/claims equal to a fresh HTTP login", async () => {
|
|
451
|
+
await createTenant(TENANT_A);
|
|
452
|
+
const userId = await createUser("parity@example.com", "pw-long-enough-3");
|
|
453
|
+
await addMembership(userId, TENANT_A);
|
|
454
|
+
|
|
455
|
+
const loginRes = await stack.http.raw("POST", "/api/auth/login", {
|
|
456
|
+
email: "parity@example.com",
|
|
457
|
+
password: "pw-long-enough-3",
|
|
458
|
+
});
|
|
459
|
+
expect(loginRes.status).toBe(200);
|
|
460
|
+
const loginBody = (await loginRes.json()) as { token: string };
|
|
461
|
+
const loginPayload = await stack.jwt.verify(loginBody.token);
|
|
462
|
+
|
|
463
|
+
const whoami = (await readAsMember(userId, WHOAMI_QN)) as {
|
|
464
|
+
id: string;
|
|
465
|
+
roles: readonly string[];
|
|
466
|
+
claims: Record<string, unknown> | null;
|
|
467
|
+
sid: string | null;
|
|
468
|
+
origin: string | null;
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
expect(whoami.sid).toBeNull();
|
|
472
|
+
expect(whoami.origin).toBe("member-resolution");
|
|
473
|
+
expect(whoami.roles).toEqual(loginPayload.roles);
|
|
474
|
+
expect(whoami.claims).toEqual(loginPayload.claims ?? null);
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// ── 3. AccessDenied — same generic error for every rejection reason ───────
|
|
479
|
+
|
|
480
|
+
describe("ctx.queryAsMember — AccessDenied, same generic error for every rejection cause", () => {
|
|
481
|
+
const GENERIC_MESSAGE = "not permitted to read as this member";
|
|
482
|
+
const GENERIC_DETAILS = { reason: "member_resolution_denied" };
|
|
483
|
+
|
|
484
|
+
test("non-member userId", async () => {
|
|
485
|
+
await createTenant(TENANT_A);
|
|
486
|
+
await createTenant(TENANT_B);
|
|
487
|
+
const userId = await createUser("nonmember@example.com", "pw-long-enough-4");
|
|
488
|
+
await addMembership(userId, TENANT_B);
|
|
489
|
+
|
|
490
|
+
const err = await readAsMemberErr(userId, WHOAMI_QN);
|
|
491
|
+
expect(err.code).toBe("access_denied");
|
|
492
|
+
expect(err.message).toBe(GENERIC_MESSAGE);
|
|
493
|
+
expect(err.details).toEqual(GENERIC_DETAILS);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("restricted (blocked) principal", async () => {
|
|
497
|
+
await createTenant(TENANT_A);
|
|
498
|
+
const userId = await createUser("restricted@example.com", "pw-long-enough-5");
|
|
499
|
+
await addMembership(userId, TENANT_A);
|
|
500
|
+
await updateRows(stack.db, userTable, { status: USER_STATUS.Restricted }, { id: userId });
|
|
501
|
+
|
|
502
|
+
const err = await readAsMemberErr(userId, WHOAMI_QN);
|
|
503
|
+
expect(err.code).toBe("access_denied");
|
|
504
|
+
expect(err.message).toBe(GENERIC_MESSAGE);
|
|
505
|
+
expect(err.details).toEqual(GENERIC_DETAILS);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test("unknown principal (membership seeded, no user row) — background policy fails closed", async () => {
|
|
509
|
+
await createTenant(TENANT_A);
|
|
510
|
+
const unknownUserId = crypto.randomUUID();
|
|
511
|
+
await addMembership(unknownUserId, TENANT_A);
|
|
512
|
+
|
|
513
|
+
const err = await readAsMemberErr(unknownUserId, WHOAMI_QN);
|
|
514
|
+
expect(err.code).toBe("access_denied");
|
|
515
|
+
expect(err.message).toBe(GENERIC_MESSAGE);
|
|
516
|
+
expect(err.details).toEqual(GENERIC_DETAILS);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("tenant destroying", async () => {
|
|
520
|
+
await createTenant(TENANT_A);
|
|
521
|
+
const userId = await createUser("destroying@example.com", "pw-long-enough-6");
|
|
522
|
+
await addMembership(userId, TENANT_A);
|
|
523
|
+
await setTenantStatus(TENANT_A, "destroying");
|
|
524
|
+
|
|
525
|
+
const err = await readAsMemberErr(userId, WHOAMI_QN);
|
|
526
|
+
expect(err.code).toBe("access_denied");
|
|
527
|
+
expect(err.message).toBe(GENERIC_MESSAGE);
|
|
528
|
+
expect(err.details).toEqual(GENERIC_DETAILS);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// Background policy is stricter than interactive sign-in: destroyRequested
|
|
532
|
+
// (cancel-window) is rejected too, unlike INTERACTIVE_SIGN_IN_POLICY.
|
|
533
|
+
test("tenant destroyRequested — background policy is strict (interactive sign-in would allow this)", async () => {
|
|
534
|
+
await createTenant(TENANT_A);
|
|
535
|
+
const userId = await createUser("destroyrequested@example.com", "pw-long-enough-7");
|
|
536
|
+
await addMembership(userId, TENANT_A);
|
|
537
|
+
await setTenantStatus(TENANT_A, "destroyRequested");
|
|
538
|
+
|
|
539
|
+
const err = await readAsMemberErr(userId, WHOAMI_QN);
|
|
540
|
+
expect(err.code).toBe("access_denied");
|
|
541
|
+
expect(err.message).toBe(GENERIC_MESSAGE);
|
|
542
|
+
expect(err.details).toEqual(GENERIC_DETAILS);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test("would-be-SYSTEM principal (global SYSTEM_ROLE) — rejected the same generic way", async () => {
|
|
546
|
+
await createTenant(TENANT_A);
|
|
547
|
+
const userId = await createUser("wouldbesystem@example.com", "pw-long-enough-16");
|
|
548
|
+
await addMembership(userId, TENANT_A);
|
|
549
|
+
await updateRows(stack.db, userTable, { roles: [SYSTEM_ROLE] }, { id: userId });
|
|
550
|
+
|
|
551
|
+
const err = await readAsMemberErr(userId, WHOAMI_QN);
|
|
552
|
+
expect(err.code).toBe("access_denied");
|
|
553
|
+
expect(err.message).toBe(GENERIC_MESSAGE);
|
|
554
|
+
expect(err.details).toEqual(GENERIC_DETAILS);
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// ── 4. Cache — repeated calls for the same user resolve once ──────────────
|
|
559
|
+
|
|
560
|
+
describe("ctx.queryAsMember — per-context cache", () => {
|
|
561
|
+
test("two calls for the same user increment the authClaims counter exactly once", async () => {
|
|
562
|
+
await createTenant(TENANT_A);
|
|
563
|
+
const userId = await createUser("cached@example.com", "pw-long-enough-8");
|
|
564
|
+
await addMembership(userId, TENANT_A);
|
|
565
|
+
|
|
566
|
+
authClaimsCalls.length = 0;
|
|
567
|
+
const result = await stack.http.writeOk(
|
|
568
|
+
"queryasmemberprobe:write:read-as-member-twice",
|
|
569
|
+
{ userId },
|
|
570
|
+
admin,
|
|
571
|
+
);
|
|
572
|
+
expect(result).toBeDefined();
|
|
573
|
+
expect(authClaimsCalls.filter((id) => id === userId)).toHaveLength(1);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("two DIFFERENT users in one run resolve independently — authClaims counter +2", async () => {
|
|
577
|
+
await createTenant(TENANT_A);
|
|
578
|
+
const userA = await createUser("cacheA@example.com", "pw-long-enough-17");
|
|
579
|
+
const userB = await createUser("cacheB@example.com", "pw-long-enough-18");
|
|
580
|
+
await addMembership(userA, TENANT_A);
|
|
581
|
+
await addMembership(userB, TENANT_A);
|
|
582
|
+
|
|
583
|
+
authClaimsCalls.length = 0;
|
|
584
|
+
const result = await stack.http.writeOk(
|
|
585
|
+
"queryasmemberprobe:write:read-as-member-two-users",
|
|
586
|
+
{ userIdA: userA, userIdB: userB },
|
|
587
|
+
admin,
|
|
588
|
+
);
|
|
589
|
+
expect(result).toBeDefined();
|
|
590
|
+
expect(authClaimsCalls.filter((id) => id === userA || id === userB)).toHaveLength(2);
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// ── 5. Grant — the same escapeHatch/systemIdentitySwitch gate as SYSTEM queryAs ──
|
|
595
|
+
|
|
596
|
+
describe("ctx.queryAsMember — gated like a SYSTEM queryAs/writeAs", () => {
|
|
597
|
+
test("handler without escapeHatch: denied", async () => {
|
|
598
|
+
await createTenant(TENANT_A);
|
|
599
|
+
const userId = await createUser("nograte@example.com", "pw-long-enough-9");
|
|
600
|
+
await addMembership(userId, TENANT_A);
|
|
601
|
+
|
|
602
|
+
const err = await stack.http.writeErr(
|
|
603
|
+
"queryasmemberprobe:write:read-as-member-no-hatch",
|
|
604
|
+
{ userId, targetQn: WHOAMI_QN, payload: {} },
|
|
605
|
+
admin,
|
|
606
|
+
);
|
|
607
|
+
expect(err.code).toBe("access_denied");
|
|
608
|
+
expect(errorReason(err.details)).toBe("system_identity_switch_denied");
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test("hook WITHOUT its own escapeHatch does not inherit the handler's grant: denied even though the handler has one", async () => {
|
|
612
|
+
await createTenant(TENANT_A);
|
|
613
|
+
const userId = await createUser("hooknohatch@example.com", "pw-long-enough-10");
|
|
614
|
+
await addMembership(userId, TENANT_A);
|
|
615
|
+
hookTargetUserId = userId;
|
|
616
|
+
|
|
617
|
+
const err = await stack.http.writeErr(
|
|
618
|
+
"queryasmemberprobe:write:hook-note-a:create",
|
|
619
|
+
{ label: "rolls back" },
|
|
620
|
+
admin,
|
|
621
|
+
);
|
|
622
|
+
expect(err.code).toBe("access_denied");
|
|
623
|
+
expect(errorReason(err.details)).toBe("system_identity_switch_denied");
|
|
624
|
+
expect(hookNoHatchOutcomes).toEqual([{ threw: true }]);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test("hook WITH its own escapeHatch works even though the handler has none", async () => {
|
|
628
|
+
await createTenant(TENANT_A);
|
|
629
|
+
const userId = await createUser("hookwithhatch@example.com", "pw-long-enough-11");
|
|
630
|
+
await addMembership(userId, TENANT_A);
|
|
631
|
+
hookTargetUserId = userId;
|
|
632
|
+
|
|
633
|
+
const result = await stack.http.writeOk<{ id: string }>(
|
|
634
|
+
"queryasmemberprobe:write:hook-note-b:create",
|
|
635
|
+
{ label: "commits" },
|
|
636
|
+
admin,
|
|
637
|
+
);
|
|
638
|
+
expect(result.id).toBeDefined();
|
|
639
|
+
expect(hookWithHatchResults).toHaveLength(1);
|
|
640
|
+
expect((hookWithHatchResults[0] as { id: string }).id).toBe(userId);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test("job: ctx.queryAsMember works ungated", async () => {
|
|
644
|
+
await createTenant(TENANT_A);
|
|
645
|
+
const userId = await createUser("jobmember@example.com", "pw-long-enough-12");
|
|
646
|
+
await addMembership(userId, TENANT_A);
|
|
647
|
+
|
|
648
|
+
// The job's tenant falls back to SYSTEM_TENANT_ID without one of
|
|
649
|
+
// _tenantId/payload.tenantId — createMemberReaderFn fails closed on that.
|
|
650
|
+
await stack.jobRunner?.dispatch("queryasmemberprobe:job:query-as-member-job", {
|
|
651
|
+
userId,
|
|
652
|
+
tenantId: TENANT_A,
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
await waitFor(() => {
|
|
656
|
+
expect(jobQueryAsMemberResults).toHaveLength(1);
|
|
657
|
+
});
|
|
658
|
+
const jobResult = jobQueryAsMemberResults[0] as {
|
|
659
|
+
id: string;
|
|
660
|
+
origin: string;
|
|
661
|
+
sid: string | null;
|
|
662
|
+
};
|
|
663
|
+
expect(jobResult.origin).toBe("member-resolution");
|
|
664
|
+
expect(jobResult.id).toBe(userId);
|
|
665
|
+
expect(jobResult.sid).toBeNull();
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
test("r.systemScope() feature handler: queryAsMember works without an escapeHatch", async () => {
|
|
669
|
+
await createTenant(TENANT_A);
|
|
670
|
+
const userId = await createUser("systemscope@example.com", "pw-long-enough-19");
|
|
671
|
+
await addMembership(userId, TENANT_A);
|
|
672
|
+
|
|
673
|
+
const result = await stack.http.writeOk<{ id: string }>(
|
|
674
|
+
SYSTEM_SCOPE_READ_AS_MEMBER_QN,
|
|
675
|
+
{ userId },
|
|
676
|
+
admin,
|
|
677
|
+
);
|
|
678
|
+
expect(result.id).toBe(userId);
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
// ── 6. Read-only enforcement — a queried handler cannot write ─────────────
|
|
683
|
+
|
|
684
|
+
describe("ctx.queryAsMember — the resolved principal is read-only (no writeAsMember)", () => {
|
|
685
|
+
test("target query handler calling ctx.write → denied with member_resolution_read_only", async () => {
|
|
686
|
+
await createTenant(TENANT_A);
|
|
687
|
+
const userId = await createUser("triesWrite@example.com", "pw-long-enough-13");
|
|
688
|
+
await addMembership(userId, TENANT_A);
|
|
689
|
+
|
|
690
|
+
const err = await readAsMemberErr(userId, TRIES_WRITE_QN);
|
|
691
|
+
expect(err.code).toBe("access_denied");
|
|
692
|
+
expect(errorReason(err.details)).toBe("member_resolution_read_only");
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
test("target query handler calling ctx.unsafeAppendEvent → denied with member_resolution_read_only", async () => {
|
|
696
|
+
await createTenant(TENANT_A);
|
|
697
|
+
const userId = await createUser("triesAppend@example.com", "pw-long-enough-14");
|
|
698
|
+
await addMembership(userId, TENANT_A);
|
|
699
|
+
|
|
700
|
+
const err = await readAsMemberErr(userId, TRIES_APPEND_EVENT_QN);
|
|
701
|
+
expect(err.code).toBe("access_denied");
|
|
702
|
+
expect(errorReason(err.details)).toBe("member_resolution_read_only");
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
test("target query handler calling ctx.jobRunner → denied with member_resolution_read_only", async () => {
|
|
706
|
+
await createTenant(TENANT_A);
|
|
707
|
+
const userId = await createUser("triesJob@example.com", "pw-long-enough-15");
|
|
708
|
+
await addMembership(userId, TENANT_A);
|
|
709
|
+
|
|
710
|
+
const err = await readAsMemberErr(userId, TRIES_JOB_RUNNER_QN);
|
|
711
|
+
expect(err.code).toBe("access_denied");
|
|
712
|
+
expect(errorReason(err.details)).toBe("member_resolution_read_only");
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
test("target query handler calling ctx.fetchForWriting(...).appendOne → denied with member_resolution_read_only", async () => {
|
|
716
|
+
await createTenant(TENANT_A);
|
|
717
|
+
const userId = await createUser("triesFetchForWriting@example.com", "pw-long-enough-20");
|
|
718
|
+
await addMembership(userId, TENANT_A);
|
|
719
|
+
|
|
720
|
+
const err = await readAsMemberErr(userId, TRIES_FETCH_FOR_WRITING_QN);
|
|
721
|
+
expect(err.code).toBe("access_denied");
|
|
722
|
+
expect(errorReason(err.details)).toBe("member_resolution_read_only");
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test("stack.dispatcher.write called directly with a resolved-like user (origin set) → denied", async () => {
|
|
726
|
+
await createTenant(TENANT_A);
|
|
727
|
+
const userId = await createUser("directdispatch@example.com", "pw-long-enough-21");
|
|
728
|
+
await addMembership(userId, TENANT_A);
|
|
729
|
+
const resolvedLikeUser: SessionUser = {
|
|
730
|
+
id: userId,
|
|
731
|
+
tenantId: TENANT_A,
|
|
732
|
+
roles: ["Admin"],
|
|
733
|
+
origin: "member-resolution",
|
|
734
|
+
};
|
|
735
|
+
|
|
736
|
+
const result = await stack.dispatcher.write(
|
|
737
|
+
"queryasmemberprobe:write:note:create",
|
|
738
|
+
{ ownerId: userId, body: "nope" },
|
|
739
|
+
resolvedLikeUser,
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
expect(result.isSuccess).toBe(false);
|
|
743
|
+
if (!result.isSuccess) {
|
|
744
|
+
expect(errorReason(result.error.details)).toBe("member_resolution_read_only");
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
});
|