@cosmicdrift/kumiko-bundled-features 0.176.0 → 0.176.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 +7 -7
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +31 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +7 -2
- package/src/auth-email-password/handlers/login.write.ts +6 -6
- package/src/auth-mfa/__tests__/verify.integration.test.ts +64 -7
- package/src/auth-mfa/handlers/verify.write.ts +8 -3
- package/src/config/__tests__/tz-resolution.integration.test.ts +7 -2
- package/src/jobs/__tests__/jobs-catalog.integration.test.ts +37 -99
- package/src/jobs/web/job-runs-screen.tsx +11 -1
- package/src/shared/index.ts +1 -0
- package/src/shared/session-timezone-field.ts +7 -0
- package/src/template-resolver/web/__tests__/editor-read-only.test.tsx +27 -2
- package/src/template-resolver/web/client-plugin.tsx +26 -12
- package/src/template-resolver/web/i18n.ts +39 -0
- package/src/tenant/__tests__/tenant-timezone-boot.integration.test.ts +60 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.176.
|
|
3
|
+
"version": "0.176.2",
|
|
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>",
|
|
@@ -120,12 +120,12 @@
|
|
|
120
120
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
121
121
|
},
|
|
122
122
|
"dependencies": {
|
|
123
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.176.
|
|
124
|
-
"@cosmicdrift/kumiko-framework": "0.176.
|
|
125
|
-
"@cosmicdrift/kumiko-headless": "0.176.
|
|
126
|
-
"@cosmicdrift/kumiko-renderer": "0.176.
|
|
127
|
-
"@cosmicdrift/kumiko-renderer-web": "0.176.
|
|
128
|
-
"@cosmicdrift/kumiko-types": "0.176.
|
|
123
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.176.2",
|
|
124
|
+
"@cosmicdrift/kumiko-framework": "0.176.2",
|
|
125
|
+
"@cosmicdrift/kumiko-headless": "0.176.2",
|
|
126
|
+
"@cosmicdrift/kumiko-renderer": "0.176.2",
|
|
127
|
+
"@cosmicdrift/kumiko-renderer-web": "0.176.2",
|
|
128
|
+
"@cosmicdrift/kumiko-types": "0.176.2",
|
|
129
129
|
"@mollie/api-client": "^4.5.0",
|
|
130
130
|
"imapflow": "^1.3.3",
|
|
131
131
|
"mailparser": "^3.9.8",
|
|
@@ -351,6 +351,37 @@ describe("invite-accept-with-login (Branch 2: anon + existing email)", () => {
|
|
|
351
351
|
expect(memberships).toHaveLength(2);
|
|
352
352
|
});
|
|
353
353
|
|
|
354
|
+
test("Bob accepts with a timezone → JWT retains the timezone", async () => {
|
|
355
|
+
await asRawClient(stack.db).unsafe(
|
|
356
|
+
`UPDATE "${userTable.tableName}" SET timezone = $1 WHERE id = $2`,
|
|
357
|
+
["Europe/Berlin", bobId],
|
|
358
|
+
);
|
|
359
|
+
try {
|
|
360
|
+
const token = await inviteEmail(BOB_EMAIL, "Editor");
|
|
361
|
+
|
|
362
|
+
const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
|
|
363
|
+
token,
|
|
364
|
+
email: BOB_EMAIL,
|
|
365
|
+
password: BOB_PASSWORD,
|
|
366
|
+
});
|
|
367
|
+
expect(res.status).toBe(200);
|
|
368
|
+
const body = (await res.json()) as { token?: string };
|
|
369
|
+
expect(body.token).toBeTypeOf("string");
|
|
370
|
+
if (!body.token) throw new Error("invite acceptance did not return a token");
|
|
371
|
+
|
|
372
|
+
const payload = await stack.jwt.verify(body.token);
|
|
373
|
+
expect(payload.timezone).toBe("Europe/Berlin");
|
|
374
|
+
} finally {
|
|
375
|
+
// Bob is shared across this describe block (other tests rely on his
|
|
376
|
+
// membership state persisting) — restore his timezone so later tests
|
|
377
|
+
// don't silently inherit it.
|
|
378
|
+
await asRawClient(stack.db).unsafe(
|
|
379
|
+
`UPDATE "${userTable.tableName}" SET timezone = NULL WHERE id = $1`,
|
|
380
|
+
[bobId],
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
354
385
|
test("Wrong password → 422 invalid_invite_token (anti-enum)", async () => {
|
|
355
386
|
const token = await inviteEmail(BOB_EMAIL, "Editor");
|
|
356
387
|
const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
29
29
|
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
30
30
|
import { z } from "zod";
|
|
31
|
-
import { decryptStoredPii, verifyPassword } from "../../shared";
|
|
31
|
+
import { decryptStoredPii, sessionTimezoneField, verifyPassword } from "../../shared";
|
|
32
32
|
// kumiko-lint-ignore cross-feature-import invite-flow
|
|
33
33
|
import {
|
|
34
34
|
INVITATION_STATUS,
|
|
@@ -96,7 +96,11 @@ export function createInviteAcceptWithLoginHandler() {
|
|
|
96
96
|
readonly role: string;
|
|
97
97
|
readonly version: number;
|
|
98
98
|
};
|
|
99
|
-
type UserAuthRow = {
|
|
99
|
+
type UserAuthRow = {
|
|
100
|
+
readonly id: string;
|
|
101
|
+
readonly passwordHash: string | null;
|
|
102
|
+
readonly timezone?: string | null;
|
|
103
|
+
};
|
|
100
104
|
|
|
101
105
|
let committed = false;
|
|
102
106
|
try {
|
|
@@ -173,6 +177,7 @@ export function createInviteAcceptWithLoginHandler() {
|
|
|
173
177
|
// buildSessionRoles calls stripForbiddenMembershipRoles internally —
|
|
174
178
|
// a reserved role on the invitation itself must never reach the session.
|
|
175
179
|
roles: buildSessionRoles([], [invitationRole]),
|
|
180
|
+
...sessionTimezoneField(userRow.timezone),
|
|
176
181
|
};
|
|
177
182
|
|
|
178
183
|
committed = true;
|
|
@@ -86,19 +86,21 @@ function ok<T>(value: T): GateOk<T> {
|
|
|
86
86
|
return { ok: true, value };
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
type AuthenticatableUserRow = AuthUserRow & { readonly passwordHash: string };
|
|
90
|
+
|
|
89
91
|
/** Uniform response on any credential miss — burns argon2 cost (#774). */
|
|
90
92
|
export async function gateResolveAuthUser(
|
|
91
93
|
ctx: HandlerContext,
|
|
92
94
|
systemUser: SessionUser,
|
|
93
95
|
email: string,
|
|
94
96
|
password: string,
|
|
95
|
-
): Promise<GateOutcome<
|
|
97
|
+
): Promise<GateOutcome<AuthenticatableUserRow>> {
|
|
96
98
|
const found = parseAuthUserRow(await ctx.queryAs(systemUser, UserQueries.findForAuth, { email }));
|
|
97
99
|
if (!found?.passwordHash || found.isDeleted) {
|
|
98
100
|
await verifyDummyPassword(password);
|
|
99
101
|
return reject(invalidCredentials());
|
|
100
102
|
}
|
|
101
|
-
return ok(found);
|
|
103
|
+
return ok({ ...found, passwordHash: found.passwordHash });
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
/**
|
|
@@ -124,14 +126,12 @@ export async function gateEnforceLockout(
|
|
|
124
126
|
/** Verify password; record miss / clear lockout on hit. */
|
|
125
127
|
export async function gateVerifyPassword(
|
|
126
128
|
ctx: HandlerContext,
|
|
127
|
-
found:
|
|
129
|
+
found: AuthenticatableUserRow,
|
|
128
130
|
password: string,
|
|
129
131
|
maxFailedAttempts: number,
|
|
130
132
|
lockoutDurationMinutes: number,
|
|
131
133
|
): Promise<GateOutcome<undefined>> {
|
|
132
|
-
const
|
|
133
|
-
if (!passwordHash) return reject(invalidCredentials());
|
|
134
|
-
const passwordOk = await verifyPassword(passwordHash, password);
|
|
134
|
+
const passwordOk = await verifyPassword(found.passwordHash, password);
|
|
135
135
|
if (!passwordOk) {
|
|
136
136
|
if (ctx.redis) {
|
|
137
137
|
await recordFailedAttempt(ctx.redis, found.id, maxFailedAttempts, lockoutDurationMinutes);
|
|
@@ -14,9 +14,12 @@ import {
|
|
|
14
14
|
expectErrorIncludes,
|
|
15
15
|
seedRow,
|
|
16
16
|
} from "@cosmicdrift/kumiko-framework/testing";
|
|
17
|
+
import { AuthHandlers as AuthEmailPasswordHandlers } from "../../auth-email-password/constants";
|
|
18
|
+
import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
|
|
17
19
|
import { createConfigFeature } from "../../config";
|
|
18
20
|
import { createConfigResolver } from "../../config/resolver";
|
|
19
21
|
import { configValuesTable } from "../../config/table";
|
|
22
|
+
import { hashPassword } from "../../shared";
|
|
20
23
|
import { createTenantFeature } from "../../tenant";
|
|
21
24
|
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
22
25
|
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
@@ -25,7 +28,7 @@ import { createUserFeature } from "../../user/feature";
|
|
|
25
28
|
import { userEntity, userTable } from "../../user/schema/user";
|
|
26
29
|
import { base32Decode } from "../base32";
|
|
27
30
|
import { AuthMfaHandlers } from "../constants";
|
|
28
|
-
import { createAuthMfaFeature } from "../feature";
|
|
31
|
+
import { createAuthMfaFeature, mfaStatusCheckerFromFeature } from "../feature";
|
|
29
32
|
import { signMfaChallengeToken } from "../mfa-challenge-token";
|
|
30
33
|
import { userMfaEntity } from "../schema/user-mfa";
|
|
31
34
|
import { currentTotpCode } from "../totp";
|
|
@@ -49,18 +52,26 @@ beforeAll(async () => {
|
|
|
49
52
|
const encryption = createTestEnvelopeCipher();
|
|
50
53
|
configureEntityFieldEncryption(encryption);
|
|
51
54
|
const resolver = createConfigResolver({ cipher: encryption });
|
|
55
|
+
const authMfaFeature = createAuthMfaFeature({
|
|
56
|
+
setupTokenSecret: SETUP_TOKEN_SECRET,
|
|
57
|
+
issuer: "Kumiko Test",
|
|
58
|
+
challengeTokenSecret: CHALLENGE_TOKEN_SECRET,
|
|
59
|
+
});
|
|
52
60
|
stack = await setupTestStack({
|
|
53
61
|
features: [
|
|
54
62
|
createConfigFeature(),
|
|
55
63
|
createUserFeature(),
|
|
56
64
|
createTenantFeature(),
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
challengeTokenSecret: CHALLENGE_TOKEN_SECRET,
|
|
65
|
+
authMfaFeature,
|
|
66
|
+
createAuthEmailPasswordFeature({
|
|
67
|
+
mfaStatusChecker: mfaStatusCheckerFromFeature(authMfaFeature),
|
|
61
68
|
}),
|
|
62
69
|
],
|
|
63
70
|
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
71
|
+
authConfig: {
|
|
72
|
+
membershipQuery: "tenant:query:memberships",
|
|
73
|
+
loginHandler: AuthEmailPasswordHandlers.login,
|
|
74
|
+
},
|
|
64
75
|
});
|
|
65
76
|
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
66
77
|
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
@@ -76,8 +87,10 @@ async function enableMfaFor(idSeed: number): Promise<{
|
|
|
76
87
|
user: ReturnType<typeof createTestUser>;
|
|
77
88
|
secret: Buffer;
|
|
78
89
|
recoveryCodes: string[];
|
|
90
|
+
password: string;
|
|
79
91
|
}> {
|
|
80
92
|
const user = createTestUser({ id: idSeed, roles: ["User"] });
|
|
93
|
+
const password = `password-${idSeed}-long-enough`;
|
|
81
94
|
const start = await stack.http.writeOk<{
|
|
82
95
|
setupToken: string;
|
|
83
96
|
otpauthUri: string;
|
|
@@ -103,14 +116,14 @@ async function enableMfaFor(idSeed: number): Promise<{
|
|
|
103
116
|
id: user.id,
|
|
104
117
|
tenantId: user.tenantId,
|
|
105
118
|
email: `user-${user.id}@example.com`,
|
|
106
|
-
passwordHash:
|
|
119
|
+
passwordHash: await hashPassword(password),
|
|
107
120
|
displayName: `Test User ${idSeed}`,
|
|
108
121
|
locale: "de",
|
|
109
122
|
emailVerified: true,
|
|
110
123
|
roles: "[]",
|
|
111
124
|
status: USER_STATUS.Active,
|
|
112
125
|
});
|
|
113
|
-
return { user, secret, recoveryCodes: start.recoveryCodes };
|
|
126
|
+
return { user, secret, recoveryCodes: start.recoveryCodes, password };
|
|
114
127
|
}
|
|
115
128
|
|
|
116
129
|
function challengeFor(userId: string, tenantId: TenantId): string {
|
|
@@ -131,6 +144,50 @@ describe("mfa verify — completes a two-step login", () => {
|
|
|
131
144
|
expect(res.session.tenantId).toBe(user.tenantId);
|
|
132
145
|
});
|
|
133
146
|
|
|
147
|
+
test("a password login followed by MFA verify retains the user's timezone", async () => {
|
|
148
|
+
const { user, password, secret } = await enableMfaFor(9);
|
|
149
|
+
await asRawClient(stack.db).unsafe(
|
|
150
|
+
`UPDATE "${userTable.tableName}" SET timezone = $1 WHERE id = $2`,
|
|
151
|
+
["Europe/Berlin", user.id],
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const loginRes = await stack.http.raw("POST", "/api/auth/login", {
|
|
155
|
+
email: `user-${user.id}@example.com`,
|
|
156
|
+
password,
|
|
157
|
+
});
|
|
158
|
+
expect(loginRes.status).toBe(200);
|
|
159
|
+
const login = (await loginRes.json()) as { challengeToken?: string };
|
|
160
|
+
expect(login.challengeToken).toBeTypeOf("string");
|
|
161
|
+
if (!login.challengeToken) throw new Error("MFA login did not return a challenge token");
|
|
162
|
+
|
|
163
|
+
const verified = await stack.http.writeOk<{ session: SessionUser }>(
|
|
164
|
+
AuthMfaHandlers.verify,
|
|
165
|
+
{ challengeToken: login.challengeToken, code: currentTotpCode(secret) },
|
|
166
|
+
GUEST,
|
|
167
|
+
);
|
|
168
|
+
expect(verified.session.timezone).toBe("Europe/Berlin");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a password login followed by MFA verify omits an unset timezone", async () => {
|
|
172
|
+
const { user, password, secret } = await enableMfaFor(10);
|
|
173
|
+
|
|
174
|
+
const loginRes = await stack.http.raw("POST", "/api/auth/login", {
|
|
175
|
+
email: `user-${user.id}@example.com`,
|
|
176
|
+
password,
|
|
177
|
+
});
|
|
178
|
+
expect(loginRes.status).toBe(200);
|
|
179
|
+
const login = (await loginRes.json()) as { challengeToken?: string };
|
|
180
|
+
expect(login.challengeToken).toBeTypeOf("string");
|
|
181
|
+
if (!login.challengeToken) throw new Error("MFA login did not return a challenge token");
|
|
182
|
+
|
|
183
|
+
const verified = await stack.http.writeOk<{ session: SessionUser }>(
|
|
184
|
+
AuthMfaHandlers.verify,
|
|
185
|
+
{ challengeToken: login.challengeToken, code: currentTotpCode(secret) },
|
|
186
|
+
GUEST,
|
|
187
|
+
);
|
|
188
|
+
expect(verified.session.timezone).toBeUndefined();
|
|
189
|
+
});
|
|
190
|
+
|
|
134
191
|
test("a wrong TOTP code is rejected", async () => {
|
|
135
192
|
const { user } = await enableMfaFor(2);
|
|
136
193
|
const challengeToken = challengeFor(user.id, user.tenantId);
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
9
9
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
10
10
|
import { z } from "zod";
|
|
11
|
-
import { burnToken } from "../../shared";
|
|
11
|
+
import { burnToken, sessionTimezoneField } from "../../shared";
|
|
12
12
|
import { USER_STATUS, UserQueries } from "../../user";
|
|
13
13
|
import { MFA_VERIFY_LOCKOUT_MINUTES, MFA_VERIFY_MAX_ATTEMPTS } from "../constants";
|
|
14
14
|
import { findUserMfaRow } from "../db/queries";
|
|
@@ -135,7 +135,7 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
|
|
|
135
135
|
const systemUser = createSystemUser(tenantId, ["SystemAdmin"]);
|
|
136
136
|
const userRow = (await ctx.queryAs(systemUser, UserQueries.findForAuth, {
|
|
137
137
|
id: userId,
|
|
138
|
-
})) as { roles?: string | null; status?: string } | null; // @cast-boundary engine-payload
|
|
138
|
+
})) as { roles?: string | null; status?: string; timezone?: string | null } | null; // @cast-boundary engine-payload
|
|
139
139
|
|
|
140
140
|
// Re-check status + membership the way login.write.ts does after its
|
|
141
141
|
// password check — the challenge token only proves "password was
|
|
@@ -168,7 +168,12 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
|
|
|
168
168
|
// read-time backstop against a rebuild-resurrected role.
|
|
169
169
|
const mergedRoles = buildSessionRoles(globalRoles, membership.roles);
|
|
170
170
|
|
|
171
|
-
const baseSession: SessionUser = {
|
|
171
|
+
const baseSession: SessionUser = {
|
|
172
|
+
id: userId,
|
|
173
|
+
tenantId,
|
|
174
|
+
roles: mergedRoles,
|
|
175
|
+
...sessionTimezoneField(userRow?.timezone),
|
|
176
|
+
};
|
|
172
177
|
const claims = await ctx.resolveAuthClaims(baseSession);
|
|
173
178
|
const session: SessionUser =
|
|
174
179
|
Object.keys(claims).length > 0 ? { ...baseSession, claims } : baseSession;
|
|
@@ -88,8 +88,13 @@ describe("buildHandlerContext ctx.tz resolution", () => {
|
|
|
88
88
|
});
|
|
89
89
|
|
|
90
90
|
test("ctx.tz.user reads SessionUser.timezone independently of tenant", async () => {
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
const admin = createTestUser({ id: 13, roles: ["Admin"] });
|
|
92
|
+
await stack.http.writeOk(
|
|
93
|
+
"config:write:set",
|
|
94
|
+
{ key: "tenant:config:timezone", value: "Europe/Berlin" },
|
|
95
|
+
admin,
|
|
96
|
+
);
|
|
97
|
+
|
|
93
98
|
const user = createTestUser({ id: 12, timezone: "Asia/Tokyo" });
|
|
94
99
|
const res = await stack.http.writeOk<{ tenant: string; user: string }>(
|
|
95
100
|
"probe:write:read-tz",
|
|
@@ -1,39 +1,22 @@
|
|
|
1
1
|
// Catalog + trigger hardening for #1602 — manual-only surface.
|
|
2
|
+
// Real HTTP via setupTestStack — no mocks, mirrors jobs-security.integration.test.ts.
|
|
2
3
|
|
|
3
4
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
4
|
-
import {
|
|
5
|
-
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
5
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
6
|
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
type SessionUser,
|
|
10
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
-
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
12
|
-
import { createJobRunner, type JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
13
|
-
import {
|
|
14
|
-
createTestDb,
|
|
15
|
-
createTestRedis,
|
|
16
|
-
type TestDb,
|
|
17
|
-
type TestRedis,
|
|
7
|
+
setupTestStack,
|
|
8
|
+
type TestStack,
|
|
18
9
|
TestUsers,
|
|
19
10
|
unsafePushTables,
|
|
20
11
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
21
|
-
import type { Hono } from "hono";
|
|
22
12
|
import { z } from "zod";
|
|
23
13
|
import { JobErrors, JobHandlers, JobQueries } from "../constants";
|
|
24
14
|
import { createJobsFeature } from "../feature";
|
|
25
|
-
import { createJobRunLogger } from "../job-run-logger";
|
|
26
15
|
import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
|
|
27
16
|
|
|
28
|
-
let
|
|
29
|
-
let testRedis: TestRedis;
|
|
30
|
-
let db: DbConnection;
|
|
31
|
-
let app: Hono;
|
|
32
|
-
let jwt: JwtHelper;
|
|
33
|
-
let jobRunner: JobRunner;
|
|
17
|
+
let stack: TestStack;
|
|
34
18
|
|
|
35
19
|
const systemAdmin = TestUsers.systemAdmin;
|
|
36
|
-
const JWT_SECRET = "test-jwt-secret-for-jobs-catalog-32chars!!";
|
|
37
20
|
|
|
38
21
|
const appFeature = defineFeature("catalog-app", (r) => {
|
|
39
22
|
r.job(
|
|
@@ -45,115 +28,70 @@ const appFeature = defineFeature("catalog-app", (r) => {
|
|
|
45
28
|
});
|
|
46
29
|
|
|
47
30
|
beforeAll(async () => {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const registry = createRegistry([appFeature, createJobsFeature()]);
|
|
53
|
-
await unsafePushTables(db, { jobRunsTable, jobRunLogsTable });
|
|
54
|
-
await createEventsTable(db);
|
|
55
|
-
|
|
56
|
-
const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
57
|
-
const logger = createJobRunLogger({ db, registry });
|
|
58
|
-
jobRunner = createJobRunner({
|
|
59
|
-
registry,
|
|
60
|
-
context: { db },
|
|
61
|
-
redisUrl,
|
|
62
|
-
consumerLane: "worker",
|
|
63
|
-
queueNamePrefix: `kumiko-jobs-catalog-test-${Date.now()}`,
|
|
64
|
-
...logger,
|
|
31
|
+
stack = await setupTestStack({
|
|
32
|
+
features: [appFeature, createJobsFeature()],
|
|
33
|
+
jobs: { consumerLane: "worker", queueNamePrefix: `kumiko-jobs-catalog-test-${Date.now()}` },
|
|
65
34
|
});
|
|
66
|
-
|
|
67
|
-
const server = buildServer({ registry, context, jwtSecret: JWT_SECRET });
|
|
68
|
-
app = server.app;
|
|
69
|
-
jwt = server.jwt;
|
|
70
|
-
|
|
71
|
-
await jobRunner.start();
|
|
35
|
+
await unsafePushTables(stack.db, { jobRunsTable, jobRunLogsTable });
|
|
72
36
|
});
|
|
73
37
|
|
|
74
38
|
afterAll(async () => {
|
|
75
|
-
await
|
|
76
|
-
await testDb.cleanup();
|
|
77
|
-
await testRedis.cleanup();
|
|
39
|
+
await stack.cleanup();
|
|
78
40
|
});
|
|
79
41
|
|
|
80
|
-
async function req(
|
|
81
|
-
method: string,
|
|
82
|
-
path: string,
|
|
83
|
-
user: SessionUser,
|
|
84
|
-
body?: unknown,
|
|
85
|
-
): Promise<Response> {
|
|
86
|
-
const token = await jwt.sign(user);
|
|
87
|
-
const init: RequestInit = {
|
|
88
|
-
method,
|
|
89
|
-
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
90
|
-
};
|
|
91
|
-
if (body) init.body = JSON.stringify(body);
|
|
92
|
-
return app.request(path, init);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async function write(user: SessionUser, type: string, payload: unknown) {
|
|
96
|
-
const res = await req("POST", "/api/write", user, { type, payload });
|
|
97
|
-
return res.json();
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async function query(user: SessionUser, type: string, payload: unknown) {
|
|
101
|
-
const res = await req("POST", "/api/query", user, { type, payload });
|
|
102
|
-
const body = await res.json();
|
|
103
|
-
if (res.status !== 200) {
|
|
104
|
-
throw new Error(`query ${type} → ${res.status}: ${JSON.stringify(body)}`);
|
|
105
|
-
}
|
|
106
|
-
return body;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
42
|
describe("jobs:query:catalog", () => {
|
|
110
43
|
test("lists only manual jobs including framework builtins", async () => {
|
|
111
|
-
const result = await query(systemAdmin, JobQueries.catalog, {});
|
|
112
44
|
type CatalogRow = {
|
|
113
45
|
readonly jobName: string;
|
|
114
46
|
readonly perTenant: boolean;
|
|
115
47
|
readonly payloadSchema: Record<string, unknown> | null;
|
|
116
48
|
};
|
|
117
|
-
const
|
|
118
|
-
|
|
49
|
+
const result = await stack.http.queryOk<{ rows: readonly CatalogRow[] }>(
|
|
50
|
+
JobQueries.catalog,
|
|
51
|
+
{},
|
|
52
|
+
systemAdmin,
|
|
53
|
+
);
|
|
54
|
+
const names = result.rows.map((r) => r.jobName);
|
|
119
55
|
expect(names).toContain("catalog-app:job:manual-echo");
|
|
120
56
|
expect(names).toContain("jobs:job:reindex-entity");
|
|
121
57
|
expect(names).toContain("jobs:job:projection-rebuild");
|
|
122
58
|
expect(names).not.toContain("catalog-app:job:cron-only");
|
|
123
59
|
|
|
124
|
-
const echo = rows.find((r) => r.jobName === "catalog-app:job:manual-echo");
|
|
60
|
+
const echo = result.rows.find((r) => r.jobName === "catalog-app:job:manual-echo");
|
|
125
61
|
expect(echo).toBeDefined();
|
|
126
62
|
expect(echo?.payloadSchema).not.toBeNull();
|
|
127
63
|
|
|
128
|
-
const reindex = rows.find((r) => r.jobName === "jobs:job:reindex-entity");
|
|
64
|
+
const reindex = result.rows.find((r) => r.jobName === "jobs:job:reindex-entity");
|
|
129
65
|
expect(reindex?.perTenant).toBe(true);
|
|
130
66
|
});
|
|
131
67
|
});
|
|
132
68
|
|
|
133
69
|
describe("jobs:write:trigger hardening", () => {
|
|
134
70
|
test("rejects cron-only jobs", async () => {
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
expect(
|
|
71
|
+
const err = await stack.http.writeErr(
|
|
72
|
+
JobHandlers.trigger,
|
|
73
|
+
{ jobName: "catalog-app:job:cron-only" },
|
|
74
|
+
systemAdmin,
|
|
75
|
+
);
|
|
76
|
+
expect(err.code).toBe("unprocessable");
|
|
77
|
+
expect(err.details).toMatchObject({ reason: JobErrors.notManual });
|
|
141
78
|
});
|
|
142
79
|
|
|
143
80
|
test("rejects invalid payload against job schema", async () => {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
payload: {},
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
expect(
|
|
81
|
+
const err = await stack.http.writeErr(
|
|
82
|
+
JobHandlers.trigger,
|
|
83
|
+
{ jobName: "catalog-app:job:manual-echo", payload: {} },
|
|
84
|
+
systemAdmin,
|
|
85
|
+
);
|
|
86
|
+
expect(err.code).toBe("validation_error");
|
|
150
87
|
});
|
|
151
88
|
|
|
152
89
|
test("accepts valid schema payload", async () => {
|
|
153
|
-
const result = await
|
|
154
|
-
|
|
155
|
-
payload: { entity: "credit" },
|
|
156
|
-
|
|
157
|
-
|
|
90
|
+
const result = await stack.http.writeOk<{ jobName: string; bullJobId: string }>(
|
|
91
|
+
JobHandlers.trigger,
|
|
92
|
+
{ jobName: "catalog-app:job:manual-echo", payload: { entity: "credit" } },
|
|
93
|
+
systemAdmin,
|
|
94
|
+
);
|
|
95
|
+
expect(result.jobName).toBe("catalog-app:job:manual-echo");
|
|
158
96
|
});
|
|
159
97
|
});
|
|
@@ -86,6 +86,16 @@ export function JobRunsScreen(): ReactNode {
|
|
|
86
86
|
? JSON.stringify(selected.payloadSchema, null, 2)
|
|
87
87
|
: null;
|
|
88
88
|
|
|
89
|
+
// Switching jobs invalidates any typed payload/messages against the new
|
|
90
|
+
// job's schema — reset so a submit can't validate stale payload text
|
|
91
|
+
// against the wrong job.
|
|
92
|
+
const handleJobNameChange = (name: string): void => {
|
|
93
|
+
setJobName(name);
|
|
94
|
+
setPayloadText("{}");
|
|
95
|
+
setClientError(null);
|
|
96
|
+
setSuccessMessage(null);
|
|
97
|
+
};
|
|
98
|
+
|
|
89
99
|
const onTrigger = async (): Promise<void> => {
|
|
90
100
|
setClientError(null);
|
|
91
101
|
setSuccessMessage(null);
|
|
@@ -168,7 +178,7 @@ export function JobRunsScreen(): ReactNode {
|
|
|
168
178
|
id="job-trigger-name"
|
|
169
179
|
name="job-trigger-name"
|
|
170
180
|
value={jobName}
|
|
171
|
-
onChange={
|
|
181
|
+
onChange={handleJobNameChange}
|
|
172
182
|
options={jobOptions}
|
|
173
183
|
/>
|
|
174
184
|
</Field>
|
package/src/shared/index.ts
CHANGED
|
@@ -16,5 +16,6 @@ export { isWithinGracePeriod } from "./grace-period";
|
|
|
16
16
|
export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
|
|
17
17
|
export { mapWithConcurrency } from "./map-with-concurrency";
|
|
18
18
|
export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
|
|
19
|
+
export { sessionTimezoneField } from "./session-timezone-field";
|
|
19
20
|
export type { SystemQueryFn } from "./system-query";
|
|
20
21
|
export { type BurnResult, burnToken, unburnToken } from "./token-burn-store";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
|
|
3
|
+
export function sessionTimezoneField(
|
|
4
|
+
timezone: string | null | undefined,
|
|
5
|
+
): Pick<SessionUser, "timezone"> | Record<string, never> {
|
|
6
|
+
return timezone !== null && timezone !== undefined ? { timezone } : {};
|
|
7
|
+
}
|
|
@@ -12,6 +12,7 @@ import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
|
|
|
12
12
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
13
13
|
import type { ReactNode } from "react";
|
|
14
14
|
import { textBlocksClient } from "../client-plugin";
|
|
15
|
+
import { defaultTranslations } from "../i18n";
|
|
15
16
|
|
|
16
17
|
mock.module("@cosmicdrift/kumiko-bundled-features/auth-email-password/web", () => ({
|
|
17
18
|
useShellUser: mock(),
|
|
@@ -50,11 +51,22 @@ function getEditor() {
|
|
|
50
51
|
return Editor;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
const localeResolver = createStaticLocaleResolver();
|
|
54
|
+
const localeResolver = createStaticLocaleResolver({ locale: "de" });
|
|
54
55
|
|
|
55
56
|
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
56
57
|
return (
|
|
57
|
-
<LocaleProvider resolver={localeResolver}>
|
|
58
|
+
<LocaleProvider resolver={localeResolver} fallbackBundles={[defaultTranslations]}>
|
|
59
|
+
<PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
|
|
60
|
+
</LocaleProvider>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function EnglishWrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
65
|
+
return (
|
|
66
|
+
<LocaleProvider
|
|
67
|
+
resolver={createStaticLocaleResolver({ locale: "en" })}
|
|
68
|
+
fallbackBundles={[defaultTranslations]}
|
|
69
|
+
>
|
|
58
70
|
<PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
|
|
59
71
|
</LocaleProvider>
|
|
60
72
|
);
|
|
@@ -115,6 +127,19 @@ describe("TextContentEditor — role-based write-access", () => {
|
|
|
115
127
|
});
|
|
116
128
|
});
|
|
117
129
|
|
|
130
|
+
describe("TextContentEditor — en locale", () => {
|
|
131
|
+
test("TenantAdmin sieht englische Labels statt hartcodiertem Deutsch (#1754)", () => {
|
|
132
|
+
// biome-ignore lint/suspicious/noExplicitAny: Bun mock function
|
|
133
|
+
(useShellUser as any).mockReturnValue({ id: "u1", roles: ["TenantAdmin"] });
|
|
134
|
+
const Editor = getEditor();
|
|
135
|
+
render(<Editor target={TARGET} onClose={() => {}} />, { wrapper: EnglishWrapper });
|
|
136
|
+
|
|
137
|
+
expect(screen.getByRole("button", { name: /^save$/i })).toBeTruthy();
|
|
138
|
+
expect(screen.getByLabelText(/title/i)).toBeTruthy();
|
|
139
|
+
expect(screen.getByLabelText(/content/i)).toBeTruthy();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
118
143
|
describe("TextContentEditor — handleSave", () => {
|
|
119
144
|
test("reicht das geladene folder unverändert an den Write-Payload durch (#898)", async () => {
|
|
120
145
|
// biome-ignore lint/suspicious/noExplicitAny: Bun mock function
|
|
@@ -17,10 +17,16 @@ import type {
|
|
|
17
17
|
TreeChildrenSubscribe,
|
|
18
18
|
TreeNode,
|
|
19
19
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
useDispatcher,
|
|
22
|
+
usePrimitives,
|
|
23
|
+
useQuery,
|
|
24
|
+
useTranslation,
|
|
25
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
21
26
|
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
22
27
|
import { type FormEvent, type ReactNode, useEffect, useState } from "react";
|
|
23
28
|
import { TemplateResolverHandlers, TemplateResolverQueries } from "../qualified-names";
|
|
29
|
+
import { defaultTranslations } from "./i18n";
|
|
24
30
|
|
|
25
31
|
// Exported for the unit test — groupBlocksByFolder is a pure function.
|
|
26
32
|
export type BlockSummary = {
|
|
@@ -211,6 +217,7 @@ function TextBlockEditor({
|
|
|
211
217
|
const { Form, Field, Input, Button, Banner } = usePrimitives();
|
|
212
218
|
const dispatcher = useDispatcher();
|
|
213
219
|
const user = useShellUser();
|
|
220
|
+
const t = useTranslation();
|
|
214
221
|
const canWrite =
|
|
215
222
|
user?.roles.includes("TenantAdmin") === true || user?.roles.includes("SystemAdmin") === true;
|
|
216
223
|
|
|
@@ -254,14 +261,20 @@ function TextBlockEditor({
|
|
|
254
261
|
...(tenantIdOverride !== undefined && { tenantIdOverride }),
|
|
255
262
|
});
|
|
256
263
|
if (result.isSuccess) {
|
|
257
|
-
setSavedMsg(
|
|
264
|
+
setSavedMsg(
|
|
265
|
+
result.data.isNew
|
|
266
|
+
? t("template-resolver.editor.created")
|
|
267
|
+
: t("template-resolver.editor.saved"),
|
|
268
|
+
);
|
|
258
269
|
return;
|
|
259
270
|
}
|
|
260
|
-
setSaveError(
|
|
271
|
+
setSaveError(
|
|
272
|
+
result.error.message ?? result.error.code ?? t("template-resolver.editor.saveFailed"),
|
|
273
|
+
);
|
|
261
274
|
} catch (e) {
|
|
262
275
|
// Network blip / dispatcher throw — otherwise submitting stays true and
|
|
263
276
|
// the save button is locked forever with no feedback.
|
|
264
|
-
setSaveError(e instanceof Error ? e.message : "
|
|
277
|
+
setSaveError(e instanceof Error ? e.message : t("template-resolver.editor.networkError"));
|
|
265
278
|
} finally {
|
|
266
279
|
setSubmitting(false);
|
|
267
280
|
}
|
|
@@ -283,21 +296,21 @@ function TextBlockEditor({
|
|
|
283
296
|
actions={
|
|
284
297
|
canWrite ? (
|
|
285
298
|
<Button type="submit" loading={submitting} disabled={disabled}>
|
|
286
|
-
{submitting ? "
|
|
299
|
+
{submitting ? t("template-resolver.editor.saving") : t("template-resolver.editor.save")}
|
|
287
300
|
</Button>
|
|
288
301
|
) : undefined
|
|
289
302
|
}
|
|
290
303
|
>
|
|
291
|
-
{loading && <Banner variant="loading">
|
|
304
|
+
{loading && <Banner variant="loading">{t("template-resolver.editor.loading")}</Banner>}
|
|
292
305
|
{loadError !== null && (
|
|
293
|
-
<Banner variant="error">
|
|
306
|
+
<Banner variant="error">
|
|
307
|
+
{t("template-resolver.editor.loadFailed")}: {loadError.code}
|
|
308
|
+
</Banner>
|
|
294
309
|
)}
|
|
295
310
|
{!canWrite && !loading && (
|
|
296
|
-
<Banner variant="info">
|
|
297
|
-
Read-only — TenantAdmin- oder SystemAdmin-Rolle für Änderungen erforderlich.
|
|
298
|
-
</Banner>
|
|
311
|
+
<Banner variant="info">{t("template-resolver.editor.readOnly")}</Banner>
|
|
299
312
|
)}
|
|
300
|
-
<Field id="text-block-title" label="
|
|
313
|
+
<Field id="text-block-title" label={t("template-resolver.editor.titleLabel")} required>
|
|
301
314
|
<Input
|
|
302
315
|
kind="text"
|
|
303
316
|
id="text-block-title"
|
|
@@ -308,7 +321,7 @@ function TextBlockEditor({
|
|
|
308
321
|
required
|
|
309
322
|
/>
|
|
310
323
|
</Field>
|
|
311
|
-
<Field id="text-block-content" label="
|
|
324
|
+
<Field id="text-block-content" label={t("template-resolver.editor.contentLabel")}>
|
|
312
325
|
<Input
|
|
313
326
|
kind="textarea"
|
|
314
327
|
id="text-block-content"
|
|
@@ -348,5 +361,6 @@ export function textBlocksClient(opts?: {
|
|
|
348
361
|
resolvers: {
|
|
349
362
|
"template-resolver:edit": TextBlockEditor,
|
|
350
363
|
},
|
|
364
|
+
translations: defaultTranslations,
|
|
351
365
|
};
|
|
352
366
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Default translation bundle for the text-block editor. textBlocksClient()
|
|
3
|
+
// hangs it into the LocaleProvider as a fallback bundle — apps override
|
|
4
|
+
// individual keys via mergeTranslations at the createKumikoApp level.
|
|
5
|
+
//
|
|
6
|
+
// Keys follow `template-resolver.editor.<slug>`.
|
|
7
|
+
|
|
8
|
+
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
9
|
+
|
|
10
|
+
export const defaultTranslations: TranslationsByLocale = {
|
|
11
|
+
de: {
|
|
12
|
+
"template-resolver.editor.titleLabel": "Titel",
|
|
13
|
+
"template-resolver.editor.contentLabel": "Inhalt",
|
|
14
|
+
"template-resolver.editor.save": "Speichern",
|
|
15
|
+
"template-resolver.editor.saving": "Speichern…",
|
|
16
|
+
"template-resolver.editor.created": "Neu angelegt.",
|
|
17
|
+
"template-resolver.editor.saved": "Gespeichert.",
|
|
18
|
+
"template-resolver.editor.saveFailed": "Speichern fehlgeschlagen.",
|
|
19
|
+
"template-resolver.editor.networkError": "Netzwerkfehler beim Speichern.",
|
|
20
|
+
"template-resolver.editor.loading": "Lädt aktuellen Stand…",
|
|
21
|
+
"template-resolver.editor.loadFailed": "Konnte Block nicht laden",
|
|
22
|
+
"template-resolver.editor.readOnly":
|
|
23
|
+
"Read-only — TenantAdmin- oder SystemAdmin-Rolle für Änderungen erforderlich.",
|
|
24
|
+
},
|
|
25
|
+
en: {
|
|
26
|
+
"template-resolver.editor.titleLabel": "Title",
|
|
27
|
+
"template-resolver.editor.contentLabel": "Content",
|
|
28
|
+
"template-resolver.editor.save": "Save",
|
|
29
|
+
"template-resolver.editor.saving": "Saving…",
|
|
30
|
+
"template-resolver.editor.created": "Created.",
|
|
31
|
+
"template-resolver.editor.saved": "Saved.",
|
|
32
|
+
"template-resolver.editor.saveFailed": "Save failed.",
|
|
33
|
+
"template-resolver.editor.networkError": "Network error while saving.",
|
|
34
|
+
"template-resolver.editor.loading": "Loading current version…",
|
|
35
|
+
"template-resolver.editor.loadFailed": "Could not load block",
|
|
36
|
+
"template-resolver.editor.readOnly":
|
|
37
|
+
"Read-only — TenantAdmin or SystemAdmin role required to make changes.",
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Regression guard for fw#1648: dispatch-shared.ts hardcodes the literal
|
|
2
|
+
// "tenant:config:timezone" (TENANT_TIMEZONE_CONFIG_KEY) since framework/pipeline
|
|
3
|
+
// can't import the bundled `tenant` feature. tz-resolution.integration.test.ts
|
|
4
|
+
// exercises that literal against a standalone probe feature, which can't see a
|
|
5
|
+
// drift to the REAL tenant feature's key name — this test boots the actual
|
|
6
|
+
// createTenantFeature() instead.
|
|
7
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
8
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import {
|
|
10
|
+
createTestUser,
|
|
11
|
+
setupTestStack,
|
|
12
|
+
type TestStack,
|
|
13
|
+
unsafePushTables,
|
|
14
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import { createConfigAccessorFactory, createConfigFeature } from "../../config/feature";
|
|
17
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
18
|
+
import { configValuesTable } from "../../config/table";
|
|
19
|
+
import { createTenantFeature } from "../feature";
|
|
20
|
+
|
|
21
|
+
const probeFeature = defineFeature("tz-probe", (r) => {
|
|
22
|
+
r.requires("tenant");
|
|
23
|
+
r.writeHandler(
|
|
24
|
+
"read-tz",
|
|
25
|
+
z.object({}),
|
|
26
|
+
async (_event, ctx) => ({ isSuccess: true, data: { tenant: ctx.tz.tenant } }),
|
|
27
|
+
{ access: { openToAll: true } },
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("ctx.tz.tenant against the real tenant feature (fw#1648)", () => {
|
|
32
|
+
let stack: TestStack;
|
|
33
|
+
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
const resolver = createConfigResolver();
|
|
36
|
+
stack = await setupTestStack({
|
|
37
|
+
features: [createConfigFeature(), createTenantFeature(), probeFeature],
|
|
38
|
+
extraContext: ({ registry }) => ({
|
|
39
|
+
configResolver: resolver,
|
|
40
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
43
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterAll(async () => {
|
|
47
|
+
await stack.cleanup();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("ctx.tz.tenant reflects tenant:config:timezone set via the real tenant feature", async () => {
|
|
51
|
+
const admin = createTestUser({ id: 20, roles: ["Admin"] });
|
|
52
|
+
await stack.http.writeOk(
|
|
53
|
+
"config:write:set",
|
|
54
|
+
{ key: "tenant:config:timezone", value: "Asia/Tokyo" },
|
|
55
|
+
admin,
|
|
56
|
+
);
|
|
57
|
+
const res = await stack.http.writeOk<{ tenant: string }>("tz-probe:write:read-tz", {}, admin);
|
|
58
|
+
expect(res.tenant).toBe("Asia/Tokyo");
|
|
59
|
+
});
|
|
60
|
+
});
|