@podosoft/podokit 1.6.1 → 1.6.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.
Files changed (25) hide show
  1. package/dist/deploy-checks.d.ts +16 -5
  2. package/dist/deploy-checks.js +14 -6
  3. package/dist/deploy-render.js +28 -3
  4. package/dist/deploy.d.ts +9 -0
  5. package/dist/deploy.js +83 -17
  6. package/dist/index.js +9 -2
  7. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +1 -1
  8. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +3 -1
  9. package/dist/templates/fullstack/tests/helpers/paths.ts +13 -2
  10. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +96 -7
  11. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +11 -0
  12. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +3 -3
  13. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +14 -2
  14. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +385 -0
  15. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +132 -43
  16. package/dist/templates/modules/audit-log/module.manifest.json +2 -2
  17. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +3 -1
  18. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +13 -1
  19. package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.spec.ts +67 -0
  20. package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.ts +54 -0
  21. package/dist/templates/modules/auth/module.manifest.json +1 -0
  22. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +163 -62
  23. package/dist/update.d.ts +25 -5
  24. package/dist/update.js +86 -10
  25. package/package.json +1 -1
@@ -24,7 +24,10 @@ export interface ConnectionBudget {
24
24
  perProcessTerms: string[];
25
25
  steadyProcesses: number;
26
26
  steady: number;
27
- /** Processes and connections while a rollout runs old and new pods side by side. */
27
+ /**
28
+ * Processes and connections while a rollout runs old and new pods side by side,
29
+ * counting replaced pods that still hold their pools while they terminate.
30
+ */
28
31
  rolloutProcesses: number;
29
32
  rollout: number;
30
33
  migration: number;
@@ -32,10 +35,18 @@ export interface ConnectionBudget {
32
35
  }
33
36
  /**
34
37
  * The most PostgreSQL connections the deployment can open: every API and worker
35
- * process with each pool full, the Kubernetes rollout's surge pod per workload
36
- * (the rendered strategy allows one), and the migration that runs beside the
37
- * release still serving. Compose replaces containers without a surge. Null when
38
- * the deployment uses no PostgreSQL.
38
+ * process with each pool full, a Kubernetes rollout's old and new pods together,
39
+ * and the migration that runs beside the release still serving. Null when the
40
+ * deployment uses no PostgreSQL.
41
+ *
42
+ * A replaced pod keeps its pools until its process exits, up to its grace period
43
+ * after the scale-down (the preStop sleep plus the drain for API pods), and the
44
+ * Deployment controller does not count terminating pods against maxSurge: it
45
+ * starts the next surge pod as soon as the previous replacement is ready. When
46
+ * replacements become ready faster than replaced pods exit, every old pod of a
47
+ * workload can still hold its pools while all of its new pods run, twice its
48
+ * replicas whatever the surge. Compose stops a container before it starts the
49
+ * replacement, so it never runs both.
39
50
  */
40
51
  export declare function connectionBudget(profile: AnyDeployProfile, modules: Set<string>): ConnectionBudget | null;
41
52
  /** The plan warning for a budget over the limit, or null. */
@@ -104,10 +104,18 @@ function poolSize(runtimeConfig, key, fallback) {
104
104
  }
105
105
  /**
106
106
  * The most PostgreSQL connections the deployment can open: every API and worker
107
- * process with each pool full, the Kubernetes rollout's surge pod per workload
108
- * (the rendered strategy allows one), and the migration that runs beside the
109
- * release still serving. Compose replaces containers without a surge. Null when
110
- * the deployment uses no PostgreSQL.
107
+ * process with each pool full, a Kubernetes rollout's old and new pods together,
108
+ * and the migration that runs beside the release still serving. Null when the
109
+ * deployment uses no PostgreSQL.
110
+ *
111
+ * A replaced pod keeps its pools until its process exits, up to its grace period
112
+ * after the scale-down (the preStop sleep plus the drain for API pods), and the
113
+ * Deployment controller does not count terminating pods against maxSurge: it
114
+ * starts the next surge pod as soon as the previous replacement is ready. When
115
+ * replacements become ready faster than replaced pods exit, every old pod of a
116
+ * workload can still hold its pools while all of its new pods run, twice its
117
+ * replicas whatever the surge. Compose stops a container before it starts the
118
+ * replacement, so it never runs both.
111
119
  */
112
120
  function connectionBudget(profile, modules) {
113
121
  const postgres = profile.dependencies.postgres;
@@ -130,7 +138,7 @@ function connectionBudget(profile, modules) {
130
138
  }
131
139
  const worker = profile.workloads.worker;
132
140
  const steadyProcesses = profile.workloads.api.replicas + (worker?.replicas ?? 0);
133
- const rolloutProcesses = steadyProcesses + (profile.driver === "kubernetes-helm" ? 1 + (worker ? 1 : 0) : 0);
141
+ const rolloutProcesses = profile.driver === "kubernetes-helm" ? 2 * steadyProcesses : steadyProcesses;
134
142
  const maxConnections = postgres.maxConnections ?? exports.DEFAULT_MAX_CONNECTIONS;
135
143
  const steady = steadyProcesses * perProcess;
136
144
  const rollout = rolloutProcesses * perProcess;
@@ -151,7 +159,7 @@ function budgetSummary(budget) {
151
159
  return (`each API or worker process holds up to ${budget.perProcess} (${budget.perProcessTerms.join(" + ")}); ` +
152
160
  `${budget.steadyProcesses} process(es) hold ${budget.steady}` +
153
161
  (budget.rolloutProcesses > budget.steadyProcesses
154
- ? `, ${budget.rolloutProcesses} during a rollout hold ${budget.rollout}`
162
+ ? `, ${budget.rolloutProcesses} during a rollout, counting replaced pods that have not exited, hold ${budget.rollout}`
155
163
  : "") +
156
164
  `, and the migration adds ${budget.migration} to the ${budget.steady}`);
157
165
  }
@@ -396,7 +396,27 @@ ${entries.length ? `data:\n${entries.map(([key, value]) => ` ${key}: ${quote(va
396
396
  // of 1 s: above the API's 800 ms per-check readiness bound, and enough for a busy
397
397
  // web server.
398
398
  const PROBE_TIMEOUT_SECONDS = 2;
399
- function deployment(profile, kind, workload, image, rolloutStateDigest, runtimeConfigDigest) {
399
+ // A terminating pod still receives new connections until every node and the
400
+ // ingress controller drop it from the Service's endpoints, while the API and web
401
+ // servers stop accepting them on SIGTERM. The kubelet runs this sleep before it
402
+ // sends SIGTERM, so the pod keeps serving meanwhile. PodoKit's API and web images
403
+ // are Alpine-based and ship /bin/sleep; an exec hook works on every Kubernetes
404
+ // version, unlike the sleep action.
405
+ const PRE_STOP_SLEEP_SECONDS = 5;
406
+ // The web server drains for SHUTDOWN_TIMEOUT seconds (30 unless set) and then
407
+ // force-closes for up to one more. The API's stop waits for in-flight requests
408
+ // without a bound of its own, so it gets the same window, which covers the /api
409
+ // proxy's default 30 s wait. The grace period also counts the preStop sleep.
410
+ const DEFAULT_SHUTDOWN_TIMEOUT_SECONDS = 30;
411
+ const SHUTDOWN_MARGIN_SECONDS = 5;
412
+ function terminationGracePeriodSeconds(runtimeConfig) {
413
+ const configured = runtimeConfig.SHUTDOWN_TIMEOUT;
414
+ const drain = configured !== undefined && /^\d+$/.test(configured)
415
+ ? Number(configured)
416
+ : DEFAULT_SHUTDOWN_TIMEOUT_SECONDS;
417
+ return PRE_STOP_SLEEP_SECONDS + drain + SHUTDOWN_MARGIN_SECONDS;
418
+ }
419
+ function deployment(profile, kind, workload, image, rolloutStateDigest, runtimeConfigDigest, gracePeriodSeconds) {
400
420
  const name = `${profile.target.release}-${kind}`;
401
421
  const workloadSecret = kind === "api" ? profile.secrets.api : profile.secrets.web;
402
422
  const secretEnvFrom = workloadSecret
@@ -456,6 +476,7 @@ ${labels(name, 8)} spec:
456
476
  runAsUser: 100
457
477
  runAsGroup: 101
458
478
  seccompProfile: { type: RuntimeDefault }
479
+ terminationGracePeriodSeconds: ${gracePeriodSeconds}
459
480
  containers:
460
481
  - name: ${kind}
461
482
  image: ${quote(image)}
@@ -467,6 +488,9 @@ ${labels(name, 8)} spec:
467
488
  - configMapRef: { name: ${profile.target.release}-runtime }
468
489
  ${secretEnvFrom} ports:
469
490
  - { name: http, containerPort: 3000 }
491
+ lifecycle:
492
+ preStop:
493
+ exec: { command: [sleep, "${PRE_STOP_SLEEP_SECONDS}"] }
470
494
  ${probe}${resources(workload.resources)}---
471
495
  apiVersion: v1
472
496
  kind: Service
@@ -634,10 +658,11 @@ ${apiPathIngress(profile, tls)}`;
634
658
  }
635
659
  function applicationManifest(profile, images, rolloutStateDigest, workerCommand, runtimeConfig) {
636
660
  const runtimeConfigDigest = digest(["runtime-config", JSON.stringify(sortedRuntimeConfig(runtimeConfig))]);
661
+ const gracePeriodSeconds = terminationGracePeriodSeconds(runtimeConfig);
637
662
  return [
638
663
  configMap(profile, runtimeConfig),
639
- deployment(profile, "api", profile.workloads.api, images.api, rolloutStateDigest, runtimeConfigDigest),
640
- deployment(profile, "web", profile.workloads.web, images.web, rolloutStateDigest, runtimeConfigDigest),
664
+ deployment(profile, "api", profile.workloads.api, images.api, rolloutStateDigest, runtimeConfigDigest, gracePeriodSeconds),
665
+ deployment(profile, "web", profile.workloads.web, images.web, rolloutStateDigest, runtimeConfigDigest, gracePeriodSeconds),
641
666
  workerDeployment(profile, images.api, rolloutStateDigest, runtimeConfigDigest, workerCommand),
642
667
  disruptionBudget(profile, "api"),
643
668
  disruptionBudget(profile, "web"),
package/dist/deploy.d.ts CHANGED
@@ -38,7 +38,16 @@ export interface DeploymentStatus {
38
38
  readyReplicas: number;
39
39
  replicas: number;
40
40
  images: string[];
41
+ /** Container restarts of the running pods of the Deployment's current revision. */
41
42
  restartCount: number;
43
+ /**
44
+ * Pods still listed that are not current: from an earlier revision, or
45
+ * terminating. They linger for the termination grace period after a rollout.
46
+ */
47
+ previousPods: {
48
+ count: number;
49
+ restartCount: number;
50
+ };
42
51
  }>;
43
52
  }
44
53
  export interface VerificationResult {
package/dist/deploy.js CHANGED
@@ -423,15 +423,86 @@ function parseDeployment(output, workloadName) {
423
423
  }
424
424
  return deployment;
425
425
  }
426
- function restartCount(output, workloadName) {
427
- const values = output
428
- .split(/\s+/)
429
- .map((value) => value.trim())
430
- .filter(Boolean);
431
- if (values.some((value) => !/^(0|[1-9][0-9]*)$/.test(value))) {
432
- throw new Error(`Kubernetes pod restart counts for ${workloadName} are invalid.`);
426
+ const REVISION_ANNOTATION = "deployment.kubernetes.io/revision";
427
+ const POD_TEMPLATE_HASH_LABEL = "pod-template-hash";
428
+ function deploymentRevision(deployment, workloadName) {
429
+ const revision = deployment.metadata?.annotations?.[REVISION_ANNOTATION];
430
+ if (typeof revision !== "string" || !/^[1-9][0-9]*$/.test(revision)) {
431
+ throw new Error(`Kubernetes Deployment ${workloadName} has no valid ${REVISION_ANNOTATION} annotation.`);
433
432
  }
434
- return values.reduce((sum, value) => sum + Number(value), 0);
433
+ return revision;
434
+ }
435
+ function kubernetesListItems(output, description) {
436
+ const list = parseJson(output, description);
437
+ if (!isRecord(list) || !Array.isArray(list.items)) {
438
+ throw new Error(`${description} returned an invalid list.`);
439
+ }
440
+ return list.items;
441
+ }
442
+ function optionalRecord(value) {
443
+ return value === undefined || isRecord(value);
444
+ }
445
+ /** The pod-template-hash of the ReplicaSet that carries the Deployment's revision. */
446
+ function currentPodTemplateHash(output, workloadName, revision) {
447
+ const description = `Kubernetes ReplicaSets of ${workloadName}`;
448
+ const matching = kubernetesListItems(output, description).map((item) => {
449
+ if (!isRecord(item) ||
450
+ !isRecord(item.metadata) ||
451
+ !optionalRecord(item.metadata.annotations) ||
452
+ !optionalRecord(item.metadata.labels)) {
453
+ throw new Error(`${description} have an invalid schema.`);
454
+ }
455
+ return {
456
+ revision: item.metadata.annotations?.[REVISION_ANNOTATION],
457
+ hash: item.metadata.labels?.[POD_TEMPLATE_HASH_LABEL],
458
+ };
459
+ }).filter((replicaSet) => replicaSet.revision === revision);
460
+ if (matching.length !== 1) {
461
+ throw new Error(`Kubernetes Deployment ${workloadName} has ${matching.length} ReplicaSets for revision ${revision}; expected one.`);
462
+ }
463
+ const hash = matching[0].hash;
464
+ if (typeof hash !== "string" || hash.length === 0) {
465
+ throw new Error(`${description}: revision ${revision} has no ${POD_TEMPLATE_HASH_LABEL} label.`);
466
+ }
467
+ return hash;
468
+ }
469
+ /**
470
+ * Restarts of the current revision's running pods, and of the rest separately.
471
+ * Pods of every revision share the workload label, and an earlier ReplicaSet's
472
+ * pods stay listed with a deletionTimestamp until they finish terminating.
473
+ */
474
+ function podRestarts(output, workloadName, currentHash) {
475
+ const description = `Kubernetes pods of ${workloadName}`;
476
+ const result = { restartCount: 0, previousPods: { count: 0, restartCount: 0 } };
477
+ for (const pod of kubernetesListItems(output, description)) {
478
+ if (!isRecord(pod) ||
479
+ !isRecord(pod.metadata) ||
480
+ !optionalRecord(pod.metadata.labels) ||
481
+ (pod.status !== undefined && !isRecord(pod.status))) {
482
+ throw new Error(`${description} have an invalid schema.`);
483
+ }
484
+ // A pod that has not started a container yet reports no statuses.
485
+ const statuses = pod.status?.containerStatuses ?? [];
486
+ if (!Array.isArray(statuses))
487
+ throw new Error(`${description} have an invalid schema.`);
488
+ let restarts = 0;
489
+ for (const status of statuses) {
490
+ const count = isRecord(status) ? status.restartCount : undefined;
491
+ if (typeof count !== "number" || !Number.isInteger(count) || count < 0) {
492
+ throw new Error(`Kubernetes pod restart counts for ${workloadName} are invalid.`);
493
+ }
494
+ restarts += count;
495
+ }
496
+ const terminating = pod.metadata.deletionTimestamp !== undefined && pod.metadata.deletionTimestamp !== null;
497
+ if (!terminating && pod.metadata.labels?.[POD_TEMPLATE_HASH_LABEL] === currentHash) {
498
+ result.restartCount += restarts;
499
+ }
500
+ else {
501
+ result.previousPods.count += 1;
502
+ result.previousPods.restartCount += restarts;
503
+ }
504
+ }
505
+ return result;
435
506
  }
436
507
  function planDigest(input, runtime) {
437
508
  return (0, node_crypto_1.createHash)("sha256")
@@ -867,14 +938,9 @@ function getDeploymentStatus(projectRoot, profileName, runner = defaultRunner) {
867
938
  });
868
939
  const deployments = items.map((item) => {
869
940
  const name = item.metadata.name;
870
- const observedRestartCount = restartCount(checked(runner, "kubectl", kubectlArgs(profile, [
871
- "get",
872
- "pods",
873
- "-l",
874
- `app.kubernetes.io/name=${name}`,
875
- "-o",
876
- "jsonpath={range .items[*].status.containerStatuses[*]}{.restartCount}{\"\\n\"}{end}",
877
- ])), name);
941
+ const selector = `app.kubernetes.io/name=${name}`;
942
+ const currentHash = currentPodTemplateHash(checked(runner, "kubectl", kubectlArgs(profile, ["get", "replicasets", "-l", selector, "-o", "json"])), name, deploymentRevision(item, name));
943
+ const restarts = podRestarts(checked(runner, "kubectl", kubectlArgs(profile, ["get", "pods", "-l", selector, "-o", "json"])), name, currentHash);
878
944
  return {
879
945
  name,
880
946
  readyReplicas: item.status?.readyReplicas ?? 0,
@@ -882,7 +948,7 @@ function getDeploymentStatus(projectRoot, profileName, runner = defaultRunner) {
882
948
  images: (item.spec?.template?.spec?.containers ?? [])
883
949
  .map((container) => container.image)
884
950
  .filter((image) => typeof image === "string"),
885
- restartCount: observedRestartCount,
951
+ ...restarts,
886
952
  };
887
953
  });
888
954
  return {
package/dist/index.js CHANGED
@@ -716,7 +716,8 @@ async function main(argv) {
716
716
  }
717
717
  process.stdout.write(`Applied: ${result.written.length} written, ${result.moved.length} moved, ` +
718
718
  `${result.removed.length} removed, ` +
719
- `${result.merged.length} merged, ${result.conflicts.length} conflict.\n`);
719
+ `${result.merged.length} merged, ${result.conflicts.length} conflict, ` +
720
+ `${result.skipped.length} skipped.\n`);
720
721
  writeApiPaths("Direct API paths:", result.apiPaths, "add");
721
722
  if (result.conflicts.length) {
722
723
  process.stdout.write("\nResolve the following, then commit:\n" +
@@ -729,6 +730,11 @@ async function main(argv) {
729
730
  result.review.map((f) => ` ${f}`).join("\n") +
730
731
  "\n");
731
732
  }
733
+ if (result.skipped.length) {
734
+ process.stdout.write("\nSkipped edited files, left as they are:\n" +
735
+ result.skipped.map((file) => ` ${file.path} (${file.reason})`).join("\n") +
736
+ "\n");
737
+ }
732
738
  return;
733
739
  }
734
740
  const plan = (0, update_1.planUpdate)(process.cwd(), templatesDir);
@@ -759,7 +765,8 @@ async function main(argv) {
759
765
  }
760
766
  process.stdout.write(`${shown.length ? "\n" : ""}${counts.update} update, ${counts.add} add, ${counts.move} move, ` +
761
767
  `${counts.remove} remove, ${counts.conflict} conflict, ${counts.review} review. ` +
762
- `Dry-run — nothing was written. Re-run with --apply to write (use --from <dir> for a 3-way merge).\n`);
768
+ `Dry-run — nothing was written. Re-run with --apply to write; it 3-way merges each conflict ` +
769
+ `against the ${plan.fromVersion} templates, fetched from npm unless --from <dir> gives them.\n`);
763
770
  }
764
771
  writeOwnedTemplateNotes(plan, templatesDir, args.from);
765
772
  }
@@ -22,7 +22,7 @@ Read the profile's `driver` first and say which one you are operating.
22
22
  7. Do not apply until the user explicitly approves that exact plan.
23
23
  8. Apply with `{{packageExecutor}} @podosoft/podokit deploy apply --profile <name> --release <stable-semver> --confirm <plan-hash>`.
24
24
  9. Run `{{packageExecutor}} @podosoft/podokit deploy status --profile <name> --json` and `{{packageExecutor}} @podosoft/podokit deploy verify --profile <name> --json`.
25
- 10. Report the exact images, the revision, running replicas, restart totals, and the verification result. Report `syncDrift` too: a non-empty value means containers are running locally synced artifacts and the release tag does not describe what is serving.
25
+ 10. Report the exact images, the revision, running replicas, restart counts, and the verification result. On Kubernetes, each Deployment's `restartCount` covers only the running pods of its current revision, and `previousPods` gives the `count` and `restartCount` of the pods still listed from an earlier revision or terminating. Right after `apply` or `rollback` these are usually the replaced pods finishing their grace period: report them separately and never attribute their restarts to the new release. Report `syncDrift` too: a non-empty value means containers are running locally synced artifacts and the release tag does not describe what is serving.
26
26
 
27
27
  ## Iterating against a deployment is a different command
28
28
 
@@ -34,7 +34,9 @@ Review these fields before planning:
34
34
  PostgreSQL also takes `maxConnections` (default 100) and `appPools` for pools the
35
35
  application opens itself, each sized by a `runtimeConfig` key (`sizeKey`) or a
36
36
  fixed `size` when the code sets it; the doctor and the plan check the connection budget
37
- against them, so lower `DB_POOL_MAX`/`DB_AUTH_POOL_MAX` or replicas when they warn.
37
+ against them, counting every API and worker replica twice during a Kubernetes rollout
38
+ because replaced pods keep their pools until they exit, so lower
39
+ `DB_POOL_MAX`/`DB_AUTH_POOL_MAX` or replicas when they warn.
38
40
  - `secrets`: API/optional web Secret names and required key names, plus image-pull Secret
39
41
  - `runtimeConfig`: non-sensitive environment values only
40
42
  - `verification`: HTTPS origin and checks with exact statuses and optional JSON fields
@@ -20,9 +20,15 @@ export interface AppPaths {
20
20
  * `AccountMenu` for a signed-in user, or null when none does.
21
21
  */
22
22
  accountMenu: string | null;
23
+ /**
24
+ * A page that renders the managed `ThemeToggle` for the signed-in
25
+ * administrator, or null when none does. Defaults to `accountMenu`, and to
26
+ * the admin console, whose header renders the toggle, when that is null.
27
+ */
28
+ themeToggle: string | null;
23
29
  }
24
30
 
25
- const defaults: AppPaths = {
31
+ const defaults: Omit<AppPaths, "themeToggle"> = {
26
32
  home: "/",
27
33
  homeReady: (page) => page.getByRole("heading", { level: 1 }),
28
34
  indexable: ["/"],
@@ -30,4 +36,9 @@ const defaults: AppPaths = {
30
36
  accountMenu: "/",
31
37
  };
32
38
 
33
- export const paths: AppPaths = { ...defaults, ...appPaths };
39
+ const merged = { ...defaults, ...appPaths };
40
+
41
+ export const paths: AppPaths = {
42
+ ...merged,
43
+ themeToggle: appPaths.themeToggle === undefined ? (merged.accountMenu ?? "/admin") : appPaths.themeToggle,
44
+ };
@@ -1,10 +1,21 @@
1
+ import type { APIRequestContext } from "@playwright/test";
1
2
  import { expect, test } from "../helpers/disposable-users";
2
3
  import { ADMIN, USER } from "../helpers/accounts";
4
+ import { mailpitReachable, waitForOtp } from "../helpers/mailpit";
5
+ import { totpCode } from "../helpers/totp";
3
6
 
4
7
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
5
8
  const origin = { origin: base };
9
+ const PASSWORD = "Podokit3e-Str0ng!pw";
6
10
 
7
- type Entry = { id: string; action: string; actorEmail: string | null; targetLabel: string | null; createdAt: string };
11
+ type Entry = {
12
+ id: string;
13
+ action: string;
14
+ actorEmail: string | null;
15
+ targetLabel: string | null;
16
+ metadata: Record<string, unknown> | null;
17
+ createdAt: string;
18
+ };
8
19
 
9
20
  async function signedIn(
10
21
  playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"],
@@ -19,6 +30,13 @@ function auditQuery(params: Record<string, string>): string {
19
30
  return `/api/audit-logs?${new URLSearchParams(params).toString()}`;
20
31
  }
21
32
 
33
+ // Sign-in methods recorded for the account, oldest first.
34
+ async function signInMethods(admin: APIRequestContext, email: string): Promise<unknown[]> {
35
+ const entries = (await (await admin.get(auditQuery({ action: "auth.login", target: email, order: "asc" }))).json()) as Entry[];
36
+ expect(entries.every((entry) => entry.actorEmail === email)).toBe(true);
37
+ return entries.map((entry) => entry.metadata?.method);
38
+ }
39
+
22
40
  test("audit log records auth/admin actions", async ({ playwright, disposableUsers }) => {
23
41
  const admin = await signedIn(playwright, ADMIN);
24
42
  const probe = await admin.get("/api/audit-logs");
@@ -58,13 +76,22 @@ test("sending a verification email is audited", async ({ playwright, disposableU
58
76
  const admin = await signedIn(playwright, ADMIN);
59
77
  test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
60
78
  const email = `verif-audit-${Date.now()}@example.com`;
61
- const created = await admin.post("/api/auth/admin/create-user", {
62
- data: { email, password: "Podokit3e-Str0ng!pw", name: "VA", role: "user" },
63
- });
64
- await disposableUsers.trackResponse(created);
65
- await admin.post("/api/auth/send-verification-email", { data: { email, callbackURL: `${base}/admin` } });
66
- const entries = (await (await admin.get(auditQuery({ action: "auth.verification_sent", target: email }))).json()) as Entry[];
79
+ await disposableUsers.create({ email, password: PASSWORD, name: "VA" });
80
+ const sent = async (): Promise<Entry[]> =>
81
+ (await (await admin.get(auditQuery({ action: "auth.verification_sent", target: email }))).json()) as Entry[];
82
+
83
+ // Better Auth sends a signed-in user's verification email only to their own address.
84
+ const refused = await admin.post("/api/auth/send-verification-email", { data: { email, callbackURL: `${base}/admin` } });
85
+ expect(refused.status()).toBe(400);
86
+ expect(await sent(), "a refused send is not audited").toEqual([]);
87
+
88
+ const user = await signedIn(playwright, { email, password: PASSWORD });
89
+ const own = await user.post("/api/auth/send-verification-email", { data: { email, callbackURL: `${base}/account` } });
90
+ expect(own.ok()).toBeTruthy();
91
+ const entries = await sent();
67
92
  expect(entries, "verification send should be audited").toHaveLength(1);
93
+ expect(entries[0]?.actorEmail).toBe(email);
94
+ await user.dispose();
68
95
  await admin.dispose();
69
96
  });
70
97
 
@@ -126,6 +153,68 @@ test("the audit log rejects bad paging parameters", async ({ playwright }) => {
126
153
  await admin.dispose();
127
154
  });
128
155
 
156
+ test("a wrong password is not audited as a sign-in", async ({ playwright, disposableUsers }) => {
157
+ const admin = await signedIn(playwright, ADMIN);
158
+ test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
159
+ const email = `audit-password-${Date.now()}@example.com`;
160
+ await disposableUsers.create({ email, password: PASSWORD });
161
+
162
+ const visitor = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
163
+ const wrong = await visitor.post("/api/auth/sign-in/email", { data: { email, password: "Wrong-password-1" } });
164
+ expect(wrong.status()).toBe(401);
165
+ expect(await signInMethods(admin, email), "a failed sign-in leaves no auth.login").toEqual([]);
166
+
167
+ expect((await visitor.post("/api/auth/sign-in/email", { data: { email, password: PASSWORD } })).ok()).toBeTruthy();
168
+ expect(await signInMethods(admin, email)).toEqual(["password"]);
169
+ await visitor.dispose();
170
+ await admin.dispose();
171
+ });
172
+
173
+ test("an email code sign-in is audited once", async ({ playwright, disposableUsers }) => {
174
+ test.skip(!(await mailpitReachable()), "Mailpit not available");
175
+ const admin = await signedIn(playwright, ADMIN);
176
+ test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
177
+ const caps = await (await admin.get("/api/account/capabilities")).json();
178
+ test.skip(!caps?.emailOtp, "email OTP not enabled");
179
+ const email = `audit-otp-${Date.now()}@example.com`;
180
+ await disposableUsers.create({ email, password: PASSWORD });
181
+
182
+ const visitor = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
183
+ const sent = await visitor.post("/api/auth/email-otp/send-verification-otp", { data: { email, type: "sign-in" } });
184
+ expect(sent.ok()).toBeTruthy();
185
+ const otp = await waitForOtp(email);
186
+ expect((await visitor.post("/api/auth/sign-in/email-otp", { data: { email, otp } })).ok()).toBeTruthy();
187
+ expect(await signInMethods(admin, email)).toEqual(["email-otp"]);
188
+ await visitor.dispose();
189
+ await admin.dispose();
190
+ });
191
+
192
+ test("a two-factor sign-in is audited when the code completes it", async ({ playwright, disposableUsers }) => {
193
+ const admin = await signedIn(playwright, ADMIN);
194
+ test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
195
+ const caps = await (await admin.get("/api/account/capabilities")).json();
196
+ test.skip(!caps?.twoFactor, "two-factor not enabled");
197
+ const email = `audit-2fa-${Date.now()}@example.com`;
198
+ await disposableUsers.create({ email, password: PASSWORD });
199
+
200
+ const owner = await signedIn(playwright, { email, password: PASSWORD });
201
+ const enable = await (await owner.post("/api/auth/two-factor/enable", { data: { password: PASSWORD } })).json();
202
+ expect((await owner.post("/api/auth/two-factor/verify-totp", { data: { code: totpCode(enable.totpURI) } })).ok()).toBeTruthy();
203
+ expect(await signInMethods(admin, email), "turning on two-factor is not a sign-in").toEqual(["password"]);
204
+
205
+ const challenged = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
206
+ const pending = await (await challenged.post("/api/auth/sign-in/email", { data: { email, password: PASSWORD } })).json();
207
+ expect(pending.twoFactorRedirect).toBe(true);
208
+ expect(await signInMethods(admin, email), "the password step alone is not a sign-in").toEqual(["password"]);
209
+
210
+ const verified = await challenged.post("/api/auth/two-factor/verify-totp", { data: { code: totpCode(enable.totpURI) } });
211
+ expect(verified.ok()).toBeTruthy();
212
+ expect(await signInMethods(admin, email)).toEqual(["password", "two-factor-totp"]);
213
+ await owner.dispose();
214
+ await challenged.dispose();
215
+ await admin.dispose();
216
+ });
217
+
129
218
  // Audit logging is a DB-backed on/off toggle (auth_config `server`, applied live).
130
219
  // Runs after the recording tests and restores the flag so nothing else is affected.
131
220
  test("audit logging honours the DB on/off toggle @smoke", { tag: "@exclusive" }, async ({ playwright, disposableUsers }) => {
@@ -339,6 +339,17 @@ test("a user can create, list and delete an organization @smoke", async ({ playw
339
339
  const list = (await (await ctx.get("/api/auth/organization/list")).json()) as Array<{ id: string; slug: string }>;
340
340
  expect(list.some((o) => o.slug === slug)).toBeTruthy();
341
341
 
342
+ // A filter or a page that matches no member is an empty list, not a server error.
343
+ const userId = ((await (await ctx.get("/api/auth/get-session")).json()) as { user: { id: string } }).user.id;
344
+ const members = async (query: string): Promise<unknown> => {
345
+ const response = await ctx.get(`/api/auth/organization/list-members?organizationId=${orgId}&${query}`);
346
+ expect(response.status(), query).toBe(200);
347
+ return response.json();
348
+ };
349
+ expect(await members(`filterField=userId&filterValue=${userId}`)).toMatchObject({ members: [{ userId }], total: 1 });
350
+ expect(await members("filterField=userId&filterValue=not-a-member")).toEqual({ members: [], total: 0 });
351
+ expect(await members("offset=1")).toEqual({ members: [], total: 1 });
352
+
342
353
  const del = await ctx.post("/api/auth/organization/delete", { data: { organizationId: orgId } });
343
354
  expect(del.ok()).toBeTruthy();
344
355
  await ctx.dispose();
@@ -10,11 +10,11 @@ async function chooseTheme(page: Page, mode: "Light" | "Dark" | "System"): Promi
10
10
  // mode-watcher marks <html> with `.dark` or `.light`, so component libraries
11
11
  // that follow either class, and fall back to the OS setting otherwise, match the
12
12
  // theme the visitor chose rather than the system one. The managed ThemeToggle
13
- // renders on the page app-paths.ts names as the account menu page.
13
+ // renders on the page app-paths.ts names as themeToggle.
14
14
  test("the chosen theme is named on <html> in both modes", async ({ page }) => {
15
- test.skip(paths.accountMenu === null, "no page renders the theme toggle (tests/helpers/app-paths.ts)");
15
+ test.skip(paths.themeToggle === null, "no page renders the theme toggle (tests/helpers/app-paths.ts)");
16
16
  await page.emulateMedia({ colorScheme: "dark" });
17
- await ready(page, paths.accountMenu ?? "/");
17
+ await ready(page, paths.themeToggle ?? "/");
18
18
  const html = page.locator("html");
19
19
 
20
20
  await chooseTheme(page, "Light");
@@ -8,10 +8,19 @@ actor / action / target model. Every entry flows through one pipeline
8
8
 
9
9
  Nothing is recorded automatically except the built-in **auth/admin actions**
10
10
  (login, user create/update/delete, ban, role change, session revoke, ...),
11
- which the better-auth hook (`audit-hook.ts`) maps to semantic action codes like
11
+ which the Better Auth plugin in `audit-hook.ts` maps to semantic action codes like
12
12
  `user.create` and `auth.login`. Everything else is **opt-in** — you decide what
13
13
  matters.
14
14
 
15
+ The plugin records an action only when the request succeeded, so a wrong
16
+ password or a forbidden admin call leaves no entry. Every sign-in method records
17
+ `auth.login` with the method in `metadata.method` (`password`, `email-otp`,
18
+ `magic-link`, `passkey`, `two-factor-totp`, ...) and, for social sign-in, the
19
+ provider in `metadata.provider`. A two-factor sign-in is recorded when the second
20
+ factor completes it, not at the password step. `auth.ts` pushes the plugin after
21
+ every other plugin: Better Auth runs after hooks in plugin order, and two-factor
22
+ changes the outcome of a password sign-in in its own after hook.
23
+
15
24
  ## Audit your own Elysia routes
16
25
 
17
26
  ```ts
@@ -86,7 +95,10 @@ survives renames/deletes), `targetType/targetId/targetLabel`, `ip`, `metadata`
86
95
 
87
96
  ## Customize
88
97
 
89
- - **Auth actions:** edit the `ACTIONS` map in `audit-hook.ts`.
98
+ - **Auth actions:** `ACTIONS` in `audit-hook.ts` maps Better Auth endpoints to
99
+ action codes, and `SIGN_IN_METHODS` lists the endpoints that sign a user in.
100
+ The `audit-hook.spec.ts` test fails when the app's Better Auth gains a sign-in
101
+ endpoint that neither list classifies.
90
102
  - **Your routes:** call `AuditService.recordRequest()` after successful operations;
91
103
  pass `{ defer: true }` only for high-volume, non-security events.
92
104
  - **Schema:** add columns with a migration, and keep the