diff --git a/Dockerfile b/Dockerfile index 04a8fa8..3e26840 100644 --- a/Dockerfile +++ b/Dockerfile @@ -76,6 +76,9 @@ COPY --from=builder /app/docker-entrypoint.sh ./docker-entrypoint.sh COPY --from=builder /app/scripts/runtime-server.ts ./scripts/runtime-server.ts COPY --from=builder /app/drizzle ./drizzle +# Database commands must use the bundled script: sources are not shipped. +RUN bun -e 'const fs = require("node:fs"); const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); for (const name of Object.keys(pkg.scripts)) { pkg.scripts[name] = pkg.scripts[name].replaceAll("bun scripts/manage-db.ts", "bun dist/scripts/manage-db.js"); } fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\n");' + # Remove build-only packages that are not needed at runtime # (esbuild, vite, rollup, tailwind, svgo — all only used during `astro build`) RUN rm -rf node_modules/esbuild node_modules/@esbuild \ diff --git a/scripts/manage-db.ts b/scripts/manage-db.ts index b8e90aa..ba7364d 100644 --- a/scripts/manage-db.ts +++ b/scripts/manage-db.ts @@ -221,7 +221,7 @@ async function resetPassword() { } const sqlite = new Database(dbPath); - const db = drizzle({ client: sqlite }); + const db = drizzle({ client: sqlite, schema: { users, accounts, sessions } }); try { const user = await db.query.users.findFirst({ diff --git a/src/lib/db/reset-password-cli.test.ts b/src/lib/db/reset-password-cli.test.ts new file mode 100644 index 0000000..e4accf9 --- /dev/null +++ b/src/lib/db/reset-password-cli.test.ts @@ -0,0 +1,65 @@ +import { afterAll, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { verifyPassword } from "better-auth/crypto"; + +const root = resolve(import.meta.dir, "../../.."); +const temporaryRoot = mkdtempSync(join(tmpdir(), "gitea-mirror-reset-password-")); +const bundle = join(temporaryRoot, "manage-db.js"); +afterAll(() => rmSync(temporaryRoot, { recursive: true, force: true })); + +function run(cmd: string[], cwd: string) { + const result = Bun.spawnSync({ cmd, cwd, stdout: "pipe", stderr: "pipe" }); + expect(result.exitCode, Buffer.from(result.stderr).toString()).toBe(0); + return Buffer.from(result.stdout).toString(); +} + +run([process.execPath, "build", "scripts/manage-db.ts", "--target=bun", `--outfile=${bundle}`], root); + +for (const bundled of [false, true]) { + for (const existingCredential of [false, true]) { + test(`reset-password (${bundled ? "bundled" : "source"}, ${existingCredential ? "existing" : "new"} credential)`, async () => { + const cwd = mkdtempSync(join(temporaryRoot, "case-")); + mkdirSync(join(cwd, "data")); + const db = new Database(join(cwd, "data/gitea-mirror.db")); + try { + db.exec(` + CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT, email TEXT NOT NULL UNIQUE, + email_verified INTEGER DEFAULT 0, image TEXT, created_at INTEGER DEFAULT 0, + updated_at INTEGER DEFAULT 0, username TEXT); + CREATE TABLE accounts (id TEXT PRIMARY KEY, account_id TEXT NOT NULL, + user_id TEXT NOT NULL, provider_id TEXT NOT NULL, issuer TEXT DEFAULT '', + provider_user_id TEXT, access_token TEXT, refresh_token TEXT, id_token TEXT, + access_token_expires_at INTEGER, refresh_token_expires_at INTEGER, scope TEXT, + expires_at INTEGER, password TEXT, created_at INTEGER DEFAULT 0, updated_at INTEGER DEFAULT 0); + CREATE TABLE sessions (id TEXT PRIMARY KEY, token TEXT NOT NULL, user_id TEXT NOT NULL, + expires_at INTEGER, ip_address TEXT, user_agent TEXT, created_at INTEGER DEFAULT 0, + updated_at INTEGER DEFAULT 0); + INSERT INTO users (id, email) VALUES ('target', 'admin@example.com'), ('other', 'other@example.com'); + INSERT INTO accounts (id, account_id, user_id, provider_id, password) + VALUES ('oauth', 'remote', 'target', 'github', NULL), ('other-account', 'other', 'other', 'credential', 'unchanged'); + INSERT INTO sessions (id, token, user_id) VALUES ('one', 'one', 'target'), ('two', 'two', 'target'), ('other', 'other', 'other'); + `); + if (existingCredential) { + db.exec("INSERT INTO accounts (id, account_id, user_id, provider_id, password) VALUES ('credential', 'target', 'target', 'credential', 'old')"); + } + const output = run([process.execPath, bundled ? bundle : join(root, "scripts/manage-db.ts"), + "reset-password", "--email=admin@example.com", "--new-password=new-secure-password"], cwd); + expect(output).toContain("Password reset for admin@example.com"); + expect(output).toContain("Cleared 2 active session(s)"); + const credentials = db.query("SELECT * FROM accounts WHERE user_id = 'target' AND provider_id = 'credential'").all() as Array<{ password: string; issuer: string }>; + expect(credentials).toHaveLength(1); + expect(await verifyPassword({ hash: credentials[0].password, password: "new-secure-password" })).toBe(true); + if (!existingCredential) expect(credentials[0].issuer).toBe("local:credential"); + expect(db.query("SELECT id FROM sessions").all()).toEqual([{ id: "other" }]); + expect(db.query("SELECT password FROM accounts WHERE id = 'other-account'").get()).toEqual({ password: "unchanged" }); + expect(db.query("SELECT password FROM accounts WHERE id = 'oauth'").get()).toEqual({ password: null }); + expect(db.query("SELECT id FROM users").all()).toHaveLength(2); + } finally { + db.close(); + } + }, 15000); + } +}