@cosmicdrift/kumiko-testing 0.343.0 → 0.345.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-testing",
3
- "version": "0.343.0",
3
+ "version": "0.345.0",
4
4
  "description": "Test template for Kumiko apps: per-flow seedTenant, app test stack, canonical test preloads, bunfig generator and integration runner.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -75,9 +75,9 @@
75
75
  "kumiko-testing": "./bin/kumiko-testing.ts"
76
76
  },
77
77
  "dependencies": {
78
- "@cosmicdrift/kumiko-bundled-features": "0.343.0",
79
- "@cosmicdrift/kumiko-dev-server": "0.343.0",
80
- "@cosmicdrift/kumiko-framework": "0.343.0",
78
+ "@cosmicdrift/kumiko-bundled-features": "0.345.0",
79
+ "@cosmicdrift/kumiko-dev-server": "0.345.0",
80
+ "@cosmicdrift/kumiko-framework": "0.345.0",
81
81
  "zod": "^4.4.3"
82
82
  },
83
83
  "peerDependencies": {
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.345.0",
4
+ "type": "improvement",
5
+ "title": "e2e kit seeds an MFA factor and answers the MFA challenge on login",
6
+ "detail": "The e2e kit now works against apps that enforce MFA. `seedTenant({ mfa: \"totp\" })` enrolls the seeded admin with a confirmed TOTP factor through the real auth-mfa endpoints and returns the base32 secret as `admin.mfaTotpSecret`. `loginViaApi` and `loginViaUi` answer the MFA challenge with that secret when the credentials carry `mfaTotpSecret`; without it they behave as before for accounts that need no MFA. `seedTenant()` also enrolls the admin when the app policy demands MFA (the login answers with a required setup), without the option; the option forces enrollment when no policy does. `enrollTotpViaApi(request, credentials)` is exported for enrolling other users, and `addUser(roles, { mfa: \"totp\" })` enrolls a seeded user (returned as `mfaTotpSecret`) on a separate request context, so the admin session stays logged in. `loginViaApi` now throws when the account answers with an MFA challenge or a required MFA setup but the credentials carry no `mfaTotpSecret`, instead of returning without a session. It also throws when the credentials carry a secret but the server answers with a required MFA setup, since the account then has no enrolled factor. Repeated logins in one 30 s window use the next valid TOTP step, because the server burns an accepted code."
7
+ },
2
8
  {
3
9
  "version": "0.339.0",
4
10
  "type": "fix",
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { AuthMfaHandlers, base32Decode } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
2
3
  import { currentTotpCode } from "@cosmicdrift/kumiko-bundled-features/auth-mfa/testing";
3
4
  import type { WriteErrorInfo } from "@cosmicdrift/kumiko-framework/errors";
4
5
  import { type APIRequestContext, type APIResponse, expect, type Page } from "@playwright/test";
@@ -6,7 +7,12 @@ import * as z from "zod";
6
7
  import type { BoundApi } from "../seed-types";
7
8
  import { CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from "./constants";
8
9
 
9
- export type LoginCredentials = { readonly email: string; readonly password: string };
10
+ export type LoginCredentials = {
11
+ readonly email: string;
12
+ readonly password: string;
13
+ // Base32 TOTP secret of an enrolled account; answers the MFA challenge after the password step.
14
+ readonly mfaTotpSecret?: string;
15
+ };
10
16
 
11
17
  type ApiEndpoint = "/api/write" | "/api/query" | "/api/command";
12
18
 
@@ -17,6 +23,15 @@ const successEnvelope = z.looseObject({
17
23
  data: z.unknown().optional(),
18
24
  error: z.unknown().optional(),
19
25
  });
26
+ const loginReplySchema = z.looseObject({
27
+ mfaRequired: z.boolean().optional(),
28
+ challengeToken: z.string().optional(),
29
+ mfaSetupRequired: z.boolean().optional(),
30
+ preauthSetupToken: z.string().optional(),
31
+ setupToken: z.string().optional(),
32
+ otpauthUri: z.string().optional(),
33
+ totpSecret: z.string().optional(),
34
+ });
20
35
  const errorEnvelope = z.looseObject({ error: z.looseObject({ code: z.string() }) });
21
36
 
22
37
  export function csrfHeaderFromCookies(
@@ -45,19 +60,178 @@ export function syntheticClientIpFor(key: string): string {
45
60
 
46
61
  export const CLIENT_IP_HEADER = "x-forwarded-for";
47
62
 
63
+ async function postAuth(
64
+ request: APIRequestContext,
65
+ path: string,
66
+ data: Record<string, unknown>,
67
+ email: string,
68
+ ): Promise<APIResponse> {
69
+ const response = await request.post(path, {
70
+ data,
71
+ headers: { [CLIENT_IP_HEADER]: syntheticClientIpFor(email) },
72
+ });
73
+ if (!response.ok()) {
74
+ throw new Error(
75
+ `loginViaApi(${email}): POST ${path} -> ${response.status()} ${await response.text()}`,
76
+ );
77
+ }
78
+ return response;
79
+ }
80
+
81
+ async function postAuthJson(
82
+ request: APIRequestContext,
83
+ path: string,
84
+ data: Record<string, unknown>,
85
+ email: string,
86
+ ): Promise<z.infer<typeof loginReplySchema>> {
87
+ return loginReplySchema.parse(await (await postAuth(request, path, data, email)).json());
88
+ }
89
+
90
+ function assertNoUnansweredMfa(email: string, reply: z.infer<typeof loginReplySchema>): void {
91
+ if (reply.mfaRequired === true || reply.mfaSetupRequired === true) {
92
+ throw new Error(
93
+ `loginViaApi(${email}): the account needs MFA but no mfaTotpSecret was given; ` +
94
+ `use seedTenant({ mfa: "totp" }) or addUser(roles, { mfa: "totp" })`,
95
+ );
96
+ }
97
+ }
98
+
48
99
  export async function loginViaApi(
49
100
  request: APIRequestContext,
50
101
  credentials: LoginCredentials,
51
102
  ): Promise<void> {
52
- const response = await request.post("/api/auth/login", {
53
- data: { email: credentials.email, password: credentials.password },
54
- headers: { [CLIENT_IP_HEADER]: syntheticClientIpFor(credentials.email) },
55
- });
56
- if (!response.ok()) {
103
+ const response = await postAuth(
104
+ request,
105
+ "/api/auth/login",
106
+ { email: credentials.email, password: credentials.password },
107
+ credentials.email,
108
+ );
109
+ const reply = loginReplySchema.parse(await response.json());
110
+ if (credentials.mfaTotpSecret === undefined) {
111
+ assertNoUnansweredMfa(credentials.email, reply);
112
+ // skip: no secret, assertNoUnansweredMfa already threw if MFA was pending
113
+ return;
114
+ }
115
+ if (reply.mfaSetupRequired === true) {
57
116
  throw new Error(
58
- `loginViaApi(${credentials.email}): POST /api/auth/login -> ${response.status()} ${await response.text()}`,
117
+ `loginViaApi(${credentials.email}): an mfaTotpSecret was given but the account has no enrolled factor; ` +
118
+ `enroll it with enrollTotpViaApi first`,
59
119
  );
60
120
  }
121
+ // skip: no MFA step for this account, the login already set the session
122
+ if (reply.mfaRequired !== true) return;
123
+ if (reply.challengeToken === undefined) {
124
+ throw new Error(`loginViaApi(${credentials.email}): MFA required but no challengeToken`);
125
+ }
126
+ await postAuth(
127
+ request,
128
+ "/api/auth/mfa/verify",
129
+ {
130
+ challengeToken: reply.challengeToken,
131
+ code: await unburnedTotpCode(credentials.mfaTotpSecret),
132
+ },
133
+ credentials.email,
134
+ );
135
+ }
136
+
137
+ const TOTP_STEP_MS = 30_000;
138
+ const lastLoginCounterBySecret = new Map<string, number>();
139
+
140
+ // The server burns an accepted login code for its whole ±1-step window, so a
141
+ // second login within the same step needs the next counter. Only counters up
142
+ // to one step ahead are valid; beyond that wait until the clock is in range.
143
+ async function unburnedTotpCode(secret: string): Promise<string> {
144
+ const currentCounter = Math.floor(Date.now() / TOTP_STEP_MS);
145
+ const counter = Math.max(currentCounter, (lastLoginCounterBySecret.get(secret) ?? -1) + 1);
146
+ lastLoginCounterBySecret.set(secret, counter);
147
+ const validFromMs = (counter - 1) * TOTP_STEP_MS;
148
+ const waitMs = validFromMs - Date.now();
149
+ if (waitMs > 0) await new Promise((resolve) => setTimeout(resolve, waitMs));
150
+ return totpCode(secret, counter * TOTP_STEP_MS);
151
+ }
152
+
153
+ function totpSecretFromOtpauthUri(otpauthUri: string): string {
154
+ const secret = new URL(otpauthUri).searchParams.get("secret");
155
+ if (secret === null) throw new Error("otpauth URI carries no secret");
156
+ return secret;
157
+ }
158
+
159
+ async function enrollPreauth(
160
+ request: APIRequestContext,
161
+ email: string,
162
+ preauthSetupToken: string,
163
+ ): Promise<string> {
164
+ const started = await postAuthJson(
165
+ request,
166
+ "/api/auth/mfa/preauth-enable-start",
167
+ { preauthSetupToken, accountLabel: email },
168
+ email,
169
+ );
170
+ if (started.setupToken === undefined || started.otpauthUri === undefined) {
171
+ throw new Error(`enrollTotpViaApi(${email}): preauth-enable-start returned no setup`);
172
+ }
173
+ const secret = totpSecretFromOtpauthUri(started.otpauthUri);
174
+ await postAuthJson(
175
+ request,
176
+ "/api/auth/mfa/preauth-confirm",
177
+ { setupToken: started.setupToken, code: await totpCode(secret) },
178
+ email,
179
+ );
180
+ return secret;
181
+ }
182
+
183
+ /**
184
+ * Logs in; when the app's MFA policy answers with a required setup, enrolls a TOTP
185
+ * factor on the spot. Returns the secret in that case, undefined for a plain login.
186
+ */
187
+ export async function loginEnrollingIfRequired(
188
+ request: APIRequestContext,
189
+ credentials: Pick<LoginCredentials, "email" | "password">,
190
+ ): Promise<string | undefined> {
191
+ const reply = await postAuthJson(
192
+ request,
193
+ "/api/auth/login",
194
+ { email: credentials.email, password: credentials.password },
195
+ credentials.email,
196
+ );
197
+ if (reply.mfaSetupRequired === true && reply.preauthSetupToken !== undefined) {
198
+ return enrollPreauth(request, credentials.email, reply.preauthSetupToken);
199
+ }
200
+ assertNoUnansweredMfa(credentials.email, reply);
201
+ return undefined;
202
+ }
203
+
204
+ /**
205
+ * Enrolls a confirmed TOTP factor through the real auth-mfa endpoints and
206
+ * returns its base32 secret. The context ends up logged in. Works under an
207
+ * MFA-required policy (pre-auth enrollment) and without one (session enrollment).
208
+ */
209
+ export async function enrollTotpViaApi(
210
+ request: APIRequestContext,
211
+ credentials: Pick<LoginCredentials, "email" | "password">,
212
+ ): Promise<string> {
213
+ const reply = await postAuthJson(
214
+ request,
215
+ "/api/auth/login",
216
+ { email: credentials.email, password: credentials.password },
217
+ credentials.email,
218
+ );
219
+ if (reply.mfaRequired === true) {
220
+ throw new Error(`enrollTotpViaApi(${credentials.email}): account already has an MFA factor`);
221
+ }
222
+ if (reply.mfaSetupRequired === true && reply.preauthSetupToken !== undefined) {
223
+ return enrollPreauth(request, credentials.email, reply.preauthSetupToken);
224
+ }
225
+ const started = await apiWrite<{ setupToken: string; totpSecret: string }>(
226
+ request,
227
+ AuthMfaHandlers.enableStart,
228
+ {},
229
+ );
230
+ await apiWrite(request, AuthMfaHandlers.enableConfirm, {
231
+ setupToken: started.setupToken,
232
+ code: await totpCode(started.totpSecret),
233
+ });
234
+ return started.totpSecret;
61
235
  }
62
236
 
63
237
  // Leaves the app page first: an app page still open while its cookies vanish redirects itself
@@ -75,6 +249,15 @@ export async function loginViaUi(page: Page, credentials: LoginCredentials): Pro
75
249
  await page.locator("#login-password").fill(credentials.password);
76
250
  await page.locator("#login-password").press("Enter");
77
251
  await expect(page.locator("#login-password")).toHaveCount(0);
252
+ // skip: no secret, so no MFA step expected
253
+ if (credentials.mfaTotpSecret === undefined) return;
254
+ // A secret means an enrolled factor, so the MFA step must come; waiting for it
255
+ // avoids racing the transition from the password form.
256
+ const codeField = page.locator("#mfa-verify-code");
257
+ await expect(codeField).toBeVisible();
258
+ await codeField.fill(await unburnedTotpCode(credentials.mfaTotpSecret));
259
+ await codeField.press("Enter");
260
+ await expect(codeField).toHaveCount(0);
78
261
  }
79
262
 
80
263
  async function postApi(
@@ -162,9 +345,6 @@ export function createHttpApi(request: APIRequestContext): BoundApi {
162
345
  }
163
346
 
164
347
  export async function totpCode(secret: Buffer | string, nowMs?: number): Promise<string> {
165
- const key =
166
- typeof secret === "string"
167
- ? (await import("@cosmicdrift/kumiko-bundled-features/auth-mfa")).base32Decode(secret)
168
- : secret;
348
+ const key = typeof secret === "string" ? base32Decode(secret) : secret;
169
349
  return currentTotpCode(key, nowMs);
170
350
  }
package/src/e2e/index.ts CHANGED
@@ -12,6 +12,7 @@ export {
12
12
  createHttpApi,
13
13
  csrfFetch,
14
14
  csrfHeaderFromCookies,
15
+ enrollTotpViaApi,
15
16
  type LoginCredentials,
16
17
  loginViaApi,
17
18
  loginViaUi,
@@ -13,6 +13,7 @@ import {
13
13
  import type * as z from "zod";
14
14
  import {
15
15
  type BoundApi,
16
+ type SeedAdminIdentity,
16
17
  type SeededCredentials,
17
18
  type SeededTenant,
18
19
  type SeededUser,
@@ -23,6 +24,8 @@ import {
23
24
  CLIENT_IP_HEADER,
24
25
  clearSession,
25
26
  createHttpApi,
27
+ enrollTotpViaApi,
28
+ loginEnrollingIfRequired,
26
29
  loginViaApi,
27
30
  syntheticClientIpFor,
28
31
  } from "./auth-kit";
@@ -36,9 +39,22 @@ import {
36
39
  seedUserResponseSchema,
37
40
  } from "./seed-contract";
38
41
 
39
- export type E2eSeedTenantOptions = Omit<SeedTenantOptions, "persist">;
42
+ export type E2eSeedTenantOptions = Omit<SeedTenantOptions, "persist"> & {
43
+ // Enrolls the admin with a confirmed TOTP factor (needed when the app
44
+ // enforces MFA); the secret comes back as `admin.mfaTotpSecret`.
45
+ readonly mfa?: "totp";
46
+ };
47
+
48
+ export type E2eSeedUserIdentity = SeedAdminIdentity & {
49
+ // Enrolls the new user with a confirmed TOTP factor; the secret comes back as `mfaTotpSecret`.
50
+ readonly mfa?: "totp";
51
+ };
40
52
 
41
- export type E2eSeededTenant = SeededTenant & {
53
+ export type E2eSeededTenant = Omit<SeededTenant, "addUser"> & {
54
+ readonly addUser: (
55
+ roles?: readonly string[],
56
+ identity?: E2eSeedUserIdentity,
57
+ ) => Promise<SeededUser>;
42
58
  readonly loginAs: (page: Page, user: SeededUser) => Promise<void>;
43
59
  // Runs an app seeder registered via createE2eSeedRoutes({ extraSeeders })
44
60
  // inside this tenant and resolves to its JSON result, unvalidated.
@@ -137,8 +153,15 @@ export async function provideSeedTenant(
137
153
  const toUser = (credentials: SeededCredentials, roles: readonly string[]): SeededUser =>
138
154
  withSession(credentials, tenantId, roles);
139
155
 
140
- const admin = toUser(seeded.admin, [ROLES.TenantAdmin]);
141
- await loginViaApi(context.request, admin);
156
+ const adminRow = toUser(seeded.admin, [ROLES.TenantAdmin]);
157
+ // Without the option, a policy that demands MFA still gets the admin enrolled
158
+ // (the login answers with a required setup); mfa: "totp" forces it without one.
159
+ const adminSecret =
160
+ opts.mfa === "totp"
161
+ ? await enrollTotpViaApi(context.request, adminRow)
162
+ : await loginEnrollingIfRequired(context.request, adminRow);
163
+ const admin =
164
+ adminSecret === undefined ? adminRow : { ...adminRow, mfaTotpSecret: adminSecret };
142
165
 
143
166
  const tenant: E2eSeededTenant = {
144
167
  id: tenantId,
@@ -146,8 +169,8 @@ export async function provideSeedTenant(
146
169
  name: seeded.name,
147
170
  admin,
148
171
  members: seeded.members.map((member) => toUser(member, [ROLES.Member])),
149
- addUser: async (roles = [ROLES.Member], identity = {}) =>
150
- toUser(
172
+ addUser: async (roles = [ROLES.Member], identity = {}) => {
173
+ const user = toUser(
151
174
  await postSeedRoute(
152
175
  request,
153
176
  SEED_ROUTES.seedUser,
@@ -160,7 +183,19 @@ export async function provideSeedTenant(
160
183
  seedUserResponseSchema,
161
184
  ),
162
185
  roles,
163
- ),
186
+ );
187
+ if (identity.mfa !== "totp") return user;
188
+ // Enrollment logs its context in; a throwaway one keeps the admin's session intact.
189
+ const enrollContext = await playwright.request.newContext({
190
+ baseURL,
191
+ extraHTTPHeaders: { [CLIENT_IP_HEADER]: syntheticClientIpFor(user.email) },
192
+ });
193
+ try {
194
+ return { ...user, mfaTotpSecret: await enrollTotpViaApi(enrollContext, user) };
195
+ } finally {
196
+ await enrollContext.dispose();
197
+ }
198
+ },
164
199
  api: httpApiFor(admin),
165
200
  apiAs: httpApiFor,
166
201
  loginAs,
package/src/seed-types.ts CHANGED
@@ -19,7 +19,11 @@ export type SeededCredentials = {
19
19
  readonly password: string;
20
20
  };
21
21
 
22
- export type SeededUser = SeededCredentials & { readonly session: SessionUser };
22
+ export type SeededUser = SeededCredentials & {
23
+ readonly session: SessionUser;
24
+ // Base32 TOTP secret, present when the e2e fixture enrolled the user via `mfa: "totp"`.
25
+ readonly mfaTotpSecret?: string;
26
+ };
23
27
 
24
28
  export type SeedPart = (ctx: { readonly tenant: SeededTenant }) => Promise<void>;
25
29