feat(server): randomize the seeded admin initial password (#172)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e05dea536b
commit
045ac250e0
35 files changed
+302
-58
No files matched your search
@@ -36,8 +36,8 @@ pnpm --filter @prismshadow/penguin-server start # node dist/index.js
|
||||
## Security notes (known MVP limits)
|
||||
|
||||
- **CSRF**: session cookie is `SameSite=Lax` and writes accept only `Content-Type: application/json`; no CSRF token yet.
|
||||
- **No login rate limiting**: add throttling at a reverse proxy for public deployments.
|
||||
- **Built-in admin starts as `admin` / `penguin-2026`**: change it immediately (a banner keeps reminding until you do).
|
||||
- **Login throttling**: per-username exponential backoff after 5 consecutive failures (1s doubling to a 60s cap, `429 too_many_attempts` inside the window, reset on success; in-memory, so a restart clears it). Unknown usernames are throttled identically, so it is not an account-existence oracle. A reverse proxy can still add IP-level limits for public deployments.
|
||||
- **Built-in admin `admin` starts with a random initial password** of the form `penguin-1234`, printed once to the server console at seed time (`PENGUIN_SEED_ADMIN_PASSWORD` pins it for tests/e2e): change it immediately (a banner keeps reminding until you do).
|
||||
- Passwords use `node:crypto` scrypt (`scrypt$N$r$p$salt$hash`, timingSafeEqual); login sessions renew on a 7-day sliding window; the DB stores only the token's sha256.
|
||||
- Model credentials live in the Project's hidden 0600 config file; the API always masks them.
|
||||
- Behind a reverse proxy, disable response buffering for SSE paths (the server already sends `X-Accel-Buffering: no`) and forward `x-forwarded-proto` to enable Secure cookies.
|
||||
|
||||
@@ -214,6 +214,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
|
||||
authSessions: authSessionsRepo,
|
||||
provisionInitialProject: (user, isAdmin) =>
|
||||
projectService.provisionInitialProject(user, isAdmin),
|
||||
seedAdminPassword: config.seedAdminPassword,
|
||||
sessionTtlMs: config.authSessionTtlMs,
|
||||
sessionRenewMs: config.authSessionRenewMs,
|
||||
...(overrides.now ? { now: overrides.now } : {}),
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
* login / logout / password change / session validation.
|
||||
*
|
||||
* - No open registration: on startup, if there are no users at all, the built-in
|
||||
* admin `admin` is seeded (initial password penguin-2026), and it adopts
|
||||
* `default_project`; all other users are created by an admin via the user
|
||||
* backend (admin-service).
|
||||
* admin `admin` is seeded with a random `penguin-<4 digits>` initial password
|
||||
* (printed once by the startup entrypoint; PENGUIN_SEED_ADMIN_PASSWORD injects
|
||||
* a fixed one for tests/e2e), and it adopts `default_project`; all other users
|
||||
* are created by an admin via the user backend (admin-service).
|
||||
* - An initial password (whether seeded or set by an admin) is flagged with
|
||||
* password_is_initial, which the frontend uses to prompt for a password change soon.
|
||||
* - Sessions: a 32-byte random token, with only its sha256 hash stored in the DB;
|
||||
* valid for 7 days, with sliding renewal once less than 6 days remain.
|
||||
*/
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { createHash, randomBytes, randomInt } from "node:crypto";
|
||||
import type { UserInfo } from "../api/types.js";
|
||||
import { HttpError } from "../http/errors.js";
|
||||
import type { AuthSessionsRepo } from "../db/repos/auth-sessions.js";
|
||||
@@ -20,9 +21,37 @@ import { hashPassword, verifyPassword } from "./password.js";
|
||||
|
||||
export const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
/** Built-in admin: user_id and initial password (matches the README and login-page hint). */
|
||||
/** Built-in admin user_id. */
|
||||
export const ADMIN_USER_ID = "admin";
|
||||
export const ADMIN_INITIAL_PASSWORD = "penguin-2026";
|
||||
|
||||
/**
|
||||
* Login throttling (per userId): the seeded initial password is `penguin-<4 digits>` —
|
||||
* 10,000 combinations — so unthrottled guessing would enumerate it in minutes. After
|
||||
* LOGIN_FREE_ATTEMPTS consecutive failures, the next attempt is admitted only after an
|
||||
* exponentially growing delay from the last failure (1s, 2s, … capped at 60s; attempts
|
||||
* inside the window are 429 `too_many_attempts` and do not extend it). Beyond ~40
|
||||
* failures that is one guess per minute, so the 10k space stops being enumerable, while
|
||||
* a legitimate user who mistyped a few times never waits more than the cap. A successful
|
||||
* login clears the counter. Counters are process memory (a restart clears them —
|
||||
* restarting is slower than waiting out the cap) and are kept for nonexistent userIds
|
||||
* too, so throttling is not an account-existence oracle. Known limit: a concurrent burst
|
||||
* can slip in before its first failure is recorded; the steady-state backoff still
|
||||
* dominates the search space.
|
||||
*/
|
||||
const LOGIN_FREE_ATTEMPTS = 5;
|
||||
const LOGIN_BACKOFF_START_MS = 1000;
|
||||
const LOGIN_BACKOFF_CAP_MS = 60_000;
|
||||
/** Failure entries idle longer than this are swept (bounds the map; far above the cap). */
|
||||
const LOGIN_FAILURE_IDLE_MS = 15 * 60_000;
|
||||
|
||||
/**
|
||||
* Random initial password for the seeded admin: `penguin-<4 digits>` — brand-related and
|
||||
* easy to type, shown once in the server startup output (the README, docs and login-page
|
||||
* hint all describe this form).
|
||||
*/
|
||||
export function generateInitialAdminPassword(): string {
|
||||
return "penguin-" + String(randomInt(0, 10000)).padStart(4, "0");
|
||||
}
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
@@ -42,6 +71,8 @@ export interface AuthServiceDeps {
|
||||
authSessions: AuthSessionsRepo;
|
||||
/** Provisions the initial Project at signup (injected by project-service, to avoid a circular dependency). */
|
||||
provisionInitialProject: (user: UserRow, isAdmin: boolean) => Promise<void>;
|
||||
/** Fixed initial password for the seeded admin (config.seedAdminPassword); null generates a random one at seed time. */
|
||||
seedAdminPassword: string | null;
|
||||
sessionTtlMs: number;
|
||||
sessionRenewMs: number;
|
||||
now?: () => Date;
|
||||
@@ -58,12 +89,25 @@ export class AuthService {
|
||||
* Startup seeding (idempotent): creates the built-in admin and adopts
|
||||
* default_project when the users table is empty; if the initial Project fails,
|
||||
* the user row is rolled back and the server retries on next startup.
|
||||
* Returns the initial password when it actually seeded — the caller prints it,
|
||||
* the only place a generated password is ever shown — and null when users
|
||||
* already exist.
|
||||
*/
|
||||
async seedAdmin(): Promise<void> {
|
||||
if (this.deps.users.count() > 0) return;
|
||||
async seedAdmin(): Promise<string | null> {
|
||||
if (this.deps.users.count() > 0) return null;
|
||||
const password = this.deps.seedAdminPassword ?? generateInitialAdminPassword();
|
||||
// The override (PENGUIN_SEED_ADMIN_PASSWORD) must meet the same policy as every
|
||||
// other initial/reset password; rejecting it here, before any insert, keeps a
|
||||
// configuration typo from creating a trivially weak privileged account. Generated
|
||||
// passwords are always 12 characters and never trip this.
|
||||
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||
throw new Error(
|
||||
`PENGUIN_SEED_ADMIN_PASSWORD must be at least ${MIN_PASSWORD_LENGTH} characters.`,
|
||||
);
|
||||
}
|
||||
const user: UserRow = {
|
||||
userId: ADMIN_USER_ID,
|
||||
passwordHash: await hashPassword(ADMIN_INITIAL_PASSWORD),
|
||||
passwordHash: await hashPassword(password),
|
||||
isAdmin: true,
|
||||
passwordIsInitial: true,
|
||||
createdAt: this.now().toISOString(),
|
||||
@@ -75,14 +119,45 @@ export class AuthService {
|
||||
this.deps.users.delete(user.userId);
|
||||
throw err;
|
||||
}
|
||||
return password;
|
||||
}
|
||||
|
||||
/** Consecutive login failures per userId (see the throttling comment on the constants). */
|
||||
private readonly loginFailures = new Map<string, { failures: number; lastFailureAt: number }>();
|
||||
|
||||
/** The wait imposed after `failures` consecutive failures (0 while within the free attempts). */
|
||||
private loginDelayMs(failures: number): number {
|
||||
const excess = failures - LOGIN_FREE_ATTEMPTS;
|
||||
if (excess <= 0) return 0;
|
||||
return Math.min(LOGIN_BACKOFF_START_MS * 2 ** (excess - 1), LOGIN_BACKOFF_CAP_MS);
|
||||
}
|
||||
|
||||
async login(userId: string, password: string): Promise<{ user: UserInfo; token: string }> {
|
||||
const nowMs = this.now().getTime();
|
||||
for (const [key, entry] of this.loginFailures) {
|
||||
if (nowMs - entry.lastFailureAt > LOGIN_FAILURE_IDLE_MS) this.loginFailures.delete(key);
|
||||
}
|
||||
const failed = this.loginFailures.get(userId);
|
||||
if (failed) {
|
||||
const readyAt = failed.lastFailureAt + this.loginDelayMs(failed.failures);
|
||||
if (nowMs < readyAt) {
|
||||
throw new HttpError(
|
||||
429,
|
||||
"too_many_attempts",
|
||||
`Too many failed sign-in attempts. Try again in ${Math.ceil((readyAt - nowMs) / 1000)}s.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const row = this.deps.users.findById(userId);
|
||||
const ok = row !== null && (await verifyPassword(password, row.passwordHash));
|
||||
if (!row || !ok) {
|
||||
this.loginFailures.set(userId, {
|
||||
failures: (failed?.failures ?? 0) + 1,
|
||||
lastFailureAt: this.now().getTime(),
|
||||
});
|
||||
throw new HttpError(401, "invalid_credentials", "Incorrect username or password.");
|
||||
}
|
||||
this.loginFailures.delete(userId);
|
||||
this.deps.authSessions.deleteExpired(this.now().toISOString());
|
||||
return { user: toUserInfo(row), token: this.issueSession(row.userId) };
|
||||
}
|
||||
|
||||
@@ -32,6 +32,12 @@ export interface ServerConfig {
|
||||
* derived per request instead. See design § "Workspace 文件预览".
|
||||
*/
|
||||
previewOrigin: string | null;
|
||||
/**
|
||||
* Fixed initial password for the seeded built-in admin (PENGUIN_SEED_ADMIN_PASSWORD),
|
||||
* used by automated tests and e2e; null (the norm) makes the seed generate a random
|
||||
* `penguin-<4 digits>` password, printed once to the server console.
|
||||
*/
|
||||
seedAdminPassword: string | null;
|
||||
/** Login session validity period (7 days). */
|
||||
authSessionTtlMs: number;
|
||||
/** Sliding renewal threshold: if the remaining validity is below this value when validation succeeds, it's renewed to the full TTL (renews under 6 days). */
|
||||
@@ -73,7 +79,7 @@ function normalizePreviewOrigin(raw: string | undefined): string | null {
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
/** Parses server config from environment variables (PORT / HOST / PENGUIN_HOME / PENGUIN_WEB_DIST / PENGUIN_WEB_DB / PENGUIN_PREVIEW_ORIGIN). */
|
||||
/** Parses server config from environment variables (PORT / HOST / PENGUIN_HOME / PENGUIN_WEB_DIST / PENGUIN_WEB_DB / PENGUIN_PREVIEW_ORIGIN / PENGUIN_SEED_ADMIN_PASSWORD). */
|
||||
export function resolveServerConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {
|
||||
const root = env.PENGUIN_HOME ?? resolveRoot();
|
||||
// An empty PORT string is treated as unset (the common `.env` case of an empty
|
||||
@@ -90,6 +96,8 @@ export function resolveServerConfig(env: NodeJS.ProcessEnv = process.env): Serve
|
||||
dbPath: env.PENGUIN_WEB_DB ?? path.join(root, "web.db"),
|
||||
webDist: env.PENGUIN_WEB_DIST ?? defaultWebDist(),
|
||||
previewOrigin: normalizePreviewOrigin(env.PENGUIN_PREVIEW_ORIGIN),
|
||||
// An empty/whitespace value is treated as unset (→ random seed password).
|
||||
seedAdminPassword: env.PENGUIN_SEED_ADMIN_PASSWORD?.trim() || null,
|
||||
authSessionTtlMs: 7 * DAY_MS,
|
||||
authSessionRenewMs: 6 * DAY_MS,
|
||||
};
|
||||
|
||||
@@ -21,8 +21,15 @@ const config = resolveServerConfig();
|
||||
const deps = buildAppDeps(config);
|
||||
const app = createApp(deps);
|
||||
|
||||
// Built-in admin seed (idempotent): creates admin (initial password penguin-2026) and adopts default_project when the users table is empty.
|
||||
await deps.authService.seedAdmin();
|
||||
// Built-in admin seed (idempotent): creates admin and adopts default_project when the
|
||||
// users table is empty. The returned initial password (random unless pinned via
|
||||
// PENGUIN_SEED_ADMIN_PASSWORD) is printed here once — the only place it is ever shown.
|
||||
const seededAdminPassword = await deps.authService.seedAdmin();
|
||||
if (seededAdminPassword !== null) {
|
||||
console.log(
|
||||
`Seeded built-in admin "admin" — initial password: ${seededAdminPassword} (change it after first sign-in)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Schedule scheduler: startup reconciliation (missed, don't backfill) + periodic scan; only active while the server is running.
|
||||
await deps.scheduler.start();
|
||||
|
||||
@@ -6,7 +6,18 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { MeResponse, ProjectsResponse } from "../src/api/types.js";
|
||||
import { apiClient, createTestApp, loginAdmin, loginUser, provisionUser } from "./helpers.js";
|
||||
import { buildAppDeps } from "../src/app.js";
|
||||
import { generateInitialAdminPassword } from "../src/auth/service.js";
|
||||
import {
|
||||
apiClient,
|
||||
createTestApp,
|
||||
loginAdmin,
|
||||
loginUser,
|
||||
makeTempRoot,
|
||||
provisionUser,
|
||||
TEST_ADMIN_PASSWORD,
|
||||
testConfig,
|
||||
} from "./helpers.js";
|
||||
import type { TestApp } from "./helpers.js";
|
||||
|
||||
describe("auth", () => {
|
||||
@@ -55,8 +66,8 @@ describe("auth", () => {
|
||||
await expect(
|
||||
fs.access(path.join(t.root, "default_project", "agents", "default_agent", "agent_state")),
|
||||
).resolves.toBeUndefined();
|
||||
// Seeding is idempotent: re-seeding does not create a duplicate account.
|
||||
await t.deps.authService.seedAdmin();
|
||||
// Seeding is idempotent: re-seeding returns null and does not create a duplicate account.
|
||||
expect(await t.deps.authService.seedAdmin()).toBeNull();
|
||||
expect(t.deps.db.prepare("SELECT COUNT(*) AS n FROM users").get()?.n).toBe(1);
|
||||
});
|
||||
|
||||
@@ -159,6 +170,98 @@ describe("auth", () => {
|
||||
expect(got.prefs.theme).toBe("dark");
|
||||
});
|
||||
|
||||
it("seedAdmin without an injected password generates penguin-<4 digits> and returns it", async () => {
|
||||
// Bypass the fixed test password: null matches the production default (random generation).
|
||||
const fresh = await createTestApp({ config: { seedAdminPassword: null } });
|
||||
try {
|
||||
expect(fresh.adminPassword).toMatch(/^penguin-\d{4}$/);
|
||||
// The returned password is the one that actually logs in.
|
||||
await loginUser(fresh.app, "admin", fresh.adminPassword);
|
||||
// Users exist now: re-seeding reports that nothing was seeded.
|
||||
expect(await fresh.deps.authService.seedAdmin()).toBeNull();
|
||||
} finally {
|
||||
await fresh.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("seedAdmin honors the injected seedAdminPassword", async () => {
|
||||
const fresh = await createTestApp({ config: { seedAdminPassword: "penguin-7777" } });
|
||||
try {
|
||||
expect(fresh.adminPassword).toBe("penguin-7777");
|
||||
await loginUser(fresh.app, "admin", "penguin-7777");
|
||||
} finally {
|
||||
await fresh.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("seedAdmin rejects an override below the password policy before creating the account", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const deps = buildAppDeps({ ...testConfig(root), seedAdminPassword: "x" }, { log: () => {} });
|
||||
try {
|
||||
await expect(deps.authService.seedAdmin()).rejects.toThrow(/at least 8 characters/);
|
||||
// Rejected before any insert: no half-created privileged account to retry around.
|
||||
expect(deps.db.prepare("SELECT COUNT(*) AS n FROM users").get()?.n).toBe(0);
|
||||
} finally {
|
||||
deps.channels.dispose();
|
||||
deps.db.close();
|
||||
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
it("throttles login failures per username with exponential backoff and resets on success", async () => {
|
||||
let clock = Date.parse("2026-08-03T00:00:00Z");
|
||||
const fresh = await createTestApp({ now: () => new Date(clock) });
|
||||
try {
|
||||
const attempt = (password: string) =>
|
||||
fresh.app.request("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ userId: "admin", password }),
|
||||
});
|
||||
// Five free failures, and the sixth still reaches verification (backoff starts after it).
|
||||
for (let i = 0; i < 6; i++) expect((await attempt("wrong-password")).status).toBe(401);
|
||||
// Inside the 1s window: rejected without touching credentials — even the CORRECT password.
|
||||
const throttled = await attempt("wrong-password");
|
||||
expect(throttled.status).toBe(429);
|
||||
const body = (await throttled.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe("too_many_attempts");
|
||||
expect((await attempt(TEST_ADMIN_PASSWORD)).status).toBe(429);
|
||||
// Past the window, the correct password signs in and clears the counter…
|
||||
clock += 1100;
|
||||
await loginUser(fresh.app, "admin", TEST_ADMIN_PASSWORD);
|
||||
// …so the next failure is an ordinary 401 again, not a 429.
|
||||
expect((await attempt("wrong-password")).status).toBe(401);
|
||||
} finally {
|
||||
await fresh.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("throttles unknown usernames identically (no account-existence oracle)", async () => {
|
||||
let clock = Date.parse("2026-08-03T00:00:00Z");
|
||||
const fresh = await createTestApp({ now: () => new Date(clock) });
|
||||
try {
|
||||
const attempt = () =>
|
||||
fresh.app.request("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ userId: "ghost", password: "whatever-123" }),
|
||||
});
|
||||
for (let i = 0; i < 6; i++) expect((await attempt()).status).toBe(401);
|
||||
expect((await attempt()).status).toBe(429);
|
||||
// The window expires on the same schedule as for real accounts.
|
||||
clock += 1100;
|
||||
expect((await attempt()).status).toBe(401);
|
||||
} finally {
|
||||
await fresh.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("generateInitialAdminPassword matches penguin-<4 digits>", () => {
|
||||
for (let i = 0; i < 32; i++) {
|
||||
expect(generateInitialAdminPassword()).toMatch(/^penguin-\d{4}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("PUT prefs shallow-merges without clobbering other writers' fields", async () => {
|
||||
const { cookie } = await provisionUser(t.app, "fred");
|
||||
const api = apiClient(t.app, cookie);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
/**
|
||||
* resolveServerConfig PORT parsing tests: both the default (missing) and empty string
|
||||
* (the common `PORT=` empty value in `.env`) fall back to 7364 — Number("") === 0 used
|
||||
* to make the empty string pass range validation and bind to a random port; explicit
|
||||
* "0" is preserved (explicit semantics for a random available port); invalid values
|
||||
* throw. This matches the CLI's resolvePort semantics (packages/cli serve).
|
||||
* resolveServerConfig parsing tests.
|
||||
*
|
||||
* PORT: both the default (missing) and empty string (the common `PORT=` empty value in
|
||||
* `.env`) fall back to 7364 — Number("") === 0 used to make the empty string pass range
|
||||
* validation and bind to a random port; explicit "0" is preserved (explicit semantics
|
||||
* for a random available port); invalid values throw. This matches the CLI's
|
||||
* resolvePort semantics (packages/cli serve).
|
||||
* PENGUIN_SEED_ADMIN_PASSWORD: unset/empty/whitespace → null (random seed password).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveServerConfig } from "../src/config.js";
|
||||
@@ -27,3 +30,19 @@ describe("resolveServerConfig: PORT parsing", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveServerConfig: PENGUIN_SEED_ADMIN_PASSWORD parsing", () => {
|
||||
it("unset/empty/whitespace → null; a value is kept trimmed", () => {
|
||||
expect(resolveServerConfig({ ...base }).seedAdminPassword).toBeNull();
|
||||
expect(
|
||||
resolveServerConfig({ ...base, PENGUIN_SEED_ADMIN_PASSWORD: "" }).seedAdminPassword,
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveServerConfig({ ...base, PENGUIN_SEED_ADMIN_PASSWORD: " " }).seedAdminPassword,
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveServerConfig({ ...base, PENGUIN_SEED_ADMIN_PASSWORD: " penguin-9999 " })
|
||||
.seedAdminPassword,
|
||||
).toBe("penguin-9999");
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import type { OmniMessage } from "@prismshadow/penguin-core";
|
||||
import { buildAppDeps, createApp } from "../src/app.js";
|
||||
import type { AppDeps, BuildDepsOverrides } from "../src/app.js";
|
||||
import type { AppEnv } from "../src/auth/middleware.js";
|
||||
import { ADMIN_INITIAL_PASSWORD, ADMIN_USER_ID } from "../src/auth/service.js";
|
||||
import { ADMIN_USER_ID } from "../src/auth/service.js";
|
||||
import type { ServerConfig } from "../src/config.js";
|
||||
import type { UserInfo } from "../src/api/types.js";
|
||||
|
||||
@@ -21,6 +21,9 @@ export async function makeTempRoot(): Promise<string> {
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Fixed seeded-admin password injected into every test app (in production the seed generates a random one). */
|
||||
export const TEST_ADMIN_PASSWORD = "penguin-0000";
|
||||
|
||||
export function testConfig(root: string): ServerConfig {
|
||||
return {
|
||||
root,
|
||||
@@ -32,6 +35,8 @@ export function testConfig(root: string): ServerConfig {
|
||||
previewOrigin: null,
|
||||
// Points to a nonexistent directory: static hosting is disabled in tests.
|
||||
webDist: path.join(root, "__no_web_dist__"),
|
||||
// Fixed seed password so loginAdmin needs no seed-time capture.
|
||||
seedAdminPassword: TEST_ADMIN_PASSWORD,
|
||||
authSessionTtlMs: 7 * DAY_MS,
|
||||
authSessionRenewMs: 6 * DAY_MS,
|
||||
};
|
||||
@@ -41,6 +46,8 @@ export interface TestApp {
|
||||
app: Hono<AppEnv>;
|
||||
deps: AppDeps;
|
||||
root: string;
|
||||
/** Initial password of the seeded admin (TEST_ADMIN_PASSWORD unless overridden via `config.seedAdminPassword`). */
|
||||
adminPassword: string;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -56,13 +63,15 @@ export async function createTestApp(options: TestAppOptions = {}): Promise<TestA
|
||||
const root = await makeTempRoot();
|
||||
if (beforeSeed) await beforeSeed(root);
|
||||
const deps = buildAppDeps({ ...testConfig(root), ...config }, { log: () => {}, ...overrides });
|
||||
// Consistent with the startup entrypoint: seed the built-in admin (owning default_project).
|
||||
await deps.authService.seedAdmin();
|
||||
// Consistent with the startup entrypoint: seed the built-in admin (owning default_project),
|
||||
// keeping the password it returns (only null if a beforeSeed hook ever pre-created users).
|
||||
const adminPassword = (await deps.authService.seedAdmin()) ?? TEST_ADMIN_PASSWORD;
|
||||
const app = createApp(deps);
|
||||
return {
|
||||
app,
|
||||
deps,
|
||||
root,
|
||||
adminPassword,
|
||||
cleanup: async () => {
|
||||
deps.channels.dispose();
|
||||
deps.db.close();
|
||||
@@ -95,9 +104,9 @@ export async function loginUser(
|
||||
return { cookie: setCookie.split(";")[0]!, user: body.user };
|
||||
}
|
||||
|
||||
/** Logs in as the seeded admin. */
|
||||
/** Logs in as the seeded admin (every test app seeds with TEST_ADMIN_PASSWORD). */
|
||||
export function loginAdmin(app: Hono<AppEnv>): Promise<{ cookie: string; user: UserInfo }> {
|
||||
return loginUser(app, ADMIN_USER_ID, ADMIN_INITIAL_PASSWORD);
|
||||
return loginUser(app, ADMIN_USER_ID, TEST_ADMIN_PASSWORD);
|
||||
}
|
||||
|
||||
/** Admin creates the account and logs in as that user (the only way to create test users while registration is closed). */
|
||||
|
||||
Reference in new issue
Block a user