@cosmicdrift/kumiko-testing 0.388.0 → 0.390.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.
@@ -1,13 +1,24 @@
1
1
  // @runtime dev
2
2
  import { createHash } from "node:crypto";
3
3
  import { buildServer, createSseBroker } from "@cosmicdrift/kumiko-framework/api";
4
- import type { DbConnection, EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
4
+ import {
5
+ assertRowLevelSecurityBootState,
6
+ type DbConnection,
7
+ type EntityTableMeta,
8
+ knownTablesOf,
9
+ type RowLevelSecurityMode,
10
+ } from "@cosmicdrift/kumiko-framework/db";
5
11
  import { createRegistry, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
6
12
  import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine/types";
7
13
  import {
8
14
  createArchivedStreamsTable,
9
15
  createEventsTable,
10
16
  } from "@cosmicdrift/kumiko-framework/event-store";
17
+ import {
18
+ appHookScopedRunner,
19
+ DEFAULT_ROW_LEVEL_SECURITY_MODE,
20
+ systemScopedRunner,
21
+ } from "@cosmicdrift/kumiko-framework/internal/row-level-security";
11
22
  import {
12
23
  createRateLimitResolver,
13
24
  unsafePushTables,
@@ -31,9 +42,11 @@ import {
31
42
  import type { SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
32
43
  import { createInMemorySearchAdapter } from "@cosmicdrift/kumiko-framework/search";
33
44
  import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
45
+ import type { Clock } from "@cosmicdrift/kumiko-types/tz-context";
34
46
  import {
35
47
  createTestDb,
36
48
  ensureTestDbTemplate,
49
+ TEST_APPLICATION_ROLE,
37
50
  TEST_DB_TEMPLATE_RUN_ENV,
38
51
  testDbTemplatePrefix,
39
52
  } from "./db.ts";
@@ -94,7 +107,7 @@ async function pushStackSchema(
94
107
  // setupTestStack call is ready for writes without needing a manual
95
108
  // createEventsTable().
96
109
  await createEventsTable(db);
97
- // Archive-stream metadata — needed by ctx.appendEvent's archive guard and
110
+ // Archive-stream metadata — needed by ctx.events.append's archive guard and
98
111
  // loadAggregate's default-skip. Idempotent, so production boot running
99
112
  // the same call is fine.
100
113
  await createArchivedStreamsTable(db);
@@ -134,6 +147,7 @@ async function resolveTestDbTemplateName(
134
147
  options: TestStackOptions,
135
148
  metas: readonly EntityTableMeta[],
136
149
  withFileTables: boolean,
150
+ rowLevelSecurity: RowLevelSecurityMode,
137
151
  ): Promise<string | undefined> {
138
152
  const runId = process.env[TEST_DB_TEMPLATE_RUN_ENV];
139
153
  if (runId === undefined || options.persistentDb === true || options.dbName !== undefined) {
@@ -141,7 +155,14 @@ async function resolveTestDbTemplateName(
141
155
  }
142
156
  const { renderTablesDdl } = await import("@cosmicdrift/kumiko-framework/db");
143
157
  const fingerprint = createHash("sha256")
144
- .update(JSON.stringify({ withFileTables, ddl: renderTablesDdl(metas) }))
158
+ .update(
159
+ JSON.stringify({
160
+ withFileTables,
161
+ rowLevelSecurity,
162
+ role: rowLevelSecurity === "on" ? TEST_APPLICATION_ROLE : "superuser",
163
+ ddl: renderTablesDdl(metas, { rowLevelSecurity }),
164
+ }),
165
+ )
145
166
  .digest("hex")
146
167
  .slice(0, 16);
147
168
  return `${testDbTemplatePrefix(runId)}${fingerprint}`;
@@ -192,8 +213,17 @@ export async function provisionStackStorage(options: TestStackOptions): Promise<
192
213
  const withFileTables = options.files !== undefined;
193
214
  const buildStackSchema = (db: DbConnection): Promise<void> =>
194
215
  pushStackSchema(db, stackTableMetas, withFileTables);
195
- const templateDbName = await resolveTestDbTemplateName(options, stackTableMetas, withFileTables);
196
- if (templateDbName !== undefined) await ensureTestDbTemplate(templateDbName, buildStackSchema);
216
+ const rowLevelSecurity = options.rowLevelSecurity ?? "on";
217
+ const applicationRole = rowLevelSecurity === "on";
218
+ const templateDbName = await resolveTestDbTemplateName(
219
+ options,
220
+ stackTableMetas,
221
+ withFileTables,
222
+ rowLevelSecurity,
223
+ );
224
+ if (templateDbName !== undefined) {
225
+ await ensureTestDbTemplate(templateDbName, buildStackSchema, { applicationRole });
226
+ }
197
227
 
198
228
  // Forward db-name/persistent-flag through to createTestDb. The
199
229
  // defaults (undefined dbName, persistent:false) keep the legacy
@@ -208,6 +238,7 @@ export async function provisionStackStorage(options: TestStackOptions): Promise<
208
238
  outsideTransactionPoolMax: options.outsideTransactionPoolMax,
209
239
  }),
210
240
  ...(templateDbName !== undefined && { templateDbName }),
241
+ applicationRole,
211
242
  }),
212
243
  createTestRedis(
213
244
  options.sharedRedisWith && { borrowKeyPrefix: options.sharedRedisWith.redis.keyPrefix },
@@ -218,13 +249,16 @@ export async function provisionStackStorage(options: TestStackOptions): Promise<
218
249
  // DB/Redis — the caller never gets the stack object back to call
219
250
  // cleanup() itself (same "leaked ephemeral DB" failure class as the later
220
251
  // try/catch in setupTestStack, but for setup that runs before that try starts).
221
- if (templateDbName === undefined) {
222
- try {
223
- await buildStackSchema(testDb.db);
224
- } catch (error) {
225
- await runCleanupsIgnoringErrors([() => testDb.cleanup(), () => testRedis.cleanup()]);
226
- throw error;
227
- }
252
+ try {
253
+ if (templateDbName === undefined) await buildStackSchema(testDb.db);
254
+ await assertRowLevelSecurityBootState(
255
+ testDb.db,
256
+ rowLevelSecurity,
257
+ knownTablesOf(options.features),
258
+ );
259
+ } catch (error) {
260
+ await runCleanupsIgnoringErrors([() => testDb.cleanup(), () => testRedis.cleanup()]);
261
+ throw error;
228
262
  }
229
263
  return { testDb, testRedis };
230
264
  }
@@ -248,14 +282,26 @@ export function createStackCacheSync(
248
282
  // A static `files.storageProvider` is wired as the per-tenant resolver — the
249
283
  // framework test seam that doesn't require mounting config + file-foundation.
250
284
  // (Bundled GDPR tests mount the real provider features instead.)
285
+ function isClock(value: unknown): value is Clock {
286
+ return (
287
+ typeof value === "object" && value !== null && "now" in value && typeof value.now === "function"
288
+ );
289
+ }
290
+
251
291
  async function resolveStackFileProvider(
252
292
  options: TestStackOptions,
253
293
  testDb: StackStorage["testDb"],
294
+ readClock: () => unknown,
254
295
  ): Promise<import("@cosmicdrift/kumiko-framework/files").FileProviderResolver | undefined> {
255
296
  if (!options.files) return undefined;
256
297
  const provider = options.files.storageProvider;
257
298
  const { withUploadLedger } = await import("@cosmicdrift/kumiko-framework/files");
258
- return withUploadLedger(() => Promise.resolve(provider), testDb.db);
299
+ // Read per call: the clock arrives via extraContext, which is merged after this resolver is built.
300
+ const now = (): Temporal.Instant => {
301
+ const clock = readClock();
302
+ return isClock(clock) ? clock.now() : Temporal.Now.instant();
303
+ };
304
+ return withUploadLedger(() => Promise.resolve(provider), testDb.db, now);
259
305
  }
260
306
 
261
307
  export async function buildStackServices(
@@ -277,7 +323,12 @@ export async function buildStackServices(
277
323
  );
278
324
 
279
325
  const entityCache = createEntityCache(testRedis.redis, { ttlSeconds: 60 });
280
- const fileProviderResolver = await resolveStackFileProvider(options, testDb);
326
+ const extraContextClock: { current: unknown } = { current: undefined };
327
+ const fileProviderResolver = await resolveStackFileProvider(
328
+ options,
329
+ testDb,
330
+ () => extraContextClock.current,
331
+ );
281
332
  const observability = options.observability ?? createNoopProvider();
282
333
 
283
334
  // Same AppContext buildServer() gets below (db/redis/searchAdapter/
@@ -290,8 +341,18 @@ export async function buildStackServices(
290
341
  // it there either (only dispatcherOptions.effectiveFeatures, consumed by
291
342
  // the command-dispatcher — see buildJobRunnerWithHook in entrypoint/
292
343
  // index.ts). Putting it here would reintroduce the exact test-vs-prod
293
- // drift fixed: a job/handler reading `ctx.effectiveFeatures` would pass in
344
+ // drift fixed: a job/handler reading `ctx.services.effectiveFeatures` would pass in
294
345
  // tests and break in prod.
346
+ const extraContext =
347
+ typeof options.extraContext === "function"
348
+ ? options.extraContext({
349
+ registry,
350
+ // The raw pool, as in prod: request-time services keep the ambient request scope, so a tenant leak shows in tests.
351
+ db: testDb.db,
352
+ sseBroker,
353
+ redis: testRedis.redis,
354
+ })
355
+ : options.extraContext;
295
356
  const appContext = {
296
357
  db: testDb.db,
297
358
  redis: testRedis.redis,
@@ -301,10 +362,9 @@ export async function buildStackServices(
301
362
  ...(options.masterKeyProvider ? { masterKeyProvider: options.masterKeyProvider } : {}),
302
363
  ...(fileProviderResolver ? { _fileProviderResolver: fileProviderResolver } : {}),
303
364
  ...(options.defaultLocale !== undefined && { defaultLocale: options.defaultLocale }),
304
- ...(typeof options.extraContext === "function"
305
- ? options.extraContext({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
306
- : options.extraContext),
365
+ ...extraContext,
307
366
  };
367
+ extraContextClock.current = extraContext?.["clock"];
308
368
  return { searchAdapter, events, registry, sseBroker, entityCache, observability, appContext };
309
369
  }
310
370
 
@@ -313,7 +373,7 @@ export type StackServices = Awaited<ReturnType<typeof buildStackServices>>;
313
373
  type JobFailureTracker = ReturnType<typeof createJobFailureTracker>;
314
374
 
315
375
  // Built before buildServer() so it can be merged into dispatcherOptions
316
- // (write handlers' `ctx.jobRunner.dispatch(...)` and the afterCommit
376
+ // (write handlers' `ctx.services.jobRunner.dispatch(...)` and the afterCommit
317
377
  // event-trigger hook both need it present at dispatcher-construction
318
378
  // time, same as the prod entrypoint's buildJobRunnerWithHook). Uses the
319
379
  // test-stack's own ephemeral redis — no separate `redisUrl` seam needed.
@@ -409,7 +469,7 @@ async function configureStackSearch(
409
469
 
410
470
  // buildServer only auto-wires an L3 RateLimitResolver from
411
471
  // `context.redis` — a degradation test that nulls `redis` via
412
- // extraContext (to exercise a handler's own ctx.redis-down guard) also
472
+ // extraContext (to exercise a handler's own ctx.services.redis-down guard) also
413
473
  // silently strips rate-limiting, even though every handler with a
414
474
  // `rateLimit` declaration still requires a resolver at dispatch time.
415
475
  // Since e7bf543ad, an IP-keyed bucket key is built unconditionally
@@ -436,6 +496,12 @@ function resolveStackRateLimitFallback(
436
496
  });
437
497
  }
438
498
 
499
+ function testCodeDb<T extends object>(options: TestStackOptions, db: T): T {
500
+ return (options.rowLevelSecurity ?? DEFAULT_ROW_LEVEL_SECURITY_MODE) === "on"
501
+ ? systemScopedRunner(db, "test stack seeds and reads rows across tenants")
502
+ : db;
503
+ }
504
+
439
505
  async function resolveStackAnonymousAccess(
440
506
  options: TestStackOptions,
441
507
  testDb: StackStorage["testDb"],
@@ -443,12 +509,16 @@ async function resolveStackAnonymousAccess(
443
509
  services: StackServices,
444
510
  ): Promise<Pick<ServerBuildOptions, "anonymousAccess">> {
445
511
  const { registry, sseBroker } = services;
512
+ const hookDb = appHookScopedRunner(
513
+ testDb.db,
514
+ options.rowLevelSecurity ?? DEFAULT_ROW_LEVEL_SECURITY_MODE,
515
+ );
446
516
  const baseAnon =
447
517
  typeof options.anonymousAccess === "function"
448
- ? options.anonymousAccess({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
518
+ ? options.anonymousAccess({ registry, db: hookDb, sseBroker, redis: testRedis.redis })
449
519
  : options.anonymousAccess;
450
520
  const resolvedAnon = options.enrichAnonymousAccess
451
- ? await options.enrichAnonymousAccess(baseAnon, { registry, db: testDb.db })
521
+ ? await options.enrichAnonymousAccess(baseAnon, { registry, db: hookDb })
452
522
  : baseAnon;
453
523
  return resolvedAnon ? { anonymousAccess: resolvedAnon } : {};
454
524
  }
@@ -557,7 +627,9 @@ function assembleTestStack(
557
627
  app: server.app,
558
628
  jwt: server.jwt,
559
629
  registry: services.registry,
560
- db: testDb.db,
630
+ // Test code sees every tenant's rows; the services keep the enforced pool.
631
+ db: testCodeDb(options, testDb.db),
632
+ unscopedDb: testDb.db,
561
633
  dbPoolMax: testDb.poolMax,
562
634
  redis: testRedis,
563
635
  cacheSync,
@@ -37,7 +37,10 @@ export type TestStack = {
37
37
  app: Hono;
38
38
  jwt: JwtHelper;
39
39
  registry: Registry;
40
+ /** Sees every tenant's rows (system scope) at rowLevelSecurity "on". */
40
41
  db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
42
+ /** The enforced pool the services use: a statement without a scope sees no tenant row. Same as `db` at "off". */
43
+ unscopedDb: import("@cosmicdrift/kumiko-framework/db").DbConnection;
41
44
  /** Resolved connection pool size of `db`. */
42
45
  readonly dbPoolMax: number;
43
46
  redis: TestRedis;
@@ -61,7 +64,7 @@ export type TestStack = {
61
64
  // wires itself (_fileProviderResolver). A dev-server that starts its own
62
65
  // lane job-runners beside this stack must hand them THIS, not a
63
66
  // `{ db, registry }` literal — that's the drift, and it makes an
64
- // event-triggered job reaching for ctx.files die where the request path works.
67
+ // event-triggered job reaching for ctx.services.files die where the request path works.
65
68
  context: AppContext;
66
69
  // Present whenever a system consumer (SSE, Search) or
67
70
  // r.multiStreamProjection is wired. Tests drain it via runOnce() (or the
@@ -73,7 +76,7 @@ export type TestStack = {
73
76
  lifecycle?: Lifecycle;
74
77
  // Only set when `options.jobs` is truthy AND ≥1 `r.job(...)` is
75
78
  // registered in the mounted features. Lets integration tests call
76
- // `stack.jobRunner.dispatch(...)` directly, mirroring `ctx.jobRunner`.
79
+ // `stack.jobRunner.dispatch(...)` directly, mirroring `ctx.services.jobRunner`.
77
80
  jobRunner?: JobRunner;
78
81
  // Waits for every in-flight job and its follow-up event-consumer /
79
82
  // job-trigger cascade to settle — instead of `waitFor` polling on a
@@ -116,7 +119,7 @@ export type TestStackOptions = {
116
119
  /** Wire up auth routes (login, tenant-switch). Leave undefined to skip. */
117
120
  authConfig?: AuthRoutesConfig;
118
121
  /** Register a file storage provider so uploads via POST /api/files work and
119
- * `ctx.files.ref(key)` is available to hooks/MSPs. Omit to skip — tests
122
+ * `ctx.services.files.ref(key)` is available to hooks/MSPs. Omit to skip — tests
120
123
  * without file handling don't need it. */
121
124
  files?: { storageProvider: import("@cosmicdrift/kumiko-framework/files").FileStorageProvider };
122
125
  /** Observability provider — omit for NoopProvider (no spans/metrics).
@@ -160,7 +163,7 @@ export type TestStackOptions = {
160
163
  persistentDb?: boolean;
161
164
  /** Cap on the main app pool (default: driver default). Lets tests saturate it deliberately. */
162
165
  dbPoolMax?: number;
163
- /** Size of the dedicated pool behind ctx.dbOutsideTransaction / ctx.outsideTransaction. */
166
+ /** Size of the dedicated pool behind ctx.db.outsideTransaction / ctx.outsideTransaction. */
164
167
  outsideTransactionPoolMax?: number;
165
168
  /** Forwarded to buildServer — when set, requests without a JWT pass
166
169
  * through as anonymous instead of 401. See AnonymousAccessConfig.
@@ -187,7 +190,7 @@ export type TestStackOptions = {
187
190
  ) => Promise<
188
191
  import("@cosmicdrift/kumiko-framework/api").ServerOptions["anonymousAccess"] | undefined
189
192
  >;
190
- /** Opt-in JobRunner wired into ctx.jobRunner and merged into
193
+ /** Opt-in JobRunner wired into ctx.services.jobRunner and merged into
191
194
  * dispatcherOptions so event-triggered jobs enqueue on commit — mirrors
192
195
  * the prod entrypoint's `buildJobRunnerWithHook`. Unlike prod (which
193
196
  * always builds one), this only builds a runner when `registry.getAllJobs()`
@@ -254,6 +257,8 @@ export type TestStackOptions = {
254
257
  * entities across features included). Use it with the app's complete feature
255
258
  * composition; the default only checks what is safe on a feature subset. */
256
259
  validateBoot?: "full";
260
+ /** Postgres tenant isolation, as in prod. "on" (default) runs the stack as the non-bypass role kumiko_app against policies; "off" uses the superuser without policies. */
261
+ rowLevelSecurity?: import("@cosmicdrift/kumiko-framework/db").RowLevelSecurityMode;
257
262
  };
258
263
 
259
264
  const DEFAULT_JWT_SECRET = "test-stack-secret-minimum-32-characters!!";