@podosoft/podokit 1.4.0 → 1.4.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 (56) hide show
  1. package/README.md +1 -1
  2. package/dist/deploy-compose-render.js +2 -1
  3. package/dist/deploy-render.js +14 -8
  4. package/dist/lockfile.d.ts +8 -1
  5. package/dist/lockfile.js +22 -3
  6. package/dist/templates/fullstack/AGENTS.md +22 -3
  7. package/dist/templates/fullstack/README.md +10 -7
  8. package/dist/templates/fullstack/apps/api/package.json +1 -1
  9. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +29 -0
  10. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +3 -1
  11. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +84 -0
  12. package/dist/templates/fullstack/apps/api/src/database/database.ts +50 -5
  13. package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +2 -2
  14. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +118 -0
  15. package/dist/templates/fullstack/apps/web/Dockerfile +1 -1
  16. package/dist/templates/fullstack/apps/web/src/error.html +73 -0
  17. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +116 -0
  18. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +34 -4
  19. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +175 -0
  20. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +170 -11
  21. package/dist/templates/fullstack/apps/web/src/routes/healthz/+server.ts +13 -0
  22. package/dist/templates/fullstack/dot-env.docker.example +4 -0
  23. package/dist/templates/fullstack/dot-env.example +7 -0
  24. package/dist/templates/fullstack/infra/k3s/web-deployment.yaml +6 -3
  25. package/dist/templates/fullstack/tests/api/health.api.spec.ts +8 -0
  26. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +32 -3
  27. package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +19 -1
  28. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +24 -1
  29. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +6 -0
  30. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +5 -0
  31. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +167 -154
  32. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +94 -57
  33. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +75 -0
  34. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +49 -39
  35. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +80 -0
  36. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +47 -43
  37. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +15 -11
  38. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +35 -31
  39. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +90 -76
  40. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +19 -15
  41. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +17 -3
  42. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +15 -6
  43. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +71 -64
  44. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +89 -79
  45. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +54 -50
  46. package/dist/templates/modules/audit-log/module.manifest.json +1 -1
  47. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +11 -5
  48. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +83 -0
  49. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +51 -7
  50. package/dist/templates/modules/auth/module.manifest.json +0 -1
  51. package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +12 -6
  52. package/dist/templates/modules/file-upload/module.manifest.json +2 -2
  53. package/dist/templates/todo/AGENTS.md +10 -3
  54. package/dist/templates/todo/README.md +10 -8
  55. package/package.json +1 -1
  56. package/dist/templates/modules/auth/files/apps/api/scripts/run-migrations.mjs +0 -14
@@ -21,10 +21,13 @@ spec:
21
21
  envFrom:
22
22
  - configMapRef: { name: app-config }
23
23
  # Without these a rolling update sends traffic to a process that has not
24
- # bound its port yet.
24
+ # bound its port yet. /healthz never calls the API, so an API outage does
25
+ # not take the web pods out of service.
25
26
  readinessProbe:
26
- httpGet: { path: /, port: 3000 }
27
+ httpGet: { path: /healthz, port: 3000 }
27
28
  initialDelaySeconds: 5
29
+ timeoutSeconds: 2
28
30
  livenessProbe:
29
- httpGet: { path: /, port: 3000 }
31
+ httpGet: { path: /healthz, port: 3000 }
30
32
  initialDelaySeconds: 15
33
+ timeoutSeconds: 2
@@ -12,3 +12,11 @@ test("GET /health/ready reports the database", async ({ request }) => {
12
12
  expect(res.status()).toBe(200);
13
13
  expect((await res.json()).status).toBe("ready");
14
14
  });
15
+
16
+ test("GET /healthz answers from the web server without caching or indexing @smoke", async ({ request }) => {
17
+ const res = await request.get("/healthz");
18
+ expect(res.status()).toBe(200);
19
+ expect(await res.text()).toBe("ok\n");
20
+ expect(res.headers()["cache-control"]).toBe("no-store");
21
+ expect(res.headers()["x-robots-tag"]).toBe("noindex, nofollow");
22
+ });
@@ -4,11 +4,16 @@ import type { QueryRunner } from "typeorm";
4
4
  import { revertLatestSqliteMigration, runSqliteMigrations } from "../database/sqlite-migrator";
5
5
  import { IndexSessionCreatedAt1792060000000 } from "../migrations/1792060000000-IndexSessionCreatedAt";
6
6
 
7
- function runner(type: "postgres" | "better-sqlite3", queries: string[], invalid = false): QueryRunner {
7
+ function runner(
8
+ type: "postgres" | "better-sqlite3",
9
+ queries: string[],
10
+ { invalid = false, session = true }: { invalid?: boolean; session?: boolean } = {},
11
+ ): QueryRunner {
8
12
  return {
9
13
  connection: { options: { type } },
10
14
  query: (sql: string): Promise<unknown[]> => {
11
15
  queries.push(sql);
16
+ if (sql.includes("IS NOT NULL")) return Promise.resolve([{ exists: session }]);
12
17
  return Promise.resolve(invalid && sql.includes("indisvalid") ? [{ invalid: 1 }] : []);
13
18
  },
14
19
  } as unknown as QueryRunner;
@@ -25,6 +30,7 @@ function rows<Row>(database: Database, sql: string): Row[] {
25
30
  }
26
31
 
27
32
  const CREATE = 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_session_createdAt_id" ON "session" ("createdAt", "id")';
33
+ const SESSION_EXISTS = 'SELECT to_regclass($1) IS NOT NULL AS "exists"';
28
34
 
29
35
  describe("admin session list index migration", () => {
30
36
  it("builds the PostgreSQL index concurrently outside a transaction", async () => {
@@ -33,6 +39,7 @@ describe("admin session list index migration", () => {
33
39
  const up: string[] = [];
34
40
  await migration.up(runner("postgres", up));
35
41
  expect(up).toEqual([
42
+ SESSION_EXISTS,
36
43
  "SELECT 1 AS \"invalid\" FROM pg_index WHERE indexrelid = to_regclass($1) AND NOT indisvalid",
37
44
  CREATE,
38
45
  ]);
@@ -44,8 +51,30 @@ describe("admin session list index migration", () => {
44
51
 
45
52
  it("rebuilds an invalid index left by an interrupted build", async () => {
46
53
  const up: string[] = [];
47
- await new IndexSessionCreatedAt1792060000000().up(runner("postgres", up, true));
48
- expect(up.slice(1)).toEqual(['DROP INDEX CONCURRENTLY IF EXISTS "IDX_session_createdAt_id"', CREATE]);
54
+ await new IndexSessionCreatedAt1792060000000().up(runner("postgres", up, { invalid: true }));
55
+ expect(up.slice(2)).toEqual(['DROP INDEX CONCURRENTLY IF EXISTS "IDX_session_createdAt_id"', CREATE]);
56
+ });
57
+
58
+ // `migration:run` runs only the application migrations, so on a new database
59
+ // Better Auth has not created its tables yet.
60
+ it("fails with the command that creates the Better Auth tables on PostgreSQL", async () => {
61
+ const up: string[] = [];
62
+ await expect(new IndexSessionCreatedAt1792060000000().up(runner("postgres", up, { session: false })))
63
+ .rejects.toThrow('Better Auth has not created the "session" table yet. Run "bun run --cwd apps/api migrate:all"');
64
+ expect(up).toEqual([SESSION_EXISTS]);
65
+ });
66
+
67
+ it("fails with the command that creates the Better Auth tables on SQLite and records nothing", async () => {
68
+ const database = new Database(":memory:");
69
+ try {
70
+ const migrations = [{ timestamp: 1792060000000, migration: new IndexSessionCreatedAt1792060000000() }];
71
+ await expect(runSqliteMigrations(database, migrations)).rejects.toThrow(
72
+ 'Better Auth has not created the "session" table yet. Run "bun run --cwd apps/api migrate:all"',
73
+ );
74
+ expect(rows(database, 'SELECT "name" FROM "migrations"')).toEqual([]);
75
+ } finally {
76
+ database.close();
77
+ }
49
78
  });
50
79
 
51
80
  it("serves the newest-first session page on SQLite and reverts", async () => {
@@ -5,6 +5,12 @@ import type { MigrationInterface, QueryRunner } from "typeorm";
5
5
  // so without this index every page sorts the whole session table.
6
6
  const INDEX = "IDX_session_createdAt_id";
7
7
 
8
+ // Better Auth creates "session" in its own schema step. `migrate:all` runs that
9
+ // step before the application migrations; `migration:run` does not.
10
+ const MISSING_SESSION_TABLE =
11
+ 'Better Auth has not created the "session" table yet. Run "bun run --cwd apps/api migrate:all", ' +
12
+ "which applies the Better Auth schema before the application migrations.";
13
+
8
14
  export class IndexSessionCreatedAt1792060000000 implements MigrationInterface {
9
15
  name = "IndexSessionCreatedAt1792060000000";
10
16
  // CONCURRENTLY cannot run inside a transaction block. The runner therefore
@@ -13,9 +19,21 @@ export class IndexSessionCreatedAt1792060000000 implements MigrationInterface {
13
19
 
14
20
  async up(queryRunner: QueryRunner): Promise<void> {
15
21
  if (queryRunner.connection.options.type !== "postgres") {
16
- await queryRunner.query(`CREATE INDEX IF NOT EXISTS "${INDEX}" ON "session" ("createdAt", "id")`);
22
+ try {
23
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS "${INDEX}" ON "session" ("createdAt", "id")`);
24
+ } catch (error) {
25
+ if (error instanceof Error && /no such table: (?:main\.)?session$/.test(error.message)) {
26
+ throw new Error(MISSING_SESSION_TABLE, { cause: error });
27
+ }
28
+ throw error;
29
+ }
17
30
  return;
18
31
  }
32
+ const [session] = await queryRunner.query(
33
+ `SELECT to_regclass($1) IS NOT NULL AS "exists"`,
34
+ [`"session"`],
35
+ ) as Array<{ exists: boolean } | undefined>;
36
+ if (!session?.exists) throw new Error(MISSING_SESSION_TABLE);
19
37
  // Sign-ins keep writing sessions while the index builds. An interrupted
20
38
  // build leaves an invalid index that IF NOT EXISTS would keep.
21
39
  const invalid = await queryRunner.query(
@@ -1,6 +1,7 @@
1
1
  import { ApiError } from "@podosoft/podokit-api-client";
2
2
  import type { RequestEvent } from "@sveltejs/kit";
3
3
  import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { BackendUnavailableError } from "#lib/server/api.js";
4
5
  import { handle } from "./hooks.server";
5
6
 
6
7
  type SessionResult = {
@@ -13,7 +14,8 @@ const client = vi.hoisted(() => ({
13
14
  getSession: vi.fn<() => Promise<SessionResult>>(),
14
15
  }));
15
16
 
16
- vi.mock("#lib/server/api.js", () => ({
17
+ vi.mock("#lib/server/api.js", async (importOriginal) => ({
18
+ ...(await importOriginal<typeof import("#lib/server/api.js")>()),
17
19
  serverApiClient: () => ({ get: client.get, auth: { getSession: client.getSession } }),
18
20
  }));
19
21
 
@@ -98,6 +100,16 @@ describe("handle", () => {
98
100
  });
99
101
  });
100
102
 
103
+ it("treats an API known to be unreachable as an outage", async () => {
104
+ client.get.mockRejectedValue(new BackendUnavailableError());
105
+ client.getSession.mockRejectedValue(new BackendUnavailableError());
106
+
107
+ await expect(handle({ event: requestEvent("/account"), resolve })).rejects.toMatchObject({ status: 503 });
108
+ const event = requestEvent("/");
109
+ await handle({ event, resolve });
110
+ expect(event.locals).toMatchObject({ site: null, siteUnavailable: true, authUnavailable: true, user: null });
111
+ });
112
+
101
113
  it("confirms an anonymous visitor on a 401 session response", async () => {
102
114
  client.get.mockResolvedValue({ locale: null });
103
115
  client.getSession.mockResolvedValue({ data: null, error: { status: 401 } });
@@ -121,4 +133,15 @@ describe("handle", () => {
121
133
  expect(client.get).not.toHaveBeenCalled();
122
134
  expect(client.getSession).not.toHaveBeenCalled();
123
135
  });
136
+
137
+ it("answers the health route without runtime lookups", async () => {
138
+ client.get.mockRejectedValue(new BackendUnavailableError());
139
+ client.getSession.mockRejectedValue(new BackendUnavailableError());
140
+
141
+ await handle({ event: requestEvent("/healthz"), resolve });
142
+
143
+ expect(client.get).not.toHaveBeenCalled();
144
+ expect(client.getSession).not.toHaveBeenCalled();
145
+ expect(resolve).toHaveBeenCalledTimes(1);
146
+ });
124
147
  });
@@ -61,6 +61,12 @@ describe("needsRuntimeContext", () => {
61
61
  expect(needsRuntimeContext(request("/apiary", { headers: { accept: NAVIGATION } }))).toBe(true);
62
62
  });
63
63
 
64
+ it("skips them for the web server's health route, which must answer while the API is down", () => {
65
+ expect(needsRuntimeContext(request("/healthz", { headers: { accept: "*/*" } }))).toBe(false);
66
+ expect(needsRuntimeContext(request("/healthz", { headers: { accept: NAVIGATION } }))).toBe(false);
67
+ expect(needsRuntimeContext(request("/healthz/extra", { routeId: null, headers: { accept: NAVIGATION } }))).toBe(true);
68
+ });
69
+
64
70
  it("skips them while prerendering", () => {
65
71
  expect(needsRuntimeContext(request("/pricing", { headers: { accept: NAVIGATION } }), { building: true })).toBe(false);
66
72
  });
@@ -1,5 +1,8 @@
1
1
  import { APP_ASSET_PATHS } from "./asset-paths";
2
2
 
3
+ /** The web server's own health route (`routes/healthz/+server.ts`), which never calls the API. */
4
+ const WEB_HEALTH_PATH = "/healthz";
5
+
3
6
  /** The request fields that decide whether a response can use the session and site settings. */
4
7
  export interface RuntimeContextRequest {
5
8
  url: URL;
@@ -92,6 +95,8 @@ export function needsRuntimeContext(
92
95
  const { pathname } = event.url;
93
96
  // The backend proxy forwards the cookies; the API resolves the session itself.
94
97
  if (underPrefix(pathname, "/api")) return false;
98
+ // The web server's health route answers the probes even while the API is down.
99
+ if (pathname === WEB_HEALTH_PATH) return false;
95
100
  // Remote functions run application code that may read the session.
96
101
  if (event.isRemoteRequest) return true;
97
102
  if (event.route.id === null) {
@@ -4,6 +4,7 @@ import {
4
4
  isUserCreationAllowed,
5
5
  } from "../../apps/api/src/auth/feature-gate";
6
6
  import { ADMIN } from "../helpers/accounts";
7
+ import { exclusive } from "../helpers/exclusive";
7
8
 
8
9
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
9
10
  const origin = { origin: base };
@@ -17,62 +18,68 @@ async function adminCtx(playwright: import("@playwright/test").PlaywrightWorkerA
17
18
  }
18
19
 
19
20
  test("admin can enable Google OAuth from the DB and it applies without a restart", async ({ playwright }) => {
20
- const admin = await adminCtx(playwright);
21
- await admin.put("/api/account/auth-config", { data: { social: { google: { enabled: false } } } }); // clean start
22
- const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
23
- const social = () => anon.post("/api/auth/sign-in/social", { data: { provider: "google", callbackURL: `${base}/admin` } });
24
-
25
- // Before: provider not configured.
26
- await expect.poll(async () => (await social()).status(), { timeout: 8000 }).toBe(404);
27
-
28
- // Paste credentials via the admin API.
29
- const put = await admin.put("/api/account/auth-config", {
30
- data: { social: { google: { enabled: true, clientId: "dummy-google-client-id", clientSecret: "dummy-google-client-secret" } } },
21
+ test.slow();
22
+ await exclusive("social-providers", async () => {
23
+ const admin = await adminCtx(playwright);
24
+ await admin.put("/api/account/auth-config", { data: { social: { google: { enabled: false } } } }); // clean start
25
+ const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
26
+ const social = () => anon.post("/api/auth/sign-in/social", { data: { provider: "google", callbackURL: `${base}/admin` } });
27
+
28
+ // Before: provider not configured.
29
+ await expect.poll(async () => (await social()).status(), { timeout: 8000 }).toBe(404);
30
+
31
+ // Paste credentials via the admin API.
32
+ const put = await admin.put("/api/account/auth-config", {
33
+ data: { social: { google: { enabled: true, clientId: "dummy-google-client-id", clientSecret: "dummy-google-client-secret" } } },
34
+ });
35
+ expect(put.ok()).toBeTruthy();
36
+ const view = await put.json();
37
+ expect(view.social.google.enabled).toBe(true);
38
+ expect(view.social.google.hasSecret).toBe(true);
39
+ expect(view.social.google.clientId).toBe("dummy-google-client-id");
40
+ expect("clientSecret" in view.social.google).toBe(false); // secret is never returned
41
+
42
+ // Applied live (no restart): social sign-in now redirects to Google.
43
+ await expect
44
+ .poll(async () => {
45
+ const r = await social();
46
+ return r.ok() ? (((await r.json())?.url as string) ?? "") : "";
47
+ }, { timeout: 8000 })
48
+ .toContain("accounts.google.com");
49
+ const caps = await (await anon.get("/api/account/capabilities")).json();
50
+ expect(caps.providers).toContain("google");
51
+
52
+ await admin.put("/api/account/auth-config", { data: { social: { google: { enabled: false } } } }); // restore
53
+ await anon.dispose();
54
+ await admin.dispose();
31
55
  });
32
- expect(put.ok()).toBeTruthy();
33
- const view = await put.json();
34
- expect(view.social.google.enabled).toBe(true);
35
- expect(view.social.google.hasSecret).toBe(true);
36
- expect(view.social.google.clientId).toBe("dummy-google-client-id");
37
- expect("clientSecret" in view.social.google).toBe(false); // secret is never returned
38
-
39
- // Applied live (no restart): social sign-in now redirects to Google.
40
- await expect
41
- .poll(async () => {
42
- const r = await social();
43
- return r.ok() ? (((await r.json())?.url as string) ?? "") : "";
44
- }, { timeout: 8000 })
45
- .toContain("accounts.google.com");
46
- const caps = await (await anon.get("/api/account/capabilities")).json();
47
- expect(caps.providers).toContain("google");
48
-
49
- await admin.put("/api/account/auth-config", { data: { social: { google: { enabled: false } } } }); // restore
50
- await anon.dispose();
51
- await admin.dispose();
52
56
  });
53
57
 
54
58
  test("admin can add and remove a social provider dynamically", async ({ playwright }) => {
55
- const admin = await adminCtx(playwright);
56
- // The catalog offers providers beyond the two legacy env-backed ones.
57
- const catalog = (await (await admin.get("/api/account/auth-config")).json()).catalog as Array<{ id: string }>;
58
- expect(catalog.map((c) => c.id)).toEqual(expect.arrayContaining(["google", "github", "apple"]));
59
+ test.slow();
60
+ await exclusive("social-providers", async () => {
61
+ const admin = await adminCtx(playwright);
62
+ // The catalog offers providers beyond the two legacy env-backed ones.
63
+ const catalog = (await (await admin.get("/api/account/auth-config")).json()).catalog as Array<{ id: string }>;
64
+ expect(catalog.map((c) => c.id)).toEqual(expect.arrayContaining(["google", "github", "apple"]));
65
+
66
+ const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
67
+ const github = () => anon.post("/api/auth/sign-in/social", { data: { provider: "github", callbackURL: `${base}/admin` } });
68
+
69
+ // Add GitHub (a provider with no env fallback) → applies live, no restart.
70
+ await admin.put("/api/account/auth-config", { data: { social: { github: { enabled: true, clientId: "dummy-github-client-id", clientSecret: "dummy-github-client-secret" } } } });
71
+ await expect
72
+ .poll(async () => { const r = await github(); return r.ok() ? (((await r.json())?.url as string) ?? "") : ""; }, { timeout: 8000 })
73
+ .toContain("github.com");
74
+
75
+ // Remove it → the row is deleted and the provider stops working (again live).
76
+ await admin.put("/api/account/auth-config", { data: { social: { github: { delete: true } } } });
77
+ expect("github" in (await (await admin.get("/api/account/auth-config")).json()).social).toBe(false);
78
+ await expect.poll(async () => (await github()).status(), { timeout: 8000 }).toBe(404);
59
79
 
60
- const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
61
- const github = () => anon.post("/api/auth/sign-in/social", { data: { provider: "github", callbackURL: `${base}/admin` } });
62
-
63
- // Add GitHub (a provider with no env fallback) → applies live, no restart.
64
- await admin.put("/api/account/auth-config", { data: { social: { github: { enabled: true, clientId: "dummy-github-client-id", clientSecret: "dummy-github-client-secret" } } } });
65
- await expect
66
- .poll(async () => { const r = await github(); return r.ok() ? (((await r.json())?.url as string) ?? "") : ""; }, { timeout: 8000 })
67
- .toContain("github.com");
68
-
69
- // Remove it → the row is deleted and the provider stops working (again live).
70
- await admin.put("/api/account/auth-config", { data: { social: { github: { delete: true } } } });
71
- expect("github" in (await (await admin.get("/api/account/auth-config")).json()).social).toBe(false);
72
- await expect.poll(async () => (await github()).status(), { timeout: 8000 }).toBe(404);
73
-
74
- await anon.dispose();
75
- await admin.dispose();
80
+ await anon.dispose();
81
+ await admin.dispose();
82
+ });
76
83
  });
77
84
 
78
85
  test("auth-config is admin-only and never leaks secrets @smoke", async ({ playwright }) => {
@@ -85,43 +92,46 @@ test("auth-config is admin-only and never leaks secrets @smoke", async ({ playwr
85
92
  });
86
93
 
87
94
  test("automatic logout validates and applies a sliding session lifetime", async ({ playwright }) => {
88
- const admin = await adminCtx(playwright);
89
- const freshAdmin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
90
-
91
- try {
92
- expect(
93
- (await admin.put("/api/account/auth-config", {
94
- data: { server: { sessionIdleTimeoutMinutes: 4 } },
95
- })).status(),
96
- ).toBe(400);
97
-
98
- const enabled = await admin.put("/api/account/auth-config", {
99
- data: { server: { sessionIdleTimeoutMinutes: 30 } },
100
- });
101
- expect(enabled.ok()).toBeTruthy();
102
- expect((await enabled.json()).server.sessionIdleTimeoutMinutes).toBe(30);
103
-
104
- const caps = await (await admin.get("/api/account/capabilities")).json();
105
- expect(caps.sessionIdleTimeoutMinutes).toBe(30);
106
-
107
- const signIn = await freshAdmin.post("/api/auth/sign-in/email", {
108
- data: { email: ADMIN.email, password: ADMIN.password },
109
- });
110
- expect(signIn.ok()).toBeTruthy();
111
- expect(signIn.headers()["set-cookie"]).toMatch(/Max-Age=1800(?:;|,)/i);
112
-
113
- const sessions = (await (await admin.get("/api/auth/list-sessions")).json()) as Array<{
114
- expiresAt: string;
115
- }>;
116
- const latestExpiry = Math.max(...sessions.map((session) => new Date(session.expiresAt).getTime()));
117
- expect(latestExpiry).toBeLessThanOrEqual(Date.now() + 30 * 60_000 + 10_000);
118
- } finally {
119
- await admin.put("/api/account/auth-config", {
120
- data: { server: { sessionIdleTimeoutMinutes: null } },
121
- });
122
- await freshAdmin.dispose();
123
- await admin.dispose();
124
- }
95
+ test.slow();
96
+ await exclusive("session-idle-timeout", async () => {
97
+ const admin = await adminCtx(playwright);
98
+ const freshAdmin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
99
+
100
+ try {
101
+ expect(
102
+ (await admin.put("/api/account/auth-config", {
103
+ data: { server: { sessionIdleTimeoutMinutes: 4 } },
104
+ })).status(),
105
+ ).toBe(400);
106
+
107
+ const enabled = await admin.put("/api/account/auth-config", {
108
+ data: { server: { sessionIdleTimeoutMinutes: 30 } },
109
+ });
110
+ expect(enabled.ok()).toBeTruthy();
111
+ expect((await enabled.json()).server.sessionIdleTimeoutMinutes).toBe(30);
112
+
113
+ const caps = await (await admin.get("/api/account/capabilities")).json();
114
+ expect(caps.sessionIdleTimeoutMinutes).toBe(30);
115
+
116
+ const signIn = await freshAdmin.post("/api/auth/sign-in/email", {
117
+ data: { email: ADMIN.email, password: ADMIN.password },
118
+ });
119
+ expect(signIn.ok()).toBeTruthy();
120
+ expect(signIn.headers()["set-cookie"]).toMatch(/Max-Age=1800(?:;|,)/i);
121
+
122
+ const sessions = (await (await admin.get("/api/auth/list-sessions")).json()) as Array<{
123
+ expiresAt: string;
124
+ }>;
125
+ const latestExpiry = Math.max(...sessions.map((session) => new Date(session.expiresAt).getTime()));
126
+ expect(latestExpiry).toBeLessThanOrEqual(Date.now() + 30 * 60_000 + 10_000);
127
+ } finally {
128
+ await admin.put("/api/account/auth-config", {
129
+ data: { server: { sessionIdleTimeoutMinutes: null } },
130
+ });
131
+ await freshAdmin.dispose();
132
+ await admin.dispose();
133
+ }
134
+ });
125
135
  });
126
136
 
127
137
  test("new registrations require approval when the policy is enabled", async ({ playwright }) => {
@@ -204,75 +214,78 @@ test("new registrations require approval when the policy is enabled", async ({ p
204
214
  });
205
215
 
206
216
  test("closed public sign-up blocks new OAuth users but preserves existing sign-in", async ({ playwright }) => {
207
- const admin = await adminCtx(playwright);
208
- const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
209
- const password = "Podokit3e-Str0ng!pw";
210
- const adminCreatedEmail = `closed-admin-${Date.now()}@example.com`;
211
- const initialSite = (await (await anon.get("/api/site/settings")).json()) as {
212
- allowSignup?: string | null;
213
- };
214
- const initialAllowSignup = initialSite.allowSignup === "false" ? "false" : "true";
215
- let adminCreatedId = "";
216
-
217
- try {
218
- expect(
219
- (await admin.put("/api/account/auth-config", {
220
- data: {
221
- social: {
222
- google: {
223
- enabled: true,
224
- clientId: "dummy-google-client-id",
225
- clientSecret: "dummy-google-client-secret",
217
+ test.slow();
218
+ await exclusive(["site-general", "social-providers"], async () => {
219
+ const admin = await adminCtx(playwright);
220
+ const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
221
+ const password = "Podokit3e-Str0ng!pw";
222
+ const adminCreatedEmail = `closed-admin-${Date.now()}@example.com`;
223
+ const initialSite = (await (await anon.get("/api/site/settings")).json()) as {
224
+ allowSignup?: string | null;
225
+ };
226
+ const initialAllowSignup = initialSite.allowSignup === "false" ? "false" : "true";
227
+ let adminCreatedId = "";
228
+
229
+ try {
230
+ expect(
231
+ (await admin.put("/api/account/auth-config", {
232
+ data: {
233
+ social: {
234
+ google: {
235
+ enabled: true,
236
+ clientId: "dummy-google-client-id",
237
+ clientSecret: "dummy-google-client-secret",
238
+ },
226
239
  },
227
240
  },
228
- },
229
- })).ok(),
230
- ).toBeTruthy();
231
- expect((await admin.put("/api/site/settings", { data: { allowSignup: "false" } })).ok()).toBeTruthy();
241
+ })).ok(),
242
+ ).toBeTruthy();
243
+ expect((await admin.put("/api/site/settings", { data: { allowSignup: "false" } })).ok()).toBeTruthy();
232
244
 
233
- // The site policy uses a short cache shared by sign-up requests and user
234
- // creation hooks, so wait for the setting to become live.
235
- await new Promise((resolve) => setTimeout(resolve, 3_200));
236
-
237
- const signup = await anon.post("/api/auth/sign-up/email", {
238
- data: {
239
- email: `closed-${Date.now()}@example.com`,
240
- password,
241
- name: "Closed Registration",
242
- },
243
- });
244
- expect(signup.status()).toBe(403);
245
- expect((await signup.json()).code).toBe(PUBLIC_SIGNUP_DISABLED);
245
+ // The site policy uses a short cache shared by sign-up requests and user
246
+ // creation hooks, so wait for the setting to become live.
247
+ await new Promise((resolve) => setTimeout(resolve, 3_200));
246
248
 
247
- // OAuth initiation must stay available for existing social users. Only a
248
- // callback that attempts to create a new user is rejected by the DB hook.
249
- const social = await anon.post("/api/auth/sign-in/social", {
250
- data: { provider: "google", callbackURL: `${base}/` },
251
- });
252
- expect(social.ok()).toBeTruthy();
253
- expect(((await social.json()).url as string)).toContain("accounts.google.com");
254
- expect(isUserCreationAllowed(false, "/callback/google")).toBe(false);
255
- expect(isUserCreationAllowed(false, undefined)).toBe(false);
256
-
257
- // Deliberate administrator provisioning remains available while the public
258
- // site is closed.
259
- expect(isUserCreationAllowed(false, "/admin/create-user")).toBe(true);
260
- const adminCreated = await admin.post("/api/auth/admin/create-user", {
261
- data: { email: adminCreatedEmail, password, name: "Admin Created", role: "user" },
262
- });
263
- expect(adminCreated.ok()).toBeTruthy();
264
- adminCreatedId = ((await adminCreated.json()).user as { id: string }).id;
265
- } finally {
266
- if (adminCreatedId) {
267
- await admin.post("/api/auth/admin/remove-user", { data: { userId: adminCreatedId } });
249
+ const signup = await anon.post("/api/auth/sign-up/email", {
250
+ data: {
251
+ email: `closed-${Date.now()}@example.com`,
252
+ password,
253
+ name: "Closed Registration",
254
+ },
255
+ });
256
+ expect(signup.status()).toBe(403);
257
+ expect((await signup.json()).code).toBe(PUBLIC_SIGNUP_DISABLED);
258
+
259
+ // OAuth initiation must stay available for existing social users. Only a
260
+ // callback that attempts to create a new user is rejected by the DB hook.
261
+ const social = await anon.post("/api/auth/sign-in/social", {
262
+ data: { provider: "google", callbackURL: `${base}/` },
263
+ });
264
+ expect(social.ok()).toBeTruthy();
265
+ expect(((await social.json()).url as string)).toContain("accounts.google.com");
266
+ expect(isUserCreationAllowed(false, "/callback/google")).toBe(false);
267
+ expect(isUserCreationAllowed(false, undefined)).toBe(false);
268
+
269
+ // Deliberate administrator provisioning remains available while the public
270
+ // site is closed.
271
+ expect(isUserCreationAllowed(false, "/admin/create-user")).toBe(true);
272
+ const adminCreated = await admin.post("/api/auth/admin/create-user", {
273
+ data: { email: adminCreatedEmail, password, name: "Admin Created", role: "user" },
274
+ });
275
+ expect(adminCreated.ok()).toBeTruthy();
276
+ adminCreatedId = ((await adminCreated.json()).user as { id: string }).id;
277
+ } finally {
278
+ if (adminCreatedId) {
279
+ await admin.post("/api/auth/admin/remove-user", { data: { userId: adminCreatedId } });
280
+ }
281
+ await admin.put("/api/site/settings", { data: { allowSignup: initialAllowSignup } });
282
+ await admin.put("/api/account/auth-config", {
283
+ data: { social: { google: { enabled: false } } },
284
+ });
285
+ await new Promise((resolve) => setTimeout(resolve, 3_200));
286
+ await anon.get("/api/auth/get-session");
287
+ await anon.dispose();
288
+ await admin.dispose();
268
289
  }
269
- await admin.put("/api/site/settings", { data: { allowSignup: initialAllowSignup } });
270
- await admin.put("/api/account/auth-config", {
271
- data: { social: { google: { enabled: false } } },
272
- });
273
- await new Promise((resolve) => setTimeout(resolve, 3_200));
274
- await anon.get("/api/auth/get-session");
275
- await anon.dispose();
276
- await admin.dispose();
277
- }
290
+ });
278
291
  });