@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.
- package/README.md +1 -1
- package/dist/deploy-compose-render.js +2 -1
- package/dist/deploy-render.js +14 -8
- package/dist/lockfile.d.ts +8 -1
- package/dist/lockfile.js +22 -3
- package/dist/templates/fullstack/AGENTS.md +22 -3
- package/dist/templates/fullstack/README.md +10 -7
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +29 -0
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +3 -1
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +84 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +50 -5
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +2 -2
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +118 -0
- package/dist/templates/fullstack/apps/web/Dockerfile +1 -1
- package/dist/templates/fullstack/apps/web/src/error.html +73 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +116 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +34 -4
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +175 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +170 -11
- package/dist/templates/fullstack/apps/web/src/routes/healthz/+server.ts +13 -0
- package/dist/templates/fullstack/dot-env.docker.example +4 -0
- package/dist/templates/fullstack/dot-env.example +7 -0
- package/dist/templates/fullstack/infra/k3s/web-deployment.yaml +6 -3
- package/dist/templates/fullstack/tests/api/health.api.spec.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +32 -3
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +19 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +24 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +5 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +167 -154
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +94 -57
- package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +75 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +49 -39
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +80 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +47 -43
- package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +15 -11
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +35 -31
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +90 -76
- package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +19 -15
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +17 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +15 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +71 -64
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +89 -79
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +54 -50
- package/dist/templates/modules/audit-log/module.manifest.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +11 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +83 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +51 -7
- package/dist/templates/modules/auth/module.manifest.json +0 -1
- package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +12 -6
- package/dist/templates/modules/file-upload/module.manifest.json +2 -2
- package/dist/templates/todo/AGENTS.md +10 -3
- package/dist/templates/todo/README.md +10 -8
- package/package.json +1 -1
- 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:
|
|
27
|
+
httpGet: { path: /healthz, port: 3000 }
|
|
27
28
|
initialDelaySeconds: 5
|
|
29
|
+
timeoutSeconds: 2
|
|
28
30
|
livenessProbe:
|
|
29
|
-
httpGet: { path:
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
});
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts
CHANGED
|
@@ -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
|
});
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts
CHANGED
|
@@ -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
|
-
|
|
21
|
-
await
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
});
|