@podosoft/podokit 1.6.0 → 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 (50) hide show
  1. package/dist/deploy-checks.d.ts +16 -5
  2. package/dist/deploy-checks.js +17 -9
  3. package/dist/deploy-render.js +28 -3
  4. package/dist/deploy-schema.d.ts +7 -3
  5. package/dist/deploy-schema.js +16 -5
  6. package/dist/deploy.d.ts +9 -0
  7. package/dist/deploy.js +83 -17
  8. package/dist/index.js +9 -2
  9. package/dist/templates/fullstack/AGENTS.md +3 -1
  10. package/dist/templates/fullstack/apps/api/package.json +1 -1
  11. package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +2 -1
  12. package/dist/templates/fullstack/apps/api/src/build.spec.ts +5 -2
  13. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +43 -4
  14. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -4
  15. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +1 -1
  16. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +5 -2
  17. package/dist/templates/fullstack/tests/README.md +20 -10
  18. package/dist/templates/fullstack/tests/helpers/paths.ts +13 -2
  19. package/dist/templates/fullstack/tests/helpers/seeded-admin.ts +15 -0
  20. package/dist/templates/fullstack/tests/package.json +3 -3
  21. package/dist/templates/fullstack/tests/playwright.config.ts +8 -6
  22. package/dist/templates/fullstack/tests/playwright.extensions.ts +73 -12
  23. package/dist/templates/fullstack/tests/run-e2e.ts +28 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +17 -3
  25. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/theme-toggle.svelte +1 -1
  26. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +96 -7
  27. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +11 -0
  28. package/dist/templates/modules/admin-dashboard/files/tests/helpers/hydration.ts +13 -4
  29. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +8 -6
  30. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +16 -10
  31. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +14 -2
  32. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +385 -0
  33. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +132 -43
  34. package/dist/templates/modules/audit-log/module.manifest.json +2 -2
  35. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +3 -1
  36. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +13 -1
  37. package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.spec.ts +67 -0
  38. package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.ts +54 -0
  39. package/dist/templates/modules/auth/module.manifest.json +1 -0
  40. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +50 -0
  41. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +23 -3
  42. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -0
  43. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +60 -3
  44. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +3 -6
  45. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +3 -6
  46. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +163 -62
  47. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +3 -6
  48. package/dist/update.d.ts +25 -5
  49. package/dist/update.js +86 -10
  50. package/package.json +2 -2
@@ -45,16 +45,26 @@ Tag a test when it changes a server-wide setting (a feature flag, the
45
45
  authentication configuration such as SMTP, social providers, or sign-up
46
46
  approval, the site settings, or a module's settings) or the profile of a seeded
47
47
  account. Each project has an `<name>-exclusive` twin, such as `ui-exclusive`,
48
- that runs only the tagged tests, one at a time. With more than one worker the
49
- twins wait for every other test, so an exclusive test never runs alongside
50
- another one. Never empty the shared mail catcher or SMS sink in a test: send to
51
- an address or number of the test's own instead.
52
-
53
- More than one worker suits the whole suite. When a run selects exclusive tests
54
- with a file name, `--grep`, or `--project`, Playwright also runs every other test
55
- first, because the twins depend on them, and a test that fails there marks the
56
- exclusive tests as not run. Use the default single worker for a few files, and
57
- do not combine `--no-deps` with `E2E_WORKERS` above 1.
48
+ that runs only the tagged tests, one at a time. Never empty the shared mail
49
+ catcher or SMS sink in a test: send to an address or number of the test's own
50
+ instead.
51
+
52
+ The `test:e2e` scripts run `run-e2e.ts`, which hands its arguments (a file,
53
+ `--grep`, `--project`) to Playwright. With more than one worker it runs
54
+ Playwright twice: first every test not tagged `@exclusive` on those workers,
55
+ then the tagged tests on one worker, even when a test failed in the first run.
56
+ The suite fails when either run fails. The second run reports to
57
+ `playwright-report/exclusive` and `test-results/exclusive`:
58
+
59
+ ```bash
60
+ bunx playwright show-report playwright-report/exclusive
61
+ ```
62
+
63
+ A plain `bunx playwright test` with `E2E_WORKERS` above 1 makes one run in which
64
+ the twins depend on every other test: a failing test there leaves the exclusive
65
+ tests not run, and selecting an exclusive test runs every other test first. Use
66
+ `{{rootRun}} test:e2e` on more workers, and do not combine `--no-deps` with
67
+ `E2E_WORKERS` above 1.
58
68
 
59
69
  Application-owned projects in `playwright.projects.cjs` get twins too. A project
60
70
  that another project depends on is a setup project and keeps a single copy.
@@ -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
+ };
@@ -0,0 +1,15 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * The administrator the admin-dashboard Playwright setup seeds, with the address
6
+ * and password `accounts.ts` reads from `E2E_*` variables and the application's
7
+ * `app-accounts.ts`, or null in an app without that module. Specs of modules
8
+ * that do not require admin-dashboard sign in with this instead of importing
9
+ * `accounts.ts`, which only admin-dashboard installs.
10
+ */
11
+ export async function seededAdmin(): Promise<{ email: string; password: string } | null> {
12
+ if (!existsSync(join(__dirname, "accounts.ts"))) return null;
13
+ const { ADMIN } = await import("./accounts");
14
+ return { email: ADMIN.email, password: ADMIN.password };
15
+ }
@@ -3,9 +3,9 @@
3
3
  "version": "0.0.0",
4
4
  "private": true,
5
5
  "scripts": {
6
- "test:e2e": "bunx playwright test",
7
- "test:e2e:ui": "bunx playwright test --project=ui --project=ui-exclusive",
8
- "test:e2e:api": "bunx playwright test --project=api --project=api-exclusive",
6
+ "test:e2e": "bun run-e2e.ts",
7
+ "test:e2e:ui": "bun run-e2e.ts --project=ui --project=ui-exclusive",
8
+ "test:e2e:api": "bun run-e2e.ts --project=api --project=api-exclusive",
9
9
  "test:e2e:report": "bunx playwright show-report"
10
10
  },
11
11
  "devDependencies": {
@@ -1,14 +1,15 @@
1
1
  import { defineConfig, devices } from "@playwright/test";
2
- import { e2eWorkers, loadPlaywrightProjects, withExclusivePhase } from "./playwright.extensions";
2
+ import { e2eRunSettings, loadPlaywrightProjects, withExclusivePhase } from "./playwright.extensions";
3
3
 
4
4
  // The suite runs against a live stack: web on E2E_BASE_URL (proxying /api/* to
5
5
  // the API). Start it first (see docs/testing.md) — locally via the dev harness,
6
6
  // in CI via scripts/e2e-ci.mjs. Two projects: `api` (request-only, backend e2e)
7
7
  // and `ui` (chromium, pages). Files are routed by suffix (*.api/*.ui.spec.ts).
8
8
  // E2E_WORKERS sets the worker count (default 1); tests tagged @exclusive run in
9
- // `<project>-exclusive` twins after the others (see playwright.extensions.ts).
9
+ // `<project>-exclusive` twins after the others, in a second run when run-e2e.ts
10
+ // starts the suite on more workers (see playwright.extensions.ts).
10
11
  const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5001";
11
- const workers = e2eWorkers();
12
+ const run = e2eRunSettings();
12
13
  const coreProjects = [
13
14
  { name: "api", testMatch: /.*\.api\.spec\.ts/ },
14
15
  { name: "ui", testMatch: /.*\.ui\.spec\.ts/, use: { ...devices["Desktop Chrome"] } },
@@ -19,13 +20,14 @@ export default defineConfig({
19
20
  fullyParallel: true,
20
21
  forbidOnly: !!process.env.CI,
21
22
  retries: process.env.CI ? 2 : 0,
22
- workers,
23
+ workers: run.workers,
24
+ outputDir: run.outputDir,
23
25
  globalSetup: "./playwright.extensions.ts",
24
- reporter: [["list"], ["html", { open: "never" }]],
26
+ reporter: [["list"], ["html", { open: "never", outputFolder: run.reportFolder }]],
25
27
  use: {
26
28
  baseURL,
27
29
  trace: "on-first-retry",
28
30
  screenshot: "only-on-failure",
29
31
  },
30
- projects: withExclusivePhase([...coreProjects, ...loadPlaywrightProjects(coreProjects)], workers),
32
+ projects: withExclusivePhase([...coreProjects, ...loadPlaywrightProjects(coreProjects)], run.workers, run.phase),
31
33
  });
@@ -17,6 +17,13 @@ const requireFromTests = createRequire(__filename);
17
17
  // the test's tags, separated by spaces.
18
18
  const TAGGED = /(?:^|\s)@exclusive(?=\s|$)/;
19
19
  const UNTAGGED = /^(?![\s\S]*(?:^|\s)@exclusive(?=\s|$))/;
20
+ const EVERY_FILE = /.*/;
21
+
22
+ /**
23
+ * One of the two Playwright runs `run-e2e.ts` makes with more than one worker:
24
+ * `shared` runs the tests not tagged `@exclusive`, `exclusive` the tagged ones.
25
+ */
26
+ export type E2ePhase = "shared" | "exclusive";
20
27
 
21
28
  function projectName(project: unknown): string | null {
22
29
  if (typeof project !== "object" || project === null || !("name" in project)) return null;
@@ -69,29 +76,74 @@ export function e2eWorkers(value = process.env.E2E_WORKERS): number {
69
76
  return workers;
70
77
  }
71
78
 
79
+ /** The run `run-e2e.ts` started, from `E2E_PHASE`, or undefined for a single run. */
80
+ export function e2ePhase(value = process.env.E2E_PHASE): E2ePhase | undefined {
81
+ if (value === undefined || value === "") return undefined;
82
+ if (value === "shared" || value === "exclusive") return value;
83
+ throw new Error(`E2E_PHASE must be "shared" or "exclusive", got "${value}".`);
84
+ }
85
+
86
+ /**
87
+ * Workers and output folders for this Playwright run. The exclusive phase runs
88
+ * on one worker and keeps its results and HTML report in `exclusive/` folders,
89
+ * because each run empties its own.
90
+ */
91
+ export function e2eRunSettings(
92
+ phase = e2ePhase(),
93
+ workers = e2eWorkers(),
94
+ ): { phase: E2ePhase | undefined; workers: number; outputDir: string; reportFolder: string } {
95
+ return phase === "exclusive"
96
+ ? { phase, workers: 1, outputDir: "test-results/exclusive", reportFolder: "playwright-report/exclusive" }
97
+ : { phase, workers, outputDir: "test-results", reportFolder: "playwright-report" };
98
+ }
99
+
100
+ /**
101
+ * The Playwright runs for `run-e2e.ts` with these command-line arguments. One
102
+ * worker needs a single run. With more workers, the tests not tagged
103
+ * `@exclusive` run first and the tagged ones second, in a run of their own that
104
+ * starts even when the first one failed. Either run may find no tests: a filter
105
+ * can select tests of one kind only, and an app may have no exclusive tests.
106
+ */
107
+ export function e2eRuns(args: string[], workers = e2eWorkers()): Array<{ phase?: E2ePhase; args: string[] }> {
108
+ if (workers === 1) return [{ args }];
109
+ return [
110
+ { phase: "shared", args: args.length > 0 ? ["--pass-with-no-tests", ...args] : args },
111
+ { phase: "exclusive", args: ["--pass-with-no-tests", ...args] },
112
+ ];
113
+ }
114
+
72
115
  /**
73
116
  * Splits each test project into one that skips tests tagged `@exclusive` and an
74
117
  * `<name>-exclusive` twin that runs only them on one worker. Projects that
75
118
  * another project depends on, or that tear one down, are setup and cleanup
76
119
  * projects and stay as they are.
77
120
  *
78
- * With more than one worker the twins depend on every other test project, and
79
- * each twin on the one before it, so an exclusive test never runs alongside
80
- * another test. A dependency runs in full, though: a run that selects only
81
- * exclusive tests, with a file, `--grep`, or `--project`, then also runs every
82
- * other test first, and a test that fails there skips the exclusive tests. Use
83
- * more than one worker for the whole suite. With one worker no dependency is
84
- * needed, so filtered runs stay small.
121
+ * With one worker no ordering is needed. With more, `run-e2e.ts` runs
122
+ * Playwright twice: in the `shared` phase the twins find no tests, and in the
123
+ * `exclusive` phase, on one worker, the other test projects find none. A single
124
+ * run with more than one worker (no phase) makes the twins depend on every
125
+ * other test project, and each twin on the one before it, so an exclusive test
126
+ * still never runs alongside another test; but there a failing test skips every
127
+ * twin after it, and a filter that selects exclusive tests runs all other tests
128
+ * first.
85
129
  */
86
- export function withExclusivePhase(projects: Projects, workers: number): Projects {
130
+ export function withExclusivePhase(projects: Projects, workers: number, phase?: E2ePhase): Projects {
87
131
  const support = new Set(
88
132
  projects.flatMap((project) => [...(project.dependencies ?? []), ...(project.teardown ? [project.teardown] : [])]),
89
133
  );
90
134
  const tested = projects.filter((project) => !support.has(projectName(project) ?? ""));
91
135
  const shared = new Map(
92
- tested.map((project) => [project, { ...project, grepInvert: [...patterns(project.grepInvert), TAGGED] }]),
136
+ tested.map((project) => [
137
+ project,
138
+ {
139
+ ...project,
140
+ grepInvert: [...patterns(project.grepInvert), TAGGED],
141
+ ...(phase === "exclusive" ? { testIgnore: EVERY_FILE } : {}),
142
+ },
143
+ ]),
93
144
  );
94
145
 
146
+ const chained = workers > 1 && phase === undefined;
95
147
  let before = [...shared.values()].map((project) => projectName(project) ?? "");
96
148
  const twins = tested.map((project) => {
97
149
  const name = `${projectName(project) ?? ""}-exclusive`;
@@ -100,7 +152,8 @@ export function withExclusivePhase(projects: Projects, workers: number): Project
100
152
  name,
101
153
  grepInvert: [...patterns(project.grepInvert), UNTAGGED],
102
154
  workers: 1,
103
- dependencies: workers > 1 ? [...(project.dependencies ?? []), ...before] : project.dependencies,
155
+ dependencies: chained ? [...(project.dependencies ?? []), ...before] : project.dependencies,
156
+ ...(phase === "shared" ? { testIgnore: EVERY_FILE } : {}),
104
157
  };
105
158
  before = [name];
106
159
  return twin;
@@ -119,13 +172,21 @@ export function withExclusivePhase(projects: Projects, workers: number): Project
119
172
  /**
120
173
  * Global setup: refuses more workers than `E2E_WORKERS` allows for. `--workers`
121
174
  * changes the count after the projects were built, so with it the exclusive
122
- * twins would run alongside the other tests.
175
+ * twins would run alongside the other tests. Warns when more than one worker
176
+ * runs without `run-e2e.ts`, where a failing test skips the exclusive tests.
123
177
  */
124
- export default function checkWorkers(config: FullConfig): void {
178
+ export default function checkWorkers(config: FullConfig, warn = console.warn): void {
125
179
  if (config.workers > 1 && e2eWorkers() === 1) {
126
180
  throw new Error(
127
181
  `Set the worker count with E2E_WORKERS=${config.workers} instead of --workers. ` +
128
182
  "Tests tagged @exclusive run after the others only when E2E_WORKERS is above 1.",
129
183
  );
130
184
  }
185
+ if (config.workers > 1 && e2ePhase() === undefined) {
186
+ warn(
187
+ "E2E_WORKERS is above 1 in a single Playwright run: a failing test makes Playwright skip " +
188
+ "the tests tagged @exclusive. Run the suite with `bun run test:e2e` (run-e2e.ts), which " +
189
+ "runs them in a second run instead.",
190
+ );
191
+ }
131
192
  }
@@ -0,0 +1,28 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { e2eRuns } from "./playwright.extensions";
3
+
4
+ // Runs the suite with the arguments it is given, such as `--grep` or a file.
5
+ // With E2E_WORKERS above 1, Playwright runs twice: the tests not tagged
6
+ // @exclusive on that many workers, then the tagged ones on one worker. The
7
+ // second run starts even when the first one failed, so a failing test cannot
8
+ // hide the exclusive tests, and the exit code is that of the first failed run.
9
+ const runs = e2eRuns(process.argv.slice(2));
10
+ let status = 0;
11
+ for (const run of runs) {
12
+ if (run.phase) {
13
+ console.log(
14
+ run.phase === "shared"
15
+ ? "\nRunning the tests not tagged @exclusive\n"
16
+ : "\nRunning the tests tagged @exclusive on one worker (report: playwright-report/exclusive)\n",
17
+ );
18
+ }
19
+ const result = spawnSync("bunx", ["playwright", "test", ...run.args], {
20
+ stdio: "inherit",
21
+ env: run.phase ? { ...process.env, E2E_PHASE: run.phase } : process.env,
22
+ });
23
+ if (result.error) throw result.error;
24
+ // Playwright exits with 130 when it is interrupted: stop instead of starting the next run.
25
+ if (result.signal !== null || result.status === 130) process.exit(result.status ?? 130);
26
+ if (status === 0) status = result.status ?? 1;
27
+ }
28
+ process.exit(status);
@@ -13,8 +13,20 @@ vi.mock("./app-nav", async () => {
13
13
  };
14
14
  });
15
15
 
16
+ // Stands in for the entries installed modules add to registry.svelte.ts, so the
17
+ // expected order does not depend on which modules an application installs.
18
+ vi.mock("./registry.svelte.js", async () => {
19
+ const { default: icon } = await import("@lucide/svelte/icons/newspaper");
20
+ return {
21
+ moduleNavEntries: [
22
+ { href: "/admin/blog", label: () => "Blog", icon, adminOnly: true },
23
+ { href: "/admin/reports", label: () => "Reports", icon },
24
+ ],
25
+ };
26
+ });
27
+
16
28
  describe("admin navigation", () => {
17
- it("places the application's entries before Settings", () => {
29
+ it("places the application's entries, then module entries, before Settings", () => {
18
30
  const hrefs = adminNavEntries({ auditLog: true }, "admin").map((entry) => entry.href);
19
31
 
20
32
  expect(hrefs).toEqual([
@@ -24,6 +36,8 @@ describe("admin navigation", () => {
24
36
  "/admin/audit",
25
37
  "/admin/specs",
26
38
  "/admin/help",
39
+ "/admin/blog",
40
+ "/admin/reports",
27
41
  "/admin/settings",
28
42
  ]);
29
43
  expect(adminNavEntries(undefined, "admin").find((entry) => entry.href === "/admin/specs")?.icon).toBe(
@@ -31,9 +45,9 @@ describe("admin navigation", () => {
31
45
  );
32
46
  });
33
47
 
34
- it("hides admin-only application entries from other roles", () => {
48
+ it("hides admin-only application and module entries from other roles", () => {
35
49
  const hrefs = adminNavEntries(undefined, "moderator").map((entry) => entry.href);
36
50
 
37
- expect(hrefs).toEqual(["/admin", "/admin/help"]);
51
+ expect(hrefs).toEqual(["/admin", "/admin/help", "/admin/reports"]);
38
52
  });
39
53
  });
@@ -13,7 +13,7 @@
13
13
  <DropdownMenu.Root>
14
14
  <DropdownMenu.Trigger>
15
15
  {#snippet child({ props })}
16
- <Button {...props} variant="ghost" size="icon" aria-label={i18n.t.theme.toggle}>
16
+ <Button {...props} variant="ghost" size="icon" aria-label={i18n.t.theme.toggle} data-testid="theme-toggle">
17
17
  <SunIcon class="size-4 dark:hidden" />
18
18
  <MoonIcon class="hidden size-4 dark:block" />
19
19
  </Button>
@@ -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();
@@ -1,9 +1,18 @@
1
1
  import type { Page } from "@playwright/test";
2
2
 
3
- // Wait for SvelteKit to attach client event handlers before driving forms. The
4
- // root layout adds this marker from onMount, so no fixed timing assumption is
5
- // needed even while the development server compiles a route for the first time.
3
+ /**
4
+ * Waits until SvelteKit has attached the current document's event handlers. The
5
+ * root layout adds this marker from onMount, so no fixed timing assumption is
6
+ * needed even while the development server compiles a route for the first time.
7
+ * Call it after `page.reload()` too: the reloaded document is visible, and
8
+ * clickable, before its handlers exist, so an early click does nothing.
9
+ */
10
+ export async function hydrated(page: Page): Promise<void> {
11
+ await page.locator('html[data-hydrated="true"]').waitFor();
12
+ }
13
+
14
+ /** Opens `path` and waits for hydration before a test drives the page. */
6
15
  export async function ready(page: Page, path: string): Promise<void> {
7
16
  await page.goto(path);
8
- await page.locator('html[data-hydrated="true"]').waitFor();
17
+ await hydrated(page);
9
18
  }
@@ -1,5 +1,5 @@
1
1
  import { defineConfig, devices } from "@playwright/test";
2
- import { e2eWorkers, loadPlaywrightProjects, withExclusivePhase } from "./playwright.extensions";
2
+ import { e2eRunSettings, loadPlaywrightProjects, withExclusivePhase } from "./playwright.extensions";
3
3
 
4
4
  // admin-dashboard overlay: adds a `setup` project that seeds admin/user sessions
5
5
  // (storageState) which the `ui` project reuses, and a `cleanup` project that
@@ -7,9 +7,10 @@ import { e2eWorkers, loadPlaywrightProjects, withExclusivePhase } from "./playwr
7
7
  // E2E_BASE_URL. Tests share one backend and database: E2E_WORKERS sets the
8
8
  // worker count (default 1), and tests tagged @exclusive, which change state the
9
9
  // others rely on, run one at a time in `<project>-exclusive` twins after the
10
- // others (see playwright.extensions.ts). Each file runs on one worker in order.
10
+ // others, in a second run when run-e2e.ts starts the suite on more workers (see
11
+ // playwright.extensions.ts). Each file runs on one worker in order.
11
12
  const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5001";
12
- const workers = e2eWorkers();
13
+ const run = e2eRunSettings();
13
14
  const coreProjects = [
14
15
  { name: "api", testMatch: /.*\.api\.spec\.ts/, dependencies: ["setup"] },
15
16
  { name: "setup", testMatch: /.*\.setup\.ts/, teardown: "cleanup" },
@@ -27,9 +28,10 @@ export default defineConfig({
27
28
  fullyParallel: false,
28
29
  forbidOnly: !!process.env.CI,
29
30
  retries: process.env.CI ? 2 : 0,
30
- workers,
31
+ workers: run.workers,
32
+ outputDir: run.outputDir,
31
33
  globalSetup: "./playwright.extensions.ts",
32
- reporter: [["list"], ["html", { open: "never" }]],
34
+ reporter: [["list"], ["html", { open: "never", outputFolder: run.reportFolder }]],
33
35
  use: { baseURL, trace: "on-first-retry", screenshot: "only-on-failure" },
34
- projects: withExclusivePhase([...coreProjects, ...loadPlaywrightProjects(coreProjects)], workers),
36
+ projects: withExclusivePhase([...coreProjects, ...loadPlaywrightProjects(coreProjects)], run.workers, run.phase),
35
37
  });
@@ -1,29 +1,35 @@
1
- import { expect, test } from "@playwright/test";
2
- import { ready } from "../helpers/hydration";
1
+ import { expect, test, type Page } from "@playwright/test";
2
+ import { hydrated, ready } from "../helpers/hydration";
3
+ import { paths } from "../helpers/paths";
4
+
5
+ async function chooseTheme(page: Page, mode: "Light" | "Dark" | "System"): Promise<void> {
6
+ await page.getByTestId("theme-toggle").click();
7
+ await page.getByRole("menuitem", { name: mode }).click();
8
+ }
3
9
 
4
10
  // mode-watcher marks <html> with `.dark` or `.light`, so component libraries
5
11
  // that follow either class, and fall back to the OS setting otherwise, match the
6
- // theme the visitor chose rather than the system one.
12
+ // theme the visitor chose rather than the system one. The managed ThemeToggle
13
+ // renders on the page app-paths.ts names as themeToggle.
7
14
  test("the chosen theme is named on <html> in both modes", async ({ page }) => {
15
+ test.skip(paths.themeToggle === null, "no page renders the theme toggle (tests/helpers/app-paths.ts)");
8
16
  await page.emulateMedia({ colorScheme: "dark" });
9
- await ready(page, "/account");
17
+ await ready(page, paths.themeToggle ?? "/");
10
18
  const html = page.locator("html");
11
19
 
12
- await page.getByRole("button", { name: "Toggle theme" }).click();
13
- await page.getByRole("menuitem", { name: "Light" }).click();
20
+ await chooseTheme(page, "Light");
14
21
  await expect(html).toHaveClass(/(^|\s)light(\s|$)/);
15
22
  await expect(html).not.toHaveClass(/(^|\s)dark(\s|$)/);
16
23
 
17
24
  // The class is set before the page renders, not only after a choice.
18
25
  await page.reload();
19
26
  await expect(html).toHaveClass(/(^|\s)light(\s|$)/);
27
+ await hydrated(page);
20
28
 
21
- await page.getByRole("button", { name: "Toggle theme" }).click();
22
- await page.getByRole("menuitem", { name: "Dark" }).click();
29
+ await chooseTheme(page, "Dark");
23
30
  await expect(html).toHaveClass(/(^|\s)dark(\s|$)/);
24
31
  await expect(html).not.toHaveClass(/(^|\s)light(\s|$)/);
25
32
 
26
- await page.getByRole("button", { name: "Toggle theme" }).click();
27
- await page.getByRole("menuitem", { name: "System" }).click();
33
+ await chooseTheme(page, "System");
28
34
  await expect(html).toHaveClass(/(^|\s)dark(\s|$)/);
29
35
  });