@cosmicdrift/kumiko-bundled-features 0.225.0 → 0.227.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__/seed-admin-guarded.integration.test.ts +183 -0
- package/src/auth-email-password/seeding.ts +112 -2
- package/src/cap-overview/__tests__/cap-overview-security.integration.test.ts +25 -15
- package/src/cap-overview/handlers/caps-usage.query.ts +13 -0
- package/src/cap-overview/handlers/tenant-caps-list.query.ts +10 -4
- package/src/cap-overview/i18n.ts +1 -0
- package/src/cap-overview/types.ts +12 -5
- package/src/cap-overview/web/__tests__/cap-cards-panel.test.tsx +46 -0
- package/src/cap-overview/web/__tests__/cap-usage-bar.test.tsx +14 -0
- package/src/cap-overview/web/cap-cards-panel.tsx +14 -4
- package/src/cap-overview/web/cap-usage-bar.tsx +9 -0
- package/src/cap-overview/web/cap-usage-cell.tsx +3 -3
- package/src/form-draft/__tests__/cleanup.integration.test.ts +51 -20
- package/src/notes-history/__tests__/notes-section.test.tsx +22 -11
- package/src/notes-history/web/notes-section.tsx +2 -2
- package/src/sessions/handlers/detail.query.ts +11 -0
- package/src/sessions/handlers/list.query.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.227.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -130,12 +130,12 @@
|
|
|
130
130
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
131
131
|
},
|
|
132
132
|
"dependencies": {
|
|
133
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
133
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.227.0",
|
|
134
|
+
"@cosmicdrift/kumiko-framework": "0.227.0",
|
|
135
|
+
"@cosmicdrift/kumiko-headless": "0.227.0",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer": "0.227.0",
|
|
137
|
+
"@cosmicdrift/kumiko-renderer-web": "0.227.0",
|
|
138
|
+
"@cosmicdrift/kumiko-types": "0.227.0",
|
|
139
139
|
"@mollie/api-client": "^4.5.0",
|
|
140
140
|
"@node-rs/argon2": "^2.0.2",
|
|
141
141
|
"@types/mailparser": "^3.4.6",
|
|
@@ -164,7 +164,7 @@
|
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@testing-library/user-event": "^14.6.1",
|
|
166
166
|
"@types/qrcode": "^1.5.5",
|
|
167
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
168
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
167
|
+
"@cosmicdrift/kumiko-locale-de": "0.227.0",
|
|
168
|
+
"@cosmicdrift/kumiko-locale-es": "0.227.0"
|
|
169
169
|
}
|
|
170
170
|
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// offlot#114 — DB integration for seedAdminGuarded, the boot-seed variant
|
|
2
|
+
// of seedAdmin that survives a blind-index miss on an otherwise-existing
|
|
3
|
+
// account. Runs against a KMS + blind-index setup, which is the only
|
|
4
|
+
// configuration where the bug reproduces: `email` holds per-row ciphertext,
|
|
5
|
+
// so seedAdmin's idempotency check can only match through the deterministic
|
|
6
|
+
// `email_bidx` companion column.
|
|
7
|
+
|
|
8
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
+
import {
|
|
11
|
+
configureBlindIndexKey,
|
|
12
|
+
configurePiiSubjectKms,
|
|
13
|
+
InMemoryKmsAdapter,
|
|
14
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
15
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
17
|
+
import {
|
|
18
|
+
setupTestStack,
|
|
19
|
+
type TestStack,
|
|
20
|
+
unsafeCreateEntityTable,
|
|
21
|
+
unsafePushTables,
|
|
22
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
23
|
+
import {
|
|
24
|
+
resetBlindIndexKeyForTests,
|
|
25
|
+
resetPiiSubjectKmsForTests,
|
|
26
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
27
|
+
import { createConfigFeature } from "../../config/feature";
|
|
28
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
29
|
+
import { configValuesTable } from "../../config/table";
|
|
30
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
31
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
32
|
+
import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
|
|
33
|
+
import { createUserFeature } from "../../user/feature";
|
|
34
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
35
|
+
import { seedUser } from "../../user/seeding";
|
|
36
|
+
import { seedAdmin, seedAdminGuarded } from "../seeding";
|
|
37
|
+
|
|
38
|
+
const BLIND_INDEX_KEY_B64 = Buffer.alloc(32, 14).toString("base64");
|
|
39
|
+
const TENANT_114: TenantId = "00000000-0000-4000-8000-0000000001d4" as TenantId;
|
|
40
|
+
const SYSADMIN_EMAIL = "sysadmin@offlot.app";
|
|
41
|
+
|
|
42
|
+
const seedOptions = {
|
|
43
|
+
email: SYSADMIN_EMAIL,
|
|
44
|
+
password: "sysadmin-pw-114",
|
|
45
|
+
displayName: "Sysadmin",
|
|
46
|
+
globalRoles: ["SystemAdmin"],
|
|
47
|
+
memberships: [
|
|
48
|
+
{ tenantId: TENANT_114, tenantKey: "demo-114", tenantName: "Demo 114", roles: ["TenantAdmin"] },
|
|
49
|
+
],
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
let stack: TestStack;
|
|
53
|
+
|
|
54
|
+
beforeAll(async () => {
|
|
55
|
+
const resolver = createConfigResolver();
|
|
56
|
+
stack = await setupTestStack({
|
|
57
|
+
features: [createConfigFeature(), createUserFeature(), createTenantFeature()],
|
|
58
|
+
extraContext: { configResolver: resolver },
|
|
59
|
+
});
|
|
60
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
61
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
62
|
+
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
63
|
+
await createEventsTable(stack.db);
|
|
64
|
+
}, 60_000);
|
|
65
|
+
|
|
66
|
+
afterAll(async () => {
|
|
67
|
+
await stack.cleanup();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
beforeEach(async () => {
|
|
71
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
72
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantTable.tableName}"`);
|
|
73
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
|
|
74
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${eventsTable.tableName}"`);
|
|
75
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
76
|
+
configureBlindIndexKey(BLIND_INDEX_KEY_B64);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
resetPiiSubjectKmsForTests();
|
|
81
|
+
resetBlindIndexKeyForTests();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
async function userIds(): Promise<readonly string[]> {
|
|
85
|
+
const rows = await selectMany<{ id: string }>(stack.db, userTable);
|
|
86
|
+
return rows.map((r) => r.id);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function membershipUserIds(): Promise<readonly string[]> {
|
|
90
|
+
const rows = await selectMany<{ userId: string }>(stack.db, tenantMembershipsTable);
|
|
91
|
+
return rows.map((r) => r.userId);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Reproduces the offlot#112 row shape in prod: the email column keeps its
|
|
95
|
+
* ciphertext, but the blind index is blank — as it is for rows written
|
|
96
|
+
* before KUMIKO_BLIND_INDEX_KEY existed, or after a subject-key erase. */
|
|
97
|
+
async function blankBlindIndex(): Promise<void> {
|
|
98
|
+
await asRawClient(stack.db).unsafe(`UPDATE "${userTable.tableName}" SET email_bidx = NULL`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe("seedAdminGuarded (offlot#114)", () => {
|
|
102
|
+
test("re-seeding twice on an empty DB stays idempotent", async () => {
|
|
103
|
+
const first = await seedAdminGuarded(stack.db, seedOptions);
|
|
104
|
+
const second = await seedAdminGuarded(stack.db, seedOptions);
|
|
105
|
+
|
|
106
|
+
expect(second.id).toBe(first.id);
|
|
107
|
+
expect(await userIds()).toEqual([first.id]);
|
|
108
|
+
expect(await membershipUserIds()).toEqual([first.id]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("a blank blind index no longer produces a second account", async () => {
|
|
112
|
+
const first = await seedAdminGuarded(stack.db, seedOptions);
|
|
113
|
+
await blankBlindIndex();
|
|
114
|
+
|
|
115
|
+
const second = await seedAdminGuarded(stack.db, seedOptions);
|
|
116
|
+
|
|
117
|
+
expect(second.id).toBe(first.id);
|
|
118
|
+
expect(await userIds()).toEqual([first.id]);
|
|
119
|
+
// Membership is reconciled onto the existing account, not a new identity.
|
|
120
|
+
expect(await membershipUserIds()).toEqual([first.id]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("unguarded seedAdmin still duplicates on a blank blind index — the gap seedAdminGuarded covers", async () => {
|
|
124
|
+
// Pins the framework behaviour the guard compensates for: seedUser's
|
|
125
|
+
// existence check is bidx-only, and neither unique index on the user
|
|
126
|
+
// table can catch this (the plaintext unique index sits on randomized
|
|
127
|
+
// ciphertext, the bidx unique index is partial WHERE email_bidx IS NOT
|
|
128
|
+
// NULL). If this ever fails, seedAdmin's idempotency check changed and
|
|
129
|
+
// this guard can be revisited.
|
|
130
|
+
const first = await seedAdmin(stack.db, seedOptions);
|
|
131
|
+
await blankBlindIndex();
|
|
132
|
+
|
|
133
|
+
const second = await seedAdmin(stack.db, seedOptions);
|
|
134
|
+
|
|
135
|
+
expect(second.id).not.toBe(first.id);
|
|
136
|
+
expect(await userIds()).toHaveLength(2);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("pre-existing duplicates are reused, never extended", async () => {
|
|
140
|
+
// Build the exact prod state: two ciphertext rows for one email, the
|
|
141
|
+
// older one with a blank blind index.
|
|
142
|
+
const canonical = await seedAdmin(stack.db, seedOptions);
|
|
143
|
+
await blankBlindIndex();
|
|
144
|
+
const duplicate = await seedAdmin(stack.db, seedOptions);
|
|
145
|
+
expect(await userIds()).toHaveLength(2);
|
|
146
|
+
|
|
147
|
+
// Guards against a false pass: the canonical pick must be driven by
|
|
148
|
+
// insertedAt ordering, not a coincidental id sort — assert the rows
|
|
149
|
+
// actually carry distinct, comparable Temporal.Instant timestamps.
|
|
150
|
+
const rows = await selectMany<{ id: string; insertedAt: { epochNanoseconds: bigint } }>(
|
|
151
|
+
stack.db,
|
|
152
|
+
userTable,
|
|
153
|
+
);
|
|
154
|
+
const canonicalRow = rows.find((r) => r.id === canonical.id);
|
|
155
|
+
const duplicateRow = rows.find((r) => r.id === duplicate.id);
|
|
156
|
+
expect(typeof canonicalRow?.insertedAt.epochNanoseconds).toBe("bigint");
|
|
157
|
+
expect(typeof duplicateRow?.insertedAt.epochNanoseconds).toBe("bigint");
|
|
158
|
+
expect(
|
|
159
|
+
canonicalRow!.insertedAt.epochNanoseconds < duplicateRow!.insertedAt.epochNanoseconds,
|
|
160
|
+
).toBe(true);
|
|
161
|
+
|
|
162
|
+
const seeded = await seedAdminGuarded(stack.db, seedOptions);
|
|
163
|
+
|
|
164
|
+
// Oldest row wins.
|
|
165
|
+
expect(seeded.id).toBe(canonical.id);
|
|
166
|
+
expect(seeded.id).not.toBe(duplicate.id);
|
|
167
|
+
// No third row: the guard refuses to add to a broken state.
|
|
168
|
+
expect(await userIds()).toHaveLength(2);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("unrelated single-row accounts are untouched", async () => {
|
|
172
|
+
const unrelated = await seedUser(stack.db, {
|
|
173
|
+
email: "dealer@offlot.app",
|
|
174
|
+
displayName: "Dealer",
|
|
175
|
+
});
|
|
176
|
+
const admin = await seedAdminGuarded(stack.db, seedOptions);
|
|
177
|
+
await blankBlindIndex();
|
|
178
|
+
await seedAdminGuarded(stack.db, seedOptions);
|
|
179
|
+
|
|
180
|
+
expect(await userIds()).toHaveLength(2);
|
|
181
|
+
expect((await userIds()).slice().sort()).toEqual([unrelated.id, admin.id].sort());
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
// Damit Sample-Server und Tests keine drei sub-paths zusammensammeln
|
|
13
13
|
// müssen.
|
|
14
14
|
|
|
15
|
-
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
|
+
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
|
+
import {
|
|
17
|
+
configuredPiiSubjectKms,
|
|
18
|
+
decryptPiiFieldValues,
|
|
19
|
+
type LocalKeyKmsAdapter,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
16
21
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
17
22
|
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
23
|
import { ConflictError } from "@cosmicdrift/kumiko-framework/errors";
|
|
@@ -21,7 +26,7 @@ import { hashPassword } from "../shared";
|
|
|
21
26
|
// kumiko-lint-ignore cross-feature-import auth-tests need user+tenant seed-helpers
|
|
22
27
|
import { type SeedTenantHooks, seedTenant, seedTenantMembership } from "../tenant/seeding";
|
|
23
28
|
// kumiko-lint-ignore cross-feature-import signup create-only guard reads the user projection by email
|
|
24
|
-
import { userTable } from "../user/schema/user";
|
|
29
|
+
import { USER_STATUS, userTable } from "../user/schema/user";
|
|
25
30
|
// kumiko-lint-ignore cross-feature-import auth-tests need user+tenant seed-helpers
|
|
26
31
|
import { seedUser } from "../user/seeding";
|
|
27
32
|
|
|
@@ -235,3 +240,108 @@ export async function seedAdmin(
|
|
|
235
240
|
|
|
236
241
|
return { id: userId };
|
|
237
242
|
}
|
|
243
|
+
|
|
244
|
+
// offlot#114 — seedAdmin's idempotency check is a plain fetchOne(email),
|
|
245
|
+
// which the query layer rewrites into `(email = $plaintext OR
|
|
246
|
+
// email_bidx = $hmac)` once a blind-index key is configured. Under KMS
|
|
247
|
+
// encryption `email` holds per-row ciphertext, so only the bidx arm can
|
|
248
|
+
// ever match — and it misses whenever a row's `email_bidx` is NULL (written
|
|
249
|
+
// before the key existed, or after a subject-key erase) or was computed
|
|
250
|
+
// with a since-rotated key. seedAdmin then inserts a second row for an
|
|
251
|
+
// email that already has an account; neither the plaintext unique index
|
|
252
|
+
// (sits on randomized ciphertext) nor the partial bidx unique index (WHERE
|
|
253
|
+
// email_bidx IS NOT NULL, so a NULL row never collides) catches it.
|
|
254
|
+
//
|
|
255
|
+
// seedAdminGuarded is a separate, additive boot-seed guard — NOT a change
|
|
256
|
+
// to seedUser/seedAdmin/provisionSignupAccount, which also run on every
|
|
257
|
+
// real self-signup request and can't afford a decrypt-scan fallback there.
|
|
258
|
+
// Use this only where seedAdmin already runs (dev-boot / sample-server /
|
|
259
|
+
// bootstrap scripts), never as a login or signup hot path.
|
|
260
|
+
|
|
261
|
+
type ActiveUserRow = {
|
|
262
|
+
readonly id: string;
|
|
263
|
+
readonly email: string;
|
|
264
|
+
readonly insertedAt: { readonly epochNanoseconds: bigint };
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
function compareInsertedAt(a: ActiveUserRow["insertedAt"], b: ActiveUserRow["insertedAt"]): number {
|
|
268
|
+
if (a.epochNanoseconds < b.epochNanoseconds) return -1;
|
|
269
|
+
if (a.epochNanoseconds > b.epochNanoseconds) return 1;
|
|
270
|
+
return 0;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Oldest row wins, tie-broken by id — the same rule a manual duplicate
|
|
274
|
+
* cleanup would apply, so this guard and any later cleanup never disagree
|
|
275
|
+
* on which row is the survivor. */
|
|
276
|
+
function pickCanonicalUserId(rows: readonly ActiveUserRow[]): string {
|
|
277
|
+
const sorted = [...rows].sort((a, b) => {
|
|
278
|
+
const byTime = compareInsertedAt(a.insertedAt, b.insertedAt);
|
|
279
|
+
return byTime !== 0 ? byTime : a.id.localeCompare(b.id);
|
|
280
|
+
});
|
|
281
|
+
const first = sorted[0];
|
|
282
|
+
if (first === undefined) throw new Error("pickCanonicalUserId: empty group");
|
|
283
|
+
return first.id;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function findExistingUserIdForEmail(
|
|
287
|
+
db: DbConnection,
|
|
288
|
+
kms: LocalKeyKmsAdapter,
|
|
289
|
+
email: string,
|
|
290
|
+
): Promise<string | undefined> {
|
|
291
|
+
// ponytail: decrypts every active user row to compare emails. Fine at
|
|
292
|
+
// boot-seed scale (tens of accounts, once per boot); if the user table
|
|
293
|
+
// grows past a few thousand rows, narrow this to rows whose email_bidx is
|
|
294
|
+
// NULL or doesn't match the expected HMAC — only those need decrypting.
|
|
295
|
+
const rows = await selectMany<ActiveUserRow>(db, userTable, {
|
|
296
|
+
isDeleted: false,
|
|
297
|
+
status: { ne: USER_STATUS.Deleted },
|
|
298
|
+
});
|
|
299
|
+
const matches: ActiveUserRow[] = [];
|
|
300
|
+
for (const row of rows) {
|
|
301
|
+
// Compared byte-exact, deliberately not case-folded: email_bidx is an
|
|
302
|
+
// HMAC over the raw value and login matches case-sensitively, so a case
|
|
303
|
+
// variant is a different account to every other code path.
|
|
304
|
+
const decrypted = await decryptPiiFieldValues({ email: row.email }, ["email"], kms, {
|
|
305
|
+
requestId: "seed-admin-guarded",
|
|
306
|
+
});
|
|
307
|
+
if (decrypted["email"] === email) matches.push(row);
|
|
308
|
+
}
|
|
309
|
+
return matches.length === 0 ? undefined : pickCanonicalUserId(matches);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Boot-seed variant of seedAdmin that survives a blind-index miss on an
|
|
314
|
+
* otherwise-existing account (see the module comment above). Not for the
|
|
315
|
+
* self-signup hot path — seedAdmin/seedUser stay the unguarded entry point
|
|
316
|
+
* there.
|
|
317
|
+
*
|
|
318
|
+
* No cheap `fetchOne` fast path here on purpose: a hit there only proves
|
|
319
|
+
* SOME row matches (plaintext or bidx), never that it's the canonical one —
|
|
320
|
+
* a stale row's bidx can go NULL while a later duplicate's stays valid, so
|
|
321
|
+
* fetchOne keeps finding the duplicate forever and the seed never converges
|
|
322
|
+
* on the oldest row. The only cheap short-circuit that stays correct is
|
|
323
|
+
* skipping the scan entirely when no KMS is configured at all (plaintext
|
|
324
|
+
* DBs have no bidx ambiguity to guard against in the first place).
|
|
325
|
+
*/
|
|
326
|
+
export async function seedAdminGuarded(
|
|
327
|
+
db: DbConnection,
|
|
328
|
+
options: SeedAdminOptions,
|
|
329
|
+
): Promise<{ id: string }> {
|
|
330
|
+
const kms = configuredPiiSubjectKms();
|
|
331
|
+
if (kms === undefined) return seedAdmin(db, options);
|
|
332
|
+
|
|
333
|
+
const existingId = await findExistingUserIdForEmail(db, kms, options.email);
|
|
334
|
+
if (existingId === undefined) return seedAdmin(db, options);
|
|
335
|
+
|
|
336
|
+
const by = options.by ?? TestUsers.systemAdmin;
|
|
337
|
+
for (const m of options.memberships) {
|
|
338
|
+
await seedTenant(db, { id: m.tenantId, key: m.tenantKey, name: m.tenantName, by });
|
|
339
|
+
await seedTenantMembership(db, {
|
|
340
|
+
userId: existingId,
|
|
341
|
+
tenantId: m.tenantId,
|
|
342
|
+
roles: m.roles,
|
|
343
|
+
by,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return { id: existingId };
|
|
347
|
+
}
|
|
@@ -42,6 +42,15 @@ const testCap: CapSpec = {
|
|
|
42
42
|
usage: async (_db, tenantId) => USAGE_BY_TENANT[tenantId] ?? 0,
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
+
// Never wired up for any tenant — proves the "not measured" state renders
|
|
46
|
+
// as null, not 0, all the way through the real query handler.
|
|
47
|
+
const unmeasuredCap: CapSpec = {
|
|
48
|
+
id: "unmeasured",
|
|
49
|
+
label: "test.cap.unmeasured",
|
|
50
|
+
limit: () => 10,
|
|
51
|
+
usage: async () => null,
|
|
52
|
+
};
|
|
53
|
+
|
|
45
54
|
let stack: TestStack;
|
|
46
55
|
let db: DbConnection;
|
|
47
56
|
|
|
@@ -54,7 +63,7 @@ beforeAll(async () => {
|
|
|
54
63
|
createTenantLifecycleFeature(),
|
|
55
64
|
billingFoundationFeature,
|
|
56
65
|
tierEngineFeature,
|
|
57
|
-
createCapOverviewFeature({ caps: [testCap] }),
|
|
66
|
+
createCapOverviewFeature({ caps: [testCap, unmeasuredCap] }),
|
|
58
67
|
],
|
|
59
68
|
});
|
|
60
69
|
db = stack.db;
|
|
@@ -111,24 +120,25 @@ describe("cap-overview tenant isolation", () => {
|
|
|
111
120
|
|
|
112
121
|
test("TenantAdmin(A) without override sees exactly A's own numbers, not a sum", async () => {
|
|
113
122
|
const tenantAdminA = createTestUser({ id: 90014, tenantId: TENANT_A, roles: ["TenantAdmin"] });
|
|
114
|
-
const result = await stack.http.queryOk<{
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
);
|
|
119
|
-
expect(
|
|
120
|
-
|
|
123
|
+
const result = await stack.http.queryOk<{
|
|
124
|
+
rows: readonly { id: string; used: number | null; percent: number | null }[];
|
|
125
|
+
}>(CapOverviewQueries.capsUsage, {}, tenantAdminA);
|
|
126
|
+
expect(result.rows).toHaveLength(2);
|
|
127
|
+
const widgetsRow = result.rows.find((row) => row.id === testCap.id);
|
|
128
|
+
expect(widgetsRow?.used).toBe(USAGE_BY_TENANT[TENANT_A]);
|
|
129
|
+
|
|
130
|
+
const unmeasuredRow = result.rows.find((row) => row.id === unmeasuredCap.id);
|
|
131
|
+
expect(unmeasuredRow?.used).toBeNull();
|
|
132
|
+
expect(unmeasuredRow?.percent).toBeNull();
|
|
121
133
|
});
|
|
122
134
|
|
|
123
135
|
test("SystemAdmin can read tenant B's usage via override", async () => {
|
|
124
136
|
const sysAdmin = createTestUser({ id: 90015, tenantId: TENANT_A, roles: ["SystemAdmin"] });
|
|
125
|
-
const result = await stack.http.queryOk<{
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
);
|
|
130
|
-
expect(result.rows).toHaveLength(1);
|
|
131
|
-
expect(result.rows[0]?.used).toBe(USAGE_BY_TENANT[TENANT_B]);
|
|
137
|
+
const result = await stack.http.queryOk<{
|
|
138
|
+
rows: readonly { id: string; used: number | null }[];
|
|
139
|
+
}>(CapOverviewQueries.capsUsage, { tenantId: TENANT_B }, sysAdmin);
|
|
140
|
+
const widgetsRow = result.rows.find((row) => row.id === testCap.id);
|
|
141
|
+
expect(widgetsRow?.used).toBe(USAGE_BY_TENANT[TENANT_B]);
|
|
132
142
|
});
|
|
133
143
|
|
|
134
144
|
test("SystemAdmin sees both tenants on the list, each with its own numbers", async () => {
|
|
@@ -65,6 +65,19 @@ export function createCapsUsageQuery(caps: readonly CapSpec[]): QueryHandlerDefi
|
|
|
65
65
|
caps.map(async (cap) => {
|
|
66
66
|
const used = await cap.usage(db, targetTenantId);
|
|
67
67
|
const limit = cap.limit(tier);
|
|
68
|
+
if (used === null) {
|
|
69
|
+
return {
|
|
70
|
+
id: cap.id,
|
|
71
|
+
label: cap.label,
|
|
72
|
+
used: null,
|
|
73
|
+
limit,
|
|
74
|
+
fraction: 0,
|
|
75
|
+
tone: "default" as const,
|
|
76
|
+
percent: null,
|
|
77
|
+
...(cap.icon !== undefined && { icon: cap.icon }),
|
|
78
|
+
...(cap.accentColor !== undefined && { accentColor: cap.accentColor }),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
68
81
|
const fraction = computeFraction(used, limit);
|
|
69
82
|
return {
|
|
70
83
|
id: cap.id,
|
|
@@ -182,12 +182,12 @@ export function createTenantCapsListQuery(caps: readonly CapSpec[], listCaps: re
|
|
|
182
182
|
// N+1 avoidance: usage is computed only for this page's tenant ids,
|
|
183
183
|
// never for the full tenant set.
|
|
184
184
|
const pageTenantIds = page.map((row) => row.tenantId);
|
|
185
|
-
const usageByCap = new Map<string, Map<string, number>>();
|
|
185
|
+
const usageByCap = new Map<string, Map<string, number | null>>();
|
|
186
186
|
for (const cap of listedCaps) {
|
|
187
187
|
if (cap.usageBatch) {
|
|
188
188
|
usageByCap.set(cap.id, await cap.usageBatch(db, pageTenantIds));
|
|
189
189
|
} else {
|
|
190
|
-
const perTenant = new Map<string, number>();
|
|
190
|
+
const perTenant = new Map<string, number | null>();
|
|
191
191
|
for (const tenantId of pageTenantIds) {
|
|
192
192
|
perTenant.set(tenantId, await cap.usage(db, tenantId));
|
|
193
193
|
}
|
|
@@ -198,9 +198,15 @@ export function createTenantCapsListQuery(caps: readonly CapSpec[], listCaps: re
|
|
|
198
198
|
const rows: TenantCapsListRow[] = page.map((row) => {
|
|
199
199
|
const capFields: Record<string, CapUsage> = {};
|
|
200
200
|
for (const cap of listedCaps) {
|
|
201
|
-
|
|
201
|
+
// A tenant absent from the map still means 0 (existing behavior);
|
|
202
|
+
// only an explicit `null` value means "not measured".
|
|
203
|
+
const rawUsed = usageByCap.get(cap.id)?.get(row.tenantId);
|
|
204
|
+
const used = rawUsed === undefined ? 0 : rawUsed;
|
|
202
205
|
const limit = cap.limit(row.tier);
|
|
203
|
-
capFields[capFieldName(cap.id)] =
|
|
206
|
+
capFields[capFieldName(cap.id)] =
|
|
207
|
+
used === null
|
|
208
|
+
? { used: null, limit, fraction: 0 }
|
|
209
|
+
: { used, limit, fraction: computeFraction(used, limit) };
|
|
204
210
|
}
|
|
205
211
|
return {
|
|
206
212
|
tenantId: row.tenantId,
|
package/src/cap-overview/i18n.ts
CHANGED
|
@@ -12,6 +12,7 @@ export const CAP_OVERVIEW_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
12
12
|
"cap-overview.platform.filter.tenant": { en: "Tenant" },
|
|
13
13
|
"cap-overview.cards.empty": { en: "No caps configured for this tenant." },
|
|
14
14
|
"cap-overview.cards.loading": { en: "Loading usage…" },
|
|
15
|
+
"cap-overview.notMeasured": { en: "Not measured yet" },
|
|
15
16
|
"cap-overview.errors.progressPrimitiveMissing": {
|
|
16
17
|
en: "Usage bar unavailable — Progress primitive is not registered.",
|
|
17
18
|
},
|
|
@@ -15,14 +15,19 @@ export type CapSpec = {
|
|
|
15
15
|
readonly id: string;
|
|
16
16
|
readonly label: string;
|
|
17
17
|
readonly limit: (tier: string) => number;
|
|
18
|
-
|
|
18
|
+
// `null` means "no measurement exists yet for this cap" (not "0 used") —
|
|
19
|
+
// callers that haven't wired up instrumentation return null instead of a
|
|
20
|
+
// number they don't actually have.
|
|
21
|
+
readonly usage: (db: TenantDb, tenantId: TenantId) => Promise<number | null>;
|
|
19
22
|
// Batched usage lookup for the tenant-caps:list screen — called once per
|
|
20
23
|
// cap with the current page's tenant ids instead of once per (cap, row).
|
|
21
|
-
// Optional: falls back to per-row `usage()` when absent.
|
|
24
|
+
// Optional: falls back to per-row `usage()` when absent. A tenant absent
|
|
25
|
+
// from the returned map still means 0 (unchanged) — only an explicit
|
|
26
|
+
// `null` value means "not measured".
|
|
22
27
|
readonly usageBatch?: (
|
|
23
28
|
db: TenantDb,
|
|
24
29
|
tenantIds: readonly TenantId[],
|
|
25
|
-
) => Promise<Map<TenantId, number>>;
|
|
30
|
+
) => Promise<Map<TenantId, number | null>>;
|
|
26
31
|
readonly unit?: "count" | "mb" | "tokens";
|
|
27
32
|
/** Icon chip on the dashboard cards. Optional — omitted renders the card
|
|
28
33
|
* without an icon, same as before this field existed. */
|
|
@@ -36,7 +41,9 @@ export type CapSpec = {
|
|
|
36
41
|
export type CapUsageTone = "default" | "warn" | "danger";
|
|
37
42
|
|
|
38
43
|
export type CapUsage = {
|
|
39
|
-
|
|
44
|
+
// `null` means not measured yet — `fraction`/`tone` stay non-nullable
|
|
45
|
+
// (0 / "default") so no consumer needs null-arithmetic to render.
|
|
46
|
+
readonly used: number | null;
|
|
40
47
|
readonly limit: number;
|
|
41
48
|
readonly fraction: number;
|
|
42
49
|
};
|
|
@@ -45,7 +52,7 @@ export type CapUsageWithMeta = CapUsage & {
|
|
|
45
52
|
readonly id: string;
|
|
46
53
|
readonly label: string;
|
|
47
54
|
readonly tone: CapUsageTone;
|
|
48
|
-
readonly percent: number;
|
|
55
|
+
readonly percent: number | null;
|
|
49
56
|
readonly icon?: CapIconKey;
|
|
50
57
|
readonly accentColor?: string;
|
|
51
58
|
};
|
|
@@ -83,6 +83,19 @@ function capRow(overrides: Partial<CapUsageWithMeta> = {}): CapUsageWithMeta {
|
|
|
83
83
|
};
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
function nullCapRow(overrides: Partial<CapUsageWithMeta> = {}): CapUsageWithMeta {
|
|
87
|
+
return {
|
|
88
|
+
id: "cap-null",
|
|
89
|
+
label: "cap.label.null",
|
|
90
|
+
tone: "default",
|
|
91
|
+
percent: null,
|
|
92
|
+
used: null,
|
|
93
|
+
limit: 100,
|
|
94
|
+
fraction: 0,
|
|
95
|
+
...overrides,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
86
99
|
beforeEach(() => {
|
|
87
100
|
queryState = { data: { rows: [] }, loading: false, error: null };
|
|
88
101
|
useQuerySpy.mockClear();
|
|
@@ -187,6 +200,39 @@ describe("CapCardsPanel", () => {
|
|
|
187
200
|
expect("tenantId" in numericTenantIdArgs).toBe(false);
|
|
188
201
|
});
|
|
189
202
|
|
|
203
|
+
test("a card with used: null shows the em-dash value and not-measured text, no bar or percent badge", () => {
|
|
204
|
+
queryState = {
|
|
205
|
+
data: { rows: [nullCapRow()] },
|
|
206
|
+
loading: false,
|
|
207
|
+
error: null,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
renderPanel(undefined);
|
|
211
|
+
|
|
212
|
+
expect(screen.getByTestId("cap-card").textContent).toContain("—");
|
|
213
|
+
expect(screen.getByTestId("cap-usage-not-measured")).toBeTruthy();
|
|
214
|
+
expect(screen.queryByTestId("cap-usage-bar")).toBeNull();
|
|
215
|
+
expect(screen.queryByText("null%")).toBeNull();
|
|
216
|
+
expect(screen.queryByText("0%")).toBeNull();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("a null-usage card and a measured card in the same panel each render their own state", () => {
|
|
220
|
+
queryState = {
|
|
221
|
+
data: {
|
|
222
|
+
rows: [capRow({ id: "c1", label: "Storage", used: 3, limit: 10 }), nullCapRow()],
|
|
223
|
+
},
|
|
224
|
+
loading: false,
|
|
225
|
+
error: null,
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
renderPanel(undefined);
|
|
229
|
+
|
|
230
|
+
expect(screen.getAllByTestId("cap-card")).toHaveLength(2);
|
|
231
|
+
expect(screen.getByText("3 / 10")).toBeTruthy();
|
|
232
|
+
expect(screen.getByTestId("cap-usage-not-measured")).toBeTruthy();
|
|
233
|
+
expect(screen.getAllByTestId("cap-usage-bar")).toHaveLength(1);
|
|
234
|
+
});
|
|
235
|
+
|
|
190
236
|
test("loading with empty rows shows the loading text instead of the empty state", () => {
|
|
191
237
|
queryState = { data: null, loading: true, error: null };
|
|
192
238
|
|
|
@@ -23,6 +23,20 @@ function fillClass(): string | undefined {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
describe("CapUsageBar", () => {
|
|
26
|
+
test("renders the not-measured text instead of a bar when used is null", () => {
|
|
27
|
+
const usage: CapUsage = { used: null, limit: 100, fraction: 0 };
|
|
28
|
+
render(
|
|
29
|
+
<Wrapper>
|
|
30
|
+
<CapUsageBar usage={usage} showLabel={false} />
|
|
31
|
+
</Wrapper>,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
expect(screen.queryByTestId("cap-usage-bar")).toBeNull();
|
|
35
|
+
expect(screen.getByTestId("cap-usage-not-measured").textContent).toBe(
|
|
36
|
+
"cap-overview.notMeasured",
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
26
40
|
test("renders the danger fill class when usage is at or over the limit", () => {
|
|
27
41
|
const usage: CapUsage = { used: 120, limit: 100, fraction: 1.2 };
|
|
28
42
|
render(
|
|
@@ -35,6 +35,14 @@ const CAP_ICONS: Record<CapIconKey, ComponentType<{ className?: string }>> = {
|
|
|
35
35
|
gauge: Gauge,
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
// Narrows `percent` to non-null alongside `used` — the query handler only
|
|
39
|
+
// ever sets both or neither, but the type doesn't encode that link.
|
|
40
|
+
function isMeasured(
|
|
41
|
+
cap: CapUsageWithMeta,
|
|
42
|
+
): cap is CapUsageWithMeta & { used: number; percent: number } {
|
|
43
|
+
return cap.used !== null;
|
|
44
|
+
}
|
|
45
|
+
|
|
38
46
|
export function CapCardsPanel({ filterParams }: ExtensionSectionProps): ReactNode {
|
|
39
47
|
const t = useTranslation();
|
|
40
48
|
const { Banner } = usePrimitives();
|
|
@@ -81,14 +89,16 @@ export function CapCardsPanel({ filterParams }: ExtensionSectionProps): ReactNod
|
|
|
81
89
|
key={cap.id}
|
|
82
90
|
icon={Icon !== undefined ? <Icon className="size-4" /> : undefined}
|
|
83
91
|
label={t(cap.label)}
|
|
84
|
-
value={`${cap.used} / ${cap.limit}`}
|
|
92
|
+
value={isMeasured(cap) ? `${cap.used} / ${cap.limit}` : "—"}
|
|
85
93
|
{...(cap.accentColor !== undefined && { accentColor: cap.accentColor })}
|
|
86
94
|
testId="cap-card"
|
|
87
95
|
>
|
|
88
96
|
<div className="flex flex-col gap-2">
|
|
89
|
-
|
|
90
|
-
<
|
|
91
|
-
|
|
97
|
+
{isMeasured(cap) && (
|
|
98
|
+
<div className="flex items-center justify-between gap-2">
|
|
99
|
+
<StatusBadge tone={TONE_TO_STATUS[cap.tone]}>{`${cap.percent}%`}</StatusBadge>
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
92
102
|
<CapUsageBar usage={cap} showLabel={false} />
|
|
93
103
|
</div>
|
|
94
104
|
</StatCard>
|
|
@@ -15,6 +15,15 @@ export function CapUsageBar({
|
|
|
15
15
|
}): ReactNode {
|
|
16
16
|
const { Progress, Text, Banner } = usePrimitives();
|
|
17
17
|
const t = useTranslation();
|
|
18
|
+
|
|
19
|
+
if (usage.used === null) {
|
|
20
|
+
return (
|
|
21
|
+
<Text variant="small" testId="cap-usage-not-measured">
|
|
22
|
+
{t("cap-overview.notMeasured")}
|
|
23
|
+
</Text>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
return (
|
|
19
28
|
<div className="flex flex-col gap-1">
|
|
20
29
|
{Progress ? (
|
|
@@ -5,10 +5,10 @@ import type { CapUsage } from "../types";
|
|
|
5
5
|
import { CapUsageBar } from "./cap-usage-bar";
|
|
6
6
|
|
|
7
7
|
function isCapUsage(value: unknown): value is CapUsage {
|
|
8
|
+
if (typeof value !== "object" || value === null) return false;
|
|
9
|
+
const used = (value as { used?: unknown }).used;
|
|
8
10
|
return (
|
|
9
|
-
typeof
|
|
10
|
-
value !== null &&
|
|
11
|
-
typeof (value as { used?: unknown }).used === "number" &&
|
|
11
|
+
(typeof used === "number" || used === null) &&
|
|
12
12
|
typeof (value as { limit?: unknown }).limit === "number" &&
|
|
13
13
|
typeof (value as { fraction?: unknown }).fraction === "number"
|
|
14
14
|
);
|
|
@@ -244,12 +244,17 @@ describe("form-draft cleanup job — FileRef release (#1915)", () => {
|
|
|
244
244
|
|
|
245
245
|
await dispatchCleanup();
|
|
246
246
|
|
|
247
|
-
|
|
248
|
-
|
|
247
|
+
// Draft deletion and FileRef release are two separate awaited phases in
|
|
248
|
+
// cleanup.job.ts — the whole batch is deleted first, then released row
|
|
249
|
+
// by row. Waiting on draftExists only proves the delete phase finished,
|
|
250
|
+
// not the (real DB + provider) release; wait on the actual release
|
|
251
|
+
// effect instead, or this assertion races the still-in-flight release.
|
|
252
|
+
await waitFor(() => {
|
|
253
|
+
expect(providerA.keys()).not.toContain(key);
|
|
249
254
|
});
|
|
255
|
+
expect(await draftExists("wizard:stale-with-photo")).toBe(false);
|
|
250
256
|
expect(await draftExists("wizard:stale-no-photo")).toBe(false);
|
|
251
257
|
expect(await draftExists("wizard:fresh")).toBe(true);
|
|
252
|
-
expect(providerA.keys()).not.toContain(key);
|
|
253
258
|
});
|
|
254
259
|
|
|
255
260
|
test("resolves each stale row's own tenant provider — never the job's SYSTEM_TENANT_ID run-tenant, never another row's tenant", async () => {
|
|
@@ -267,15 +272,18 @@ describe("form-draft cleanup job — FileRef release (#1915)", () => {
|
|
|
267
272
|
|
|
268
273
|
await dispatchCleanup();
|
|
269
274
|
|
|
270
|
-
|
|
271
|
-
|
|
275
|
+
// Same two-phase timing as the single-tenant release test above: wait on
|
|
276
|
+
// each provider's actual release, not on draft deletion (which finishes
|
|
277
|
+
// for the whole batch before either row's release even starts).
|
|
278
|
+
await waitFor(() => {
|
|
279
|
+
expect(providerA.keys()).not.toContain(keyA);
|
|
272
280
|
});
|
|
273
|
-
await waitFor(
|
|
274
|
-
expect(
|
|
281
|
+
await waitFor(() => {
|
|
282
|
+
expect(providerB.keys()).not.toContain(keyB);
|
|
275
283
|
});
|
|
276
284
|
|
|
277
|
-
expect(
|
|
278
|
-
expect(
|
|
285
|
+
expect(await draftExists("wizard:tenant-a-stale")).toBe(false);
|
|
286
|
+
expect(await draftExists("wizard:tenant-b-stale")).toBe(false);
|
|
279
287
|
// Cross-tenant guard: neither key ever lands in the other tenant's provider.
|
|
280
288
|
expect(providerA.keys()).not.toContain(keyB);
|
|
281
289
|
expect(providerB.keys()).not.toContain(keyA);
|
|
@@ -286,15 +294,28 @@ describe("form-draft cleanup job — FileRef release (#1915)", () => {
|
|
|
286
294
|
await providerA.write(forgedKey, new Uint8Array([9, 9, 9]), "image/jpeg");
|
|
287
295
|
// Deliberately no seedFileRef — the stale draft's `values` claims a
|
|
288
296
|
// storageKey its owner never actually uploaded.
|
|
289
|
-
|
|
290
297
|
await saveDraft("wizard:stale-forged", { photo: fileRefPointer(forgedKey) });
|
|
291
|
-
await backdate("wizard:stale-forged",
|
|
298
|
+
await backdate("wizard:stale-forged", 40);
|
|
299
|
+
|
|
300
|
+
// Release sentinel: a real, legitimately-owned FileRef backdated less
|
|
301
|
+
// than the forged row, so selectStaleDraftsBatch's oldest-first order
|
|
302
|
+
// processes the forged row first. Releases are awaited sequentially in
|
|
303
|
+
// order within one job run, so observing the sentinel's release proves
|
|
304
|
+
// the forged row's (earlier) release turn already ran and correctly did
|
|
305
|
+
// nothing — a plain draftExists wait can't prove that, only that the
|
|
306
|
+
// delete phase (which precedes all releases) has finished.
|
|
307
|
+
const sentinelKey = "tenant-a/vehicle/forged-sentinel.jpg";
|
|
308
|
+
await providerA.write(sentinelKey, new Uint8Array([7]), "image/jpeg");
|
|
309
|
+
await seedFileRef(sentinelKey);
|
|
310
|
+
await saveDraft("wizard:stale-forged-sentinel", { photo: fileRefPointer(sentinelKey) });
|
|
311
|
+
await backdate("wizard:stale-forged-sentinel", 31);
|
|
292
312
|
|
|
293
313
|
await dispatchCleanup();
|
|
294
314
|
|
|
295
|
-
await waitFor(
|
|
296
|
-
expect(
|
|
315
|
+
await waitFor(() => {
|
|
316
|
+
expect(providerA.keys()).not.toContain(sentinelKey);
|
|
297
317
|
});
|
|
318
|
+
expect(await draftExists("wizard:stale-forged")).toBe(false);
|
|
298
319
|
expect(providerA.keys()).toContain(forgedKey);
|
|
299
320
|
});
|
|
300
321
|
|
|
@@ -304,18 +325,28 @@ describe("form-draft cleanup job — FileRef release (#1915)", () => {
|
|
|
304
325
|
await seedFileRef(key);
|
|
305
326
|
// The photo was uploaded when the domain entity was originally created —
|
|
306
327
|
// long before this editing session's draft. backdate() (below) also
|
|
307
|
-
// ages the draft row to
|
|
328
|
+
// ages the draft row to 40 days, so the file must be aged further still
|
|
308
329
|
// to model "predates the draft" rather than "same moment".
|
|
309
330
|
await backdateFileRef(key, 60);
|
|
310
331
|
|
|
311
332
|
await saveDraft("wizard:stale-with-prefilled-photo", { photo: fileRefPointer(key) });
|
|
312
|
-
await backdate("wizard:stale-with-prefilled-photo",
|
|
333
|
+
await backdate("wizard:stale-with-prefilled-photo", 40);
|
|
334
|
+
|
|
335
|
+
// Release sentinel — see the forged-draft-value test above for why a
|
|
336
|
+
// plain draftExists wait can't prove this row's release was actually
|
|
337
|
+
// (correctly) skipped, only that it hasn't been attempted yet.
|
|
338
|
+
const sentinelKey = "tenant-a/vehicle/prefilled-sentinel.jpg";
|
|
339
|
+
await providerA.write(sentinelKey, new Uint8Array([7]), "image/jpeg");
|
|
340
|
+
await seedFileRef(sentinelKey);
|
|
341
|
+
await saveDraft("wizard:stale-with-prefilled-sentinel", { photo: fileRefPointer(sentinelKey) });
|
|
342
|
+
await backdate("wizard:stale-with-prefilled-sentinel", 31);
|
|
313
343
|
|
|
314
344
|
await dispatchCleanup();
|
|
315
345
|
|
|
316
|
-
await waitFor(
|
|
317
|
-
expect(
|
|
346
|
+
await waitFor(() => {
|
|
347
|
+
expect(providerA.keys()).not.toContain(sentinelKey);
|
|
318
348
|
});
|
|
349
|
+
expect(await draftExists("wizard:stale-with-prefilled-photo")).toBe(false);
|
|
319
350
|
// The live domain entity this draft was editing still references `key`.
|
|
320
351
|
expect(providerA.keys()).toContain(key);
|
|
321
352
|
});
|
|
@@ -336,9 +367,9 @@ describe("form-draft cleanup job — FileRef release (#1915)", () => {
|
|
|
336
367
|
|
|
337
368
|
await dispatchCleanup();
|
|
338
369
|
|
|
339
|
-
await waitFor(
|
|
340
|
-
expect(
|
|
370
|
+
await waitFor(() => {
|
|
371
|
+
expect(providerA.keys()).not.toContain(key);
|
|
341
372
|
});
|
|
342
|
-
expect(
|
|
373
|
+
expect(await draftExists("wizard:new:create-mode-stale")).toBe(false);
|
|
343
374
|
});
|
|
344
375
|
});
|
|
@@ -131,12 +131,12 @@ describe("NotesSection — entry display", () => {
|
|
|
131
131
|
test("note body and meta line render as two distinct nodes, not one run-on line", async () => {
|
|
132
132
|
const view = renderSection();
|
|
133
133
|
await waitFor(() => {
|
|
134
|
-
if (view.queryByTestId("notes-section-
|
|
134
|
+
if (view.queryByTestId("notes-section-body-note-1") === null) {
|
|
135
135
|
throw new Error("row not rendered yet");
|
|
136
136
|
}
|
|
137
137
|
});
|
|
138
|
-
const bodyEl = view.getByTestId("notes-section-
|
|
139
|
-
const metaEl = view.getByTestId("notes-section-
|
|
138
|
+
const bodyEl = view.getByTestId("notes-section-body-note-1");
|
|
139
|
+
const metaEl = view.getByTestId("notes-section-meta-note-1");
|
|
140
140
|
expect(bodyEl).not.toBe(metaEl);
|
|
141
141
|
expect(bodyEl.textContent).toBe(NOTE_BODY);
|
|
142
142
|
expect(metaEl.textContent).not.toContain(NOTE_BODY);
|
|
@@ -145,14 +145,25 @@ describe("NotesSection — entry display", () => {
|
|
|
145
145
|
expect(row.className).toContain("flex-col");
|
|
146
146
|
});
|
|
147
147
|
|
|
148
|
+
test("the row testid prefix matches exactly one node per note, not three", async () => {
|
|
149
|
+
const view = renderSection();
|
|
150
|
+
await waitFor(() => {
|
|
151
|
+
if (view.queryByTestId("notes-section-row-note-1") === null) {
|
|
152
|
+
throw new Error("rows not rendered yet");
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
const matches = view.container.querySelectorAll('[data-testid^="notes-section-row-"]');
|
|
156
|
+
expect(matches.length).toBe(NOTE_ROWS.length);
|
|
157
|
+
});
|
|
158
|
+
|
|
148
159
|
test("a stamped authorName renders as the author, never the raw authorId", async () => {
|
|
149
160
|
const view = renderSection();
|
|
150
161
|
await waitFor(() => {
|
|
151
|
-
if (view.queryByTestId("notes-section-
|
|
162
|
+
if (view.queryByTestId("notes-section-meta-note-1") === null) {
|
|
152
163
|
throw new Error("row not rendered yet");
|
|
153
164
|
}
|
|
154
165
|
});
|
|
155
|
-
const metaEl = view.getByTestId("notes-section-
|
|
166
|
+
const metaEl = view.getByTestId("notes-section-meta-note-1");
|
|
156
167
|
expect(metaEl.textContent).not.toContain(AUTHOR_ID);
|
|
157
168
|
expect(metaEl.textContent).toContain(AUTHOR_NAME);
|
|
158
169
|
});
|
|
@@ -160,22 +171,22 @@ describe("NotesSection — entry display", () => {
|
|
|
160
171
|
test("a null authorName (legacy row) renders the placeholder", async () => {
|
|
161
172
|
const view = renderSection();
|
|
162
173
|
await waitFor(() => {
|
|
163
|
-
if (view.queryByTestId("notes-section-
|
|
174
|
+
if (view.queryByTestId("notes-section-meta-note-2") === null) {
|
|
164
175
|
throw new Error("row not rendered yet");
|
|
165
176
|
}
|
|
166
177
|
});
|
|
167
|
-
const metaEl = view.getByTestId("notes-section-
|
|
178
|
+
const metaEl = view.getByTestId("notes-section-meta-note-2");
|
|
168
179
|
expect(metaEl.textContent).toContain("Unknown author");
|
|
169
180
|
});
|
|
170
181
|
|
|
171
182
|
test("a shredded authorName sentinel renders the placeholder, never [[erased]]", async () => {
|
|
172
183
|
const view = renderSection();
|
|
173
184
|
await waitFor(() => {
|
|
174
|
-
if (view.queryByTestId("notes-section-
|
|
185
|
+
if (view.queryByTestId("notes-section-meta-note-3") === null) {
|
|
175
186
|
throw new Error("row not rendered yet");
|
|
176
187
|
}
|
|
177
188
|
});
|
|
178
|
-
const metaEl = view.getByTestId("notes-section-
|
|
189
|
+
const metaEl = view.getByTestId("notes-section-meta-note-3");
|
|
179
190
|
expect(metaEl.textContent).toContain("Unknown author");
|
|
180
191
|
expect(metaEl.textContent).not.toContain(PII_ERASED_SENTINEL);
|
|
181
192
|
});
|
|
@@ -183,11 +194,11 @@ describe("NotesSection — entry display", () => {
|
|
|
183
194
|
test("date renders formatted, never the raw ISO timestamp", async () => {
|
|
184
195
|
const view = renderSection();
|
|
185
196
|
await waitFor(() => {
|
|
186
|
-
if (view.queryByTestId("notes-section-
|
|
197
|
+
if (view.queryByTestId("notes-section-meta-note-1") === null) {
|
|
187
198
|
throw new Error("row not rendered yet");
|
|
188
199
|
}
|
|
189
200
|
});
|
|
190
|
-
const metaEl = view.getByTestId("notes-section-
|
|
201
|
+
const metaEl = view.getByTestId("notes-section-meta-note-1");
|
|
191
202
|
expect(metaEl.textContent).not.toContain(INSERTED_AT);
|
|
192
203
|
expect(metaEl.textContent).toContain(formatWhen(INSERTED_AT));
|
|
193
204
|
});
|
|
@@ -149,8 +149,8 @@ export function NotesSection({
|
|
|
149
149
|
data-testid={`notes-section-row-${n.id}`}
|
|
150
150
|
className="flex flex-col gap-1 rounded-md border p-2"
|
|
151
151
|
>
|
|
152
|
-
<Text testId={`notes-section-
|
|
153
|
-
<Text variant="small" testId={`notes-section-
|
|
152
|
+
<Text testId={`notes-section-body-${n.id}`}>{n.body}</Text>
|
|
153
|
+
<Text variant="small" testId={`notes-section-meta-${n.id}`}>
|
|
154
154
|
{t("notesHistory.section.meta", {
|
|
155
155
|
author: displayAuthorName(n.authorName, t("notesHistory.section.authorUnknown")),
|
|
156
156
|
date: formatWhen(n.insertedAt),
|
|
@@ -6,10 +6,21 @@ import { userSessionTable } from "../schema/user-session";
|
|
|
6
6
|
|
|
7
7
|
// Admin single-session inspector — mirrors list.query's decrypt handling for
|
|
8
8
|
// the one-row case. ctx.db (TenantDb) applies tenant-scoping automatically.
|
|
9
|
+
const sessionRowSchema = z.object({
|
|
10
|
+
id: z.string(),
|
|
11
|
+
userId: z.string(),
|
|
12
|
+
createdAt: z.unknown(),
|
|
13
|
+
expiresAt: z.unknown(),
|
|
14
|
+
revokedAt: z.unknown(),
|
|
15
|
+
ip: z.string().nullable(),
|
|
16
|
+
userAgent: z.string().nullable(),
|
|
17
|
+
});
|
|
18
|
+
|
|
9
19
|
export const detailQuery = defineQueryHandler({
|
|
10
20
|
name: "user-session:detail",
|
|
11
21
|
schema: z.object({ id: z.uuid() }),
|
|
12
22
|
access: { roles: access.admin },
|
|
23
|
+
outputSchema: sessionRowSchema.nullable(),
|
|
13
24
|
handler: async (query, ctx) => {
|
|
14
25
|
const row = await fetchOne<{
|
|
15
26
|
id: string;
|
|
@@ -33,6 +33,20 @@ export const listQuery = definePagedQueryHandler({
|
|
|
33
33
|
sortDirection: z.enum(["asc", "desc"]).optional(),
|
|
34
34
|
}),
|
|
35
35
|
access: { roles: access.admin },
|
|
36
|
+
outputSchema: z.object({
|
|
37
|
+
rows: z.array(
|
|
38
|
+
z.object({
|
|
39
|
+
id: z.string(),
|
|
40
|
+
userId: z.string(),
|
|
41
|
+
createdAt: z.unknown(),
|
|
42
|
+
expiresAt: z.unknown(),
|
|
43
|
+
revokedAt: z.unknown(),
|
|
44
|
+
ip: z.string().nullable(),
|
|
45
|
+
userAgent: z.string().nullable(),
|
|
46
|
+
}),
|
|
47
|
+
),
|
|
48
|
+
nextCursor: z.string().nullable(),
|
|
49
|
+
}),
|
|
36
50
|
handler: async (query, ctx) => {
|
|
37
51
|
const requestedSort = query.payload.sort;
|
|
38
52
|
const sortColumn: SortableColumn =
|