@cosmicdrift/kumiko-framework 0.105.0 → 0.105.2
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 +2 -2
- package/src/api/__tests__/jwt.test.ts +27 -0
- package/src/api/jwt.ts +4 -1
- package/src/engine/__tests__/required-surface-keys.test.ts +134 -1
- package/src/engine/extensions/user-data.ts +6 -0
- package/src/files/__tests__/storage-tracking.integration.test.ts +8 -0
- package/src/files/file-routes.ts +1 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +43 -0
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +24 -0
- package/src/pipeline/projection-rebuild.ts +6 -0
- package/src/time/__tests__/boot-tz-warning.test.ts +17 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.105.
|
|
3
|
+
"version": "0.105.2",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
"zod": "^4.4.3"
|
|
182
182
|
},
|
|
183
183
|
"devDependencies": {
|
|
184
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.105.
|
|
184
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.105.2",
|
|
185
185
|
"bun-types": "^1.3.13",
|
|
186
186
|
"pino-pretty": "^13.1.3"
|
|
187
187
|
},
|
|
@@ -26,6 +26,17 @@ function forge(claims: Record<string, unknown>): Promise<string> {
|
|
|
26
26
|
.sign(new TextEncoder().encode(SECRET));
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// Same as forge(), but never calls .setSubject() — the only way to produce a
|
|
30
|
+
// validly-signed token with no `sub` claim at all (forge() always sets one).
|
|
31
|
+
function forgeNoSub(claims: Record<string, unknown>): Promise<string> {
|
|
32
|
+
return new jose.SignJWT(claims)
|
|
33
|
+
.setProtectedHeader({ alg: "HS256" })
|
|
34
|
+
.setIssuer(ISSUER)
|
|
35
|
+
.setIssuedAt()
|
|
36
|
+
.setExpirationTime("1h")
|
|
37
|
+
.sign(new TextEncoder().encode(SECRET));
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
describe("createJwtHelper.verify — payload validation (KF-2)", () => {
|
|
30
41
|
const jwt = createJwtHelper(SECRET);
|
|
31
42
|
|
|
@@ -55,4 +66,20 @@ describe("createJwtHelper.verify — payload validation (KF-2)", () => {
|
|
|
55
66
|
const token = await forge({ tenantId: TENANT, roles: ["ok", 7] });
|
|
56
67
|
await expect(jwt.verify(token)).rejects.toThrow(/roles/);
|
|
57
68
|
});
|
|
69
|
+
|
|
70
|
+
it("rejects a validly-signed token with no sub claim", async () => {
|
|
71
|
+
const token = await forgeNoSub({ tenantId: TENANT, roles: ["TenantAdmin"] });
|
|
72
|
+
await expect(jwt.verify(token)).rejects.toThrow(/sub/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("rejects a token whose sub claim is an empty string", async () => {
|
|
76
|
+
const token = await new jose.SignJWT({ tenantId: TENANT, roles: ["TenantAdmin"] })
|
|
77
|
+
.setProtectedHeader({ alg: "HS256" })
|
|
78
|
+
.setSubject("")
|
|
79
|
+
.setIssuer(ISSUER)
|
|
80
|
+
.setIssuedAt()
|
|
81
|
+
.setExpirationTime("1h")
|
|
82
|
+
.sign(new TextEncoder().encode(SECRET));
|
|
83
|
+
await expect(jwt.verify(token)).rejects.toThrow(/sub/);
|
|
84
|
+
});
|
|
58
85
|
});
|
package/src/api/jwt.ts
CHANGED
|
@@ -69,9 +69,12 @@ export function createJwtHelper(secret: string, issuer = "kumiko"): JwtHelper {
|
|
|
69
69
|
}
|
|
70
70
|
roles.push(role);
|
|
71
71
|
}
|
|
72
|
+
if (typeof payload.sub !== "string" || payload.sub === "") {
|
|
73
|
+
throw new Error("JWT payload validation failed: sub claim is missing or malformed");
|
|
74
|
+
}
|
|
72
75
|
|
|
73
76
|
const result: JwtPayload = {
|
|
74
|
-
sub:
|
|
77
|
+
sub: payload.sub,
|
|
75
78
|
tenantId,
|
|
76
79
|
roles,
|
|
77
80
|
};
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
ACTION_FORM_ENTITY,
|
|
4
|
+
CONFIG_EDIT_ENTITY,
|
|
4
5
|
fieldLabelKey,
|
|
6
|
+
requiredKeysFromNav,
|
|
5
7
|
requiredKeysFromScreen,
|
|
8
|
+
requiredKeysFromWorkspace,
|
|
6
9
|
screenTitleKey,
|
|
7
10
|
} from "../../i18n/required-surface-keys";
|
|
8
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
ConfigEditScreenDefinition,
|
|
13
|
+
EntityEditScreenDefinition,
|
|
14
|
+
EntityListScreenDefinition,
|
|
15
|
+
} from "../types";
|
|
9
16
|
|
|
10
17
|
describe("requiredKeysFromScreen", () => {
|
|
11
18
|
test("entityList emits screen title + column field labels", () => {
|
|
@@ -35,4 +42,130 @@ describe("requiredKeysFromScreen", () => {
|
|
|
35
42
|
});
|
|
36
43
|
expect(keys).toContain(fieldLabelKey("publicstatus", ACTION_FORM_ENTITY, "title"));
|
|
37
44
|
});
|
|
45
|
+
|
|
46
|
+
test("entityEdit emits submitLabel + section titles + field labels (override honored)", () => {
|
|
47
|
+
const screen: EntityEditScreenDefinition = {
|
|
48
|
+
id: "component-edit",
|
|
49
|
+
type: "entityEdit",
|
|
50
|
+
entity: "component",
|
|
51
|
+
submitLabel: "publicstatus:actions.saveComponent",
|
|
52
|
+
fieldLabels: { name: "publicstatus:override.name" },
|
|
53
|
+
layout: {
|
|
54
|
+
sections: [{ title: "publicstatus:section.basics", fields: ["name", "status"] }],
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
const keys = requiredKeysFromScreen("publicstatus", screen);
|
|
58
|
+
expect(keys).toContain("publicstatus:actions.saveComponent");
|
|
59
|
+
expect(keys).toContain("publicstatus:section.basics");
|
|
60
|
+
// override wins over the default entity:field convention
|
|
61
|
+
expect(keys).toContain("publicstatus:override.name");
|
|
62
|
+
expect(keys).not.toContain(fieldLabelKey("publicstatus", "component", "name"));
|
|
63
|
+
expect(keys).toContain(fieldLabelKey("publicstatus", "component", "status"));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("entityEdit extension section pushes only the section title (no field labels)", () => {
|
|
67
|
+
const screen: EntityEditScreenDefinition = {
|
|
68
|
+
id: "component-edit",
|
|
69
|
+
type: "entityEdit",
|
|
70
|
+
entity: "component",
|
|
71
|
+
layout: {
|
|
72
|
+
sections: [
|
|
73
|
+
{
|
|
74
|
+
kind: "extension",
|
|
75
|
+
title: "publicstatus:section.customFields",
|
|
76
|
+
component: { react: {} },
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
const keys = requiredKeysFromScreen("publicstatus", screen);
|
|
82
|
+
expect(keys).toEqual([screenTitleKey("component-edit"), "publicstatus:section.customFields"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("configEdit uses the CONFIG_EDIT_ENTITY namespace, honors fieldLabels override", () => {
|
|
86
|
+
const screen: ConfigEditScreenDefinition = {
|
|
87
|
+
id: "settings-retention",
|
|
88
|
+
type: "configEdit",
|
|
89
|
+
scope: "tenant",
|
|
90
|
+
configKeys: { days: "publicstatus:config:retentionDays" },
|
|
91
|
+
fieldLabels: { days: "publicstatus:override.retentionDays" },
|
|
92
|
+
fields: { days: { type: "number" } },
|
|
93
|
+
layout: { sections: [{ fields: ["days"] }] },
|
|
94
|
+
};
|
|
95
|
+
const keys = requiredKeysFromScreen("publicstatus", screen);
|
|
96
|
+
expect(keys).toContain("publicstatus:override.retentionDays");
|
|
97
|
+
expect(keys).not.toContain(fieldLabelKey("publicstatus", CONFIG_EDIT_ENTITY, "days"));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("configEdit without a fieldLabels override falls back to the CONFIG_EDIT_ENTITY convention", () => {
|
|
101
|
+
const screen: ConfigEditScreenDefinition = {
|
|
102
|
+
id: "settings-retention",
|
|
103
|
+
type: "configEdit",
|
|
104
|
+
scope: "tenant",
|
|
105
|
+
configKeys: { days: "publicstatus:config:retentionDays" },
|
|
106
|
+
fields: { days: { type: "number" } },
|
|
107
|
+
layout: { sections: [{ fields: ["days"] }] },
|
|
108
|
+
};
|
|
109
|
+
const keys = requiredKeysFromScreen("publicstatus", screen);
|
|
110
|
+
expect(keys).toContain(fieldLabelKey("publicstatus", CONFIG_EDIT_ENTITY, "days"));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("custom screen emits only the screen title — no field surface to validate", () => {
|
|
114
|
+
const keys = requiredKeysFromScreen("publicstatus", {
|
|
115
|
+
id: "dashboard",
|
|
116
|
+
type: "custom",
|
|
117
|
+
renderer: { react: {} },
|
|
118
|
+
});
|
|
119
|
+
expect(keys).toEqual([screenTitleKey("dashboard")]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("entityList rowActions/toolbarActions emit label + confirm + confirmLabel", () => {
|
|
123
|
+
const screen: EntityListScreenDefinition = {
|
|
124
|
+
id: "component-list",
|
|
125
|
+
type: "entityList",
|
|
126
|
+
entity: "component",
|
|
127
|
+
columns: ["name"],
|
|
128
|
+
rowActions: [
|
|
129
|
+
{
|
|
130
|
+
id: "delete",
|
|
131
|
+
label: "publicstatus:actions.delete",
|
|
132
|
+
handler: "publicstatus:write:component:delete",
|
|
133
|
+
confirm: "publicstatus:confirm.deleteComponent",
|
|
134
|
+
confirmLabel: "publicstatus:confirm.deleteComponentButton",
|
|
135
|
+
style: "danger",
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
toolbarActions: [
|
|
139
|
+
{
|
|
140
|
+
kind: "writeHandler",
|
|
141
|
+
id: "sync-all",
|
|
142
|
+
label: "publicstatus:actions.syncAll",
|
|
143
|
+
handler: "publicstatus:write:component:syncAll",
|
|
144
|
+
confirm: "publicstatus:confirm.syncAll",
|
|
145
|
+
confirmLabel: "publicstatus:confirm.syncAllButton",
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
};
|
|
149
|
+
const keys = requiredKeysFromScreen("publicstatus", screen);
|
|
150
|
+
expect(keys).toContain("publicstatus:actions.delete");
|
|
151
|
+
expect(keys).toContain("publicstatus:confirm.deleteComponent");
|
|
152
|
+
expect(keys).toContain("publicstatus:confirm.deleteComponentButton");
|
|
153
|
+
expect(keys).toContain("publicstatus:actions.syncAll");
|
|
154
|
+
expect(keys).toContain("publicstatus:confirm.syncAll");
|
|
155
|
+
expect(keys).toContain("publicstatus:confirm.syncAllButton");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("requiredKeysFromNav / requiredKeysFromWorkspace", () => {
|
|
160
|
+
test("nav label is a required key", () => {
|
|
161
|
+
expect(requiredKeysFromNav({ id: "catalog", label: "shop:nav.catalog" })).toEqual([
|
|
162
|
+
"shop:nav.catalog",
|
|
163
|
+
]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("workspace label is a required key", () => {
|
|
167
|
+
expect(
|
|
168
|
+
requiredKeysFromWorkspace({ id: "disposition", label: "bmc:workspace.disposition" }),
|
|
169
|
+
).toEqual(["bmc:workspace.disposition"]);
|
|
170
|
+
});
|
|
38
171
|
});
|
|
@@ -90,6 +90,12 @@ export interface UserDataHookCtx {
|
|
|
90
90
|
* touch tenant-scoped rows. Absent → treat as `"multi-user"` (no erasure).
|
|
91
91
|
*/
|
|
92
92
|
readonly tenantModel?: TenantUserModel;
|
|
93
|
+
/**
|
|
94
|
+
* Original user email captured before the forget transaction anonymizes it.
|
|
95
|
+
* Set on delete hooks during `runForgetCleanup` so matchers (e.g. email
|
|
96
|
+
* subscriptions) work in every tenant pass. Absent on export hooks.
|
|
97
|
+
*/
|
|
98
|
+
readonly userEmailBeforeDelete?: string | null;
|
|
93
99
|
}
|
|
94
100
|
|
|
95
101
|
/**
|
|
@@ -224,4 +224,12 @@ describe("file download roundtrip (GET /files/:id)", () => {
|
|
|
224
224
|
await deleteFile(admin, id);
|
|
225
225
|
expect((await download(admin, id)).status).toBe(404);
|
|
226
226
|
});
|
|
227
|
+
|
|
228
|
+
test("a cross-tenant download is rejected (404, no existence oracle)", async () => {
|
|
229
|
+
const id = await upload(admin, "tenant-a-only.png", SMALL);
|
|
230
|
+
const res = await download(otherAdmin, id);
|
|
231
|
+
expect(res.status).toBe(404);
|
|
232
|
+
const body = (await res.json()) as { error?: string };
|
|
233
|
+
expect(body.error).toBe("not_found");
|
|
234
|
+
});
|
|
227
235
|
});
|
package/src/files/file-routes.ts
CHANGED
|
@@ -150,8 +150,8 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
|
150
150
|
// are orphaned in the provider; cleanup-jobs sweep those later. Losing a
|
|
151
151
|
// row on append-failure is acceptable; corrupting a committed row with a
|
|
152
152
|
// missing binary is not.
|
|
153
|
-
const data = new Uint8Array(await file.arrayBuffer());
|
|
154
153
|
const storageProvider = await options.resolveProvider(user.tenantId);
|
|
154
|
+
const data = new Uint8Array(await file.arrayBuffer());
|
|
155
155
|
await storageProvider.write(storageKey, data, file.type);
|
|
156
156
|
|
|
157
157
|
// Create via the standard entity executor: emits fileRef.created +
|
|
@@ -547,6 +547,49 @@ describe("write-handler shape guard", () => {
|
|
|
547
547
|
});
|
|
548
548
|
});
|
|
549
549
|
|
|
550
|
+
// --- Dispatch: geoTzProvider seam ---
|
|
551
|
+
|
|
552
|
+
describe("dispatcher context.geoTzProvider (680/1)", () => {
|
|
553
|
+
test("ctx.tz.fromCoordinates reaches the app-injected geoTzProvider through a real dispatch", async () => {
|
|
554
|
+
// All geo-tz.test.ts coverage calls createTzContext({ geoTz }) directly —
|
|
555
|
+
// none go through the dispatcher or read context.geoTzProvider. A typo in
|
|
556
|
+
// the property forwarding at dispatcher.ts would make the injection a
|
|
557
|
+
// silent no-op (provider configured, ctx.tz.fromCoordinates still throws).
|
|
558
|
+
const geoFeature = defineFeature("geo", (r) => {
|
|
559
|
+
r.queryHandler(
|
|
560
|
+
"resolve-zone",
|
|
561
|
+
z.object({ latitude: z.number(), longitude: z.number() }),
|
|
562
|
+
async (query, ctx) => ({
|
|
563
|
+
zone: await ctx.tz.fromCoordinates({
|
|
564
|
+
latitude: query.payload.latitude,
|
|
565
|
+
longitude: query.payload.longitude,
|
|
566
|
+
}),
|
|
567
|
+
}),
|
|
568
|
+
{ access: { openToAll: true } },
|
|
569
|
+
);
|
|
570
|
+
});
|
|
571
|
+
const registry = createRegistry([geoFeature]);
|
|
572
|
+
const calls: Array<{ latitude: number; longitude: number }> = [];
|
|
573
|
+
const dispatcher = createDispatcher(registry, {
|
|
574
|
+
geoTzProvider: {
|
|
575
|
+
fromCoordinates: ({ latitude, longitude }) => {
|
|
576
|
+
calls.push({ latitude, longitude });
|
|
577
|
+
return "Europe/Berlin";
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
const result = await dispatcher.query(
|
|
583
|
+
"geo:query:resolve-zone",
|
|
584
|
+
{ latitude: 52.52, longitude: 13.405 },
|
|
585
|
+
createTestUser(),
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
expect(result).toEqual({ zone: "Europe/Berlin" });
|
|
589
|
+
expect(calls).toEqual([{ latitude: 52.52, longitude: 13.405 }]);
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
|
|
550
593
|
// --- Mock helpers ---
|
|
551
594
|
|
|
552
595
|
function createMockIdempotencyGuard() {
|
|
@@ -750,6 +750,30 @@ describe("rebuildProjection — live-tail catch-up (#363 Phase 2)", () => {
|
|
|
750
750
|
expect(await getCount(groupX)).toBe(1); // #443 fixed: fenced count re-check re-replayed the missed low-id event
|
|
751
751
|
});
|
|
752
752
|
|
|
753
|
+
test("(656/1) a clean rebuild with no concurrency does not trigger the #443 recompute", async () => {
|
|
754
|
+
// The #443 shortfall-recompute rebuilds the shadow a 2nd time and re-replays
|
|
755
|
+
// the whole log — correct on the rare path it guards, but a silent perf
|
|
756
|
+
// regression if it fired on every rebuild. Its final values are identical
|
|
757
|
+
// either way (full re-replay is idempotent), so only a call-count seam can
|
|
758
|
+
// prove it stayed off the happy path.
|
|
759
|
+
const group = "00000000-0000-4000-8000-000000000203";
|
|
760
|
+
await appendCreatedEvent(group, "a");
|
|
761
|
+
await appendCreatedEvent(group, "b");
|
|
762
|
+
|
|
763
|
+
let shadowBuilds = 0;
|
|
764
|
+
const result = await rebuildProjection(qualifiedProjectionName, {
|
|
765
|
+
db: testDb.db,
|
|
766
|
+
registry,
|
|
767
|
+
__test_onBuildShadowTable: () => {
|
|
768
|
+
shadowBuilds++;
|
|
769
|
+
},
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
expect(shadowBuilds).toBe(1);
|
|
773
|
+
expect(result.eventsProcessed).toBe(2);
|
|
774
|
+
expect(await getCount(group)).toBe(2);
|
|
775
|
+
});
|
|
776
|
+
|
|
753
777
|
test("the rebuild tx sees concurrently-committed rows (READ COMMITTED, not a frozen snapshot)", async () => {
|
|
754
778
|
// The catch-up loop only works if each fresh SELECT in the rebuild tx sees
|
|
755
779
|
// rows other connections committed since the previous batch. Under
|
|
@@ -151,6 +151,10 @@ type RebuildDeps = {
|
|
|
151
151
|
// a slow callback here delays the cutover fence acquisition, widening the
|
|
152
152
|
// window in which concurrent writers can still commit below the cursor.
|
|
153
153
|
readonly __test_onBeforeFence?: () => void | Promise<void>;
|
|
154
|
+
// Test-only seam: fires each time the shadow table is (re)built. The #443
|
|
155
|
+
// recompute is idempotent on final values, so only a call-count seam like
|
|
156
|
+
// this can prove it didn't fire on a clean, non-concurrent rebuild.
|
|
157
|
+
readonly __test_onBuildShadowTable?: () => void;
|
|
154
158
|
};
|
|
155
159
|
|
|
156
160
|
export async function rebuildProjection(
|
|
@@ -216,6 +220,7 @@ export async function rebuildProjection(
|
|
|
216
220
|
await db.begin(async (tx: DbTx) => {
|
|
217
221
|
await markProjectionRebuilding(tx, projectionName);
|
|
218
222
|
await buildShadowTable(tx, meta);
|
|
223
|
+
deps.__test_onBuildShadowTable?.();
|
|
219
224
|
|
|
220
225
|
// A projection that subscribes to nothing has no events to replay and no
|
|
221
226
|
// live writer touching its table — skip straight to swapping the empty
|
|
@@ -253,6 +258,7 @@ export async function rebuildProjection(
|
|
|
253
258
|
const expectedEvents = await countSubscribedEvents(tx, sourcesList, subscribedList);
|
|
254
259
|
if (expectedEvents > BigInt(eventsProcessed)) {
|
|
255
260
|
await buildShadowTable(tx, meta);
|
|
261
|
+
deps.__test_onBuildShadowTable?.();
|
|
256
262
|
lastProcessedEventId = 0n;
|
|
257
263
|
eventsProcessed = 0;
|
|
258
264
|
while ((await drainBatch(tx)) === REBUILD_BATCH_SIZE) {
|
|
@@ -18,7 +18,22 @@ describe("warnIfNonUtcServerTimeZone", () => {
|
|
|
18
18
|
expect(messages[0]).toContain("UTC");
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
test("Default-Aufruf liest die Prozess-TZ
|
|
22
|
-
|
|
21
|
+
test("Default-Aufruf liest die ECHTE Prozess-TZ (ambient, nicht injiziert)", () => {
|
|
22
|
+
// Das ist der einzige Test der ambient-TZ liest statt sie zu injizieren —
|
|
23
|
+
// der Grund warum der tz-matrix-CI-Job (4 Legs: LA/Berlin/Tokyo/Apia)
|
|
24
|
+
// überhaupt einen Unterschied zwischen den Legs sehen kann. Ohne diese
|
|
25
|
+
// Assertion wäre "läuft in jeder Zone ohne zu werfen" in allen 4 Legs
|
|
26
|
+
// identisch grün — die beworbene Schutzwirkung ("ein `new Date(wallClock)`-
|
|
27
|
+
// Bug bricht HIER") würde nicht existieren.
|
|
28
|
+
const ambientTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
29
|
+
const messages: string[] = [];
|
|
30
|
+
const warned = warnIfNonUtcServerTimeZone(undefined, (m) => messages.push(m));
|
|
31
|
+
if (ambientTz === "UTC") {
|
|
32
|
+
expect(warned).toBe(false);
|
|
33
|
+
expect(messages).toHaveLength(0);
|
|
34
|
+
} else {
|
|
35
|
+
expect(warned).toBe(true);
|
|
36
|
+
expect(messages[0]).toContain(ambientTz);
|
|
37
|
+
}
|
|
23
38
|
});
|
|
24
39
|
});
|