From c1a187890af14beaa671d817b9d6e03b4f02e743 Mon Sep 17 00:00:00 2001 From: Laszlo Toth Date: Mon, 19 Oct 2026 09:45:34 +0200 Subject: [PATCH 1/3] fix(update): recover arr admin logins after an aborted install An install that fails at docker compose pull or the health gate stops before the wiring steps. The containers can be started by hand and answer health checks, but no Sonarr/Radarr/Prowlarr user was ever created, so the password in admin.txt is rejected. `arrstack update` now seeds a missing arr admin user from admin.txt, and never overwrites credentials the user changed in the UI. `arrstack doctor` flags state.json without install_completed_at so the incomplete install, not the password, is visible as the cause. Docs get the failure signature and the `arrstack install --resume` recovery path. --- README.md | 2 +- docs/guide/09-updating.md | 12 ++-- docs/troubleshooting/common-errors.md | 18 ++++++ src/state/store.ts | 23 +++++++ src/usecase/doctor.ts | 14 +++++ src/usecase/install.ts | 10 +-- src/usecase/update.ts | 55 +++++++++++++++- src/wiring/arr-auth.ts | 25 ++++++-- tests/state/store.test.ts | 20 ++++++ tests/usecase/update.test.ts | 59 ++++++++++++++++++ tests/wiring/arr-auth.test.ts | 90 +++++++++++++++++++++++++++ 11 files changed, 310 insertions(+), 18 deletions(-) create mode 100644 tests/wiring/arr-auth.test.ts diff --git a/README.md b/README.md index 6a7d7b1..a46c19a 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ Add a drive later by mounting it on the host, re-running `arrstack`, and enterin | --- | --- | | `arrstack install` | Runs the wizard and installs (default when no subcommand is given) | | `arrstack doctor` | Three passes: preflight (Docker installed + running, Compose v2 present, 10 GB free on `/` and on `storage_root`, ports 80 and 443 free), `docker compose ps` container state, then an HTTP probe on every service that declares an HTTP healthcheck | -| `arrstack update` | `docker compose pull` then `docker compose up -d`. Configs and media untouched | +| `arrstack update` | Pulls images, recreates containers, health-checks them, and seeds any missing Sonarr/Radarr/Prowlarr admin login. Configs and media untouched | | `arrstack show-password` | Prints `/admin.txt` (username, password, timestamp) to stdout | | `arrstack logs ` | Follows `docker compose logs -f ` for one service (`jellyfin`, `sonarr`, etc) | | `arrstack uninstall` | `docker compose down` only. Preserves `config/`, `state.json`, `admin.txt`, and media. Add `--purge` to also delete `/config/`; `state.json`, `admin.txt`, and media still survive | diff --git a/docs/guide/09-updating.md b/docs/guide/09-updating.md index 79a1e5c..117939e 100644 --- a/docs/guide/09-updating.md +++ b/docs/guide/09-updating.md @@ -18,11 +18,15 @@ arrstack doctor ## What `arrstack update` does 1. Reads `state.json` to find the install directory and compose file. -2. Runs `docker compose pull` against the generated `docker-compose.yml` so every enabled service fetches its latest image. -3. Runs `docker compose up -d`, which recreates only the containers whose images changed. -4. Prints a hint to run `arrstack doctor` next, which re-probes every service HTTP endpoint. +2. Re-renders `docker-compose.yml` and `Caddyfile` from the current binary's templates, so installer fixes reach an existing install. +3. Runs `docker compose pull` against the generated `docker-compose.yml` so every enabled service fetches its latest image. +4. Runs `docker compose up -d`, which recreates only the containers whose images changed. +5. Health-checks every enabled service and seeds a missing Sonarr, Radarr, or Prowlarr admin user from `admin.txt`. This repairs arr logins left behind by an install that aborted before its wiring steps. +6. Prunes dangling images and prints a hint to run `arrstack doctor` next. -Duration: 30 seconds to 5 minutes depending on image sizes and download speed. Health checks, image pruning, and single-service updates are not built in yet, run them with `docker compose` directly if you need them. +Duration: 30 seconds to 5 minutes depending on image sizes and download speed. To update a single service, use `docker compose` directly. + +`arrstack update` does not re-run the Jellyfin or Jellyseerr bootstrap. If those were never configured because an install failed early, use `arrstack install --resume`. ## Image tag policy diff --git a/docs/troubleshooting/common-errors.md b/docs/troubleshooting/common-errors.md index bc177e8..1a7b4c4 100644 --- a/docs/troubleshooting/common-errors.md +++ b/docs/troubleshooting/common-errors.md @@ -31,6 +31,24 @@ sudo -E arrstack install --install-dir /opt/arrstack # or run elevated --- +### admin.txt credentials are rejected after a failed install + +**Symptom.** The install failed at `docker compose pull` or the health gate, you started the containers by hand with `docker compose up -d`, and now the username/password in `admin.txt` does not log in to Sonarr, Radarr, Prowlarr, or Jellyfin. + +**Cause.** The installer writes `admin.txt` before it touches Docker, but it creates the arr admin users and the Jellyfin admin user during the wiring steps that run *after* the containers are healthy. An install that aborts early never reaches them. The containers still answer health checks, so everything looks up, but no login exists yet. Running `docker compose up -d` by hand does not run the wiring. + +**Fix.** Finish the install so the wiring steps run: +```bash +arrstack install --resume +``` +On arrstack 1.2.1 and later, `arrstack update` also seeds a missing Sonarr/Radarr/Prowlarr admin user, but it does not run the Jellyfin or Jellyseerr setup. Use `--resume` when Jellyfin login is also broken. + +`arrstack doctor` prints an "Install incomplete" warning when `state.json` has no `install_completed_at` timestamp, which is the signature of this state. + +**Prevention.** Let the installer finish instead of starting containers manually after a failed step. Re-run `arrstack install --resume` once the underlying image or config problem is fixed. + +--- + ### Jellyseerr EACCES on /app/config/logs **Symptom.** `jellyseerr | Error: EACCES: permission denied, mkdir '/app/config/logs'` followed by exit 1. diff --git a/src/state/store.ts b/src/state/store.ts index f44ba94..a9ffec6 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -48,6 +48,29 @@ export function adminTxtGuardMessage(installDir: string): string | null { ); } +export interface AdminCredentials { + username: string; + password: string; +} + +/** + * Read the credentials the installer wrote at install time. Returns null when + * admin.txt is absent, unreadable, or incomplete, so callers can skip recovery + * work instead of crashing or rotating the password. + */ +export function readAdminCredentials(installDir: string): AdminCredentials | null { + const p = join(installDir, "admin.txt"); + try { + const text = readFileSync(p, "utf-8"); + const username = text.match(/^username:\s*(.+)$/m)?.[1]?.trim(); + const password = text.match(/^password:\s*(.+)$/m)?.[1]?.trim(); + if (!username || !password) return null; + return { username, password }; + } catch { + return null; + } +} + export function writeState(installDir: string, state: State): void { const statePath = join(installDir, STATE_FILE); const tmpPath = `${statePath}.tmp`; diff --git a/src/usecase/doctor.ts b/src/usecase/doctor.ts index 2c2a44c..ee1b253 100644 --- a/src/usecase/doctor.ts +++ b/src/usecase/doctor.ts @@ -72,6 +72,20 @@ export async function runDoctor(installDir: string): Promise { let allOk = true; + // A failed install (most often an image pull failure) writes state.json + // before the docker steps but never reaches the wiring steps. Containers can + // still be started by hand and answer health checks while no arr admin user + // exists, so admin.txt credentials fail to log in. Surface that here. + if (!state.install_completed_at) { + section("Install state"); + allOk = false; + fail( + "Install incomplete", + "state.json has no install_completed_at timestamp", + "Run: arrstack install --resume to finish service wiring (arr logins, Jellyfin setup)", + ); + } + // --- Preflight checks --- section("System checks"); // Post-install: skip 80/443-free checks. Our own Caddy is supposed to be diff --git a/src/usecase/install.ts b/src/usecase/install.ts index 12c4b9e..9a3427f 100644 --- a/src/usecase/install.ts +++ b/src/usecase/install.ts @@ -30,7 +30,7 @@ import { repushProwlarrIndexersToApps, } from "../wiring/prowlarr-flaresolverr.js"; import { linkJellyseerrToArrs } from "../wiring/jellyseerr-arr.js"; -import { seedArrAdmin } from "../wiring/arr-auth.js"; +import { seedArrAdmin, ARR_AUTH_TARGETS } from "../wiring/arr-auth.js"; import { configureTrailarr } from "../wiring/trailarr.js"; import { configureArr } from "../wiring/sonarr-radarr.js"; import { configureQbit } from "../wiring/qbittorrent.js"; @@ -569,11 +569,7 @@ export async function runInstall( // Step 12c2: Seed admin credentials on each arr app. Sonarr/Radarr/Prowlarr // store users in SQLite, so the config.xml preset alone doesn't pre-create // an account. PUT /api/v{N}/config/host with username/password does. - for (const { id, port, version } of [ - { id: "prowlarr", port: 9696, version: "v1" as const }, - { id: "sonarr", port: 8989, version: "v3" as const }, - { id: "radarr", port: 7878, version: "v3" as const }, - ]) { + for (const { id, port, apiVersion } of ARR_AUTH_TARGETS) { if (!has(id)) continue; await runStep(`Seeding ${id} admin credentials`, onStep, log, async () => { await seedArrAdmin({ @@ -581,7 +577,7 @@ export async function runInstall( username: state.admin.username, password: adminPassword, baseUrl: `http://localhost:${port}`, - apiVersion: version, + apiVersion, }); }); } diff --git a/src/usecase/update.ts b/src/usecase/update.ts index 053e418..a89fd44 100644 --- a/src/usecase/update.ts +++ b/src/usecase/update.ts @@ -1,6 +1,6 @@ import { existsSync, openSync, writeSync, closeSync, fsyncSync, writeFileSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import { readState, writeState } from "../state/store.js"; +import { readState, writeState, readAdminCredentials } from "../state/store.js"; import { exec } from "../lib/exec.js"; import { getServicesByIds } from "../catalog/index.js"; import type { Service } from "../catalog/schema.js"; @@ -9,12 +9,19 @@ import { renderCaddyfile } from "../renderer/caddy.js"; import { renderCompose } from "../renderer/compose.js"; import { resolveVpnWireguardKey } from "../wiring/nordvpn.js"; import { configureBazarrProviderHub } from "../wiring/bazarr-providerhub.js"; +import { + seedArrAdmin, + ARR_AUTH_TARGETS, + type ArrAuthOptions, + type SeedArrAdminResult, +} from "../wiring/arr-auth.js"; export interface UpdateDeps { runStreaming: (argv: string[], onLine: (line: string) => void) => Promise<{ ok: boolean; code: number | null }>; captureImages: (composeFile: string) => Promise>; pruneImages: () => Promise<{ ok: boolean; message: string }>; checkHealth: (url: string, timeoutMs: number) => Promise; + seedArrAdmin: (opts: ArrAuthOptions) => Promise; now: () => number; } @@ -41,6 +48,7 @@ export async function runUpdate(installDir: string, deps?: Partial): captureImages: deps?.captureImages ?? defaultCaptureImages, pruneImages: deps?.pruneImages ?? defaultPruneImages, checkHealth: deps?.checkHealth ?? defaultCheckHealth, + seedArrAdmin: deps?.seedArrAdmin ?? seedArrAdmin, now: deps?.now ?? Date.now, }; @@ -126,6 +134,14 @@ export async function runUpdate(installDir: string, deps?: Partial): await runHealthChecks(services, d.checkHealth, logAndEcho); phaseTimes["health"] = d.now() - healthStart; + // Recovery: an install that failed before the wiring steps (image pull + // error, health gate timeout) leaves the arr containers running but with no + // admin user, so the password in admin.txt cannot log in. Seed any arr that + // has no user yet. Best-effort: never fail an update over this. + const seedStart = d.now(); + await reseedArrAdmins(installDir, state, d.seedArrAdmin, logAndEcho); + phaseTimes["seed-arr-admins"] = d.now() - seedStart; + // Provider Hub: keep the default subtitle providers installed + enabled on // existing installs too (the built-in providers are being retired). // Best-effort: a failure here logs a warning and never fails the update. @@ -289,6 +305,43 @@ async function runHealthChecks( ); } +async function reseedArrAdmins( + installDir: string, + state: State, + seed: UpdateDeps["seedArrAdmin"], + log: (line: string) => void, +): Promise { + const targets = ARR_AUTH_TARGETS.filter((t) => state.services_enabled.includes(t.id)); + if (targets.length === 0) return; + + const creds = readAdminCredentials(installDir); + if (!creds) { + log("[update] admin.txt missing or unreadable; skipping arr admin recovery"); + return; + } + + for (const target of targets) { + try { + const result = await seed({ + apiKey: state.api_keys[target.id] ?? "", + username: state.admin.username, + password: creds.password, + baseUrl: `http://localhost:${target.port}`, + apiVersion: target.apiVersion, + }); + log( + `[update] ${target.id}: ${ + result === "seeded" ? "seeded admin credentials" : "admin login already configured" + }`, + ); + } catch (err) { + log( + `[update] warning: could not verify ${target.id} admin login: ${(err as Error).message}`, + ); + } + } +} + function diffImages(before: Map, after: Map): UpdateSummary[] { const ids = new Set([...before.keys(), ...after.keys()]); const out: UpdateSummary[] = []; diff --git a/src/wiring/arr-auth.ts b/src/wiring/arr-auth.ts index dd4da40..6446367 100644 --- a/src/wiring/arr-auth.ts +++ b/src/wiring/arr-auth.ts @@ -16,11 +16,23 @@ export interface ArrAuthOptions { apiVersion: "v1" | "v3"; } +// Shared by install and update so both know every arr app whose login is +// seeded through the API. Ports match the catalog's adminPort values. +export const ARR_AUTH_TARGETS = [ + { id: "prowlarr", port: 9696, apiVersion: "v1" }, + { id: "sonarr", port: 8989, apiVersion: "v3" }, + { id: "radarr", port: 7878, apiVersion: "v3" }, +] as const; + +export type ArrAuthTarget = (typeof ARR_AUTH_TARGETS)[number]; + async function readBody(res: Response): Promise { try { return (await res.text()).slice(0, 400); } catch { return ""; } } -export async function seedArrAdmin(opts: ArrAuthOptions): Promise { +export type SeedArrAdminResult = "seeded" | "exists"; + +export async function seedArrAdmin(opts: ArrAuthOptions): Promise { const hostUrl = `${opts.baseUrl}/api/${opts.apiVersion}/config/host`; const getRes = await fetchWithRetry(hostUrl, { @@ -31,10 +43,12 @@ export async function seedArrAdmin(opts: ArrAuthOptions): Promise { } const current = (await getRes.json()) as Record; - // If the admin user is already set, don't re-PUT — some arr versions reject - // identical password updates as "Must match" validation noise on reconfigure. - if (current.username === opts.username && current.password) { - return; + // Any existing user wins. Do not overwrite a username/password the user + // changed in the UI, even when it differs from the installer default. Only a + // genuinely unseeded instance (the state left behind when an install aborts + // before the wiring steps) gets credentials written. + if (current.username && current.password) { + return "exists"; } const updated = { @@ -58,4 +72,5 @@ export async function seedArrAdmin(opts: ArrAuthOptions): Promise { `PUT ${hostUrl} failed: HTTP ${putRes.status}\n${await readBody(putRes)}`, ); } + return "seeded"; } diff --git a/tests/state/store.test.ts b/tests/state/store.test.ts index c2f6c65..5b12a47 100644 --- a/tests/state/store.test.ts +++ b/tests/state/store.test.ts @@ -7,6 +7,7 @@ import { writeState, adminTxtUnreadable, adminTxtGuardMessage, + readAdminCredentials, } from "../../src/state/store.js"; import type { State } from "../../src/state/schema.js"; @@ -102,6 +103,25 @@ test("adminTxtUnreadable: true when admin.txt exists but can't be read", () => { chmodSync(p, 0o600); // let afterEach clean up }); +test("readAdminCredentials parses username and password from admin.txt", () => { + mkdirSync(TMP, { recursive: true }); + writeFileSync( + join(TMP, "admin.txt"), + "username: admin\npassword: UXsr-v3H8-JpMZ-gqCm\ngenerated: 2026-06-24T23:31:10.664Z\n", + ); + expect(readAdminCredentials(TMP)).toEqual({ + username: "admin", + password: "UXsr-v3H8-JpMZ-gqCm", + }); +}); + +test("readAdminCredentials returns null when admin.txt is absent or incomplete", () => { + mkdirSync(TMP, { recursive: true }); + expect(readAdminCredentials(TMP)).toBeNull(); + writeFileSync(join(TMP, "admin.txt"), "username: admin\n"); + expect(readAdminCredentials(TMP)).toBeNull(); +}); + test("adminTxtGuardMessage: null when admin.txt is absent or readable", () => { mkdirSync(TMP, { recursive: true }); expect(adminTxtGuardMessage(TMP)).toBeNull(); diff --git a/tests/usecase/update.test.ts b/tests/usecase/update.test.ts index c9f1df9..c065798 100644 --- a/tests/usecase/update.test.ts +++ b/tests/usecase/update.test.ts @@ -72,6 +72,7 @@ function makeDeps(overrides: Partial = {}): { deps: UpdateDeps; call return { ok: true, message: "Total reclaimed space: 0B" }; }, checkHealth: async () => true, + seedArrAdmin: async () => "exists", now: Date.now, ...overrides, }; @@ -212,6 +213,64 @@ test("runUpdate attempts the Bazarr Provider Hub wiring when bazarr is enabled", expect(logTxt.toLowerCase()).toContain("provider hub"); }); +test("runUpdate seeds arr admin credentials from admin.txt after a failed install", async () => { + // Regression for #6: an install that aborts at docker compose pull leaves the + // arr containers running with no user in SQLite, so the admin.txt password is + // rejected. update must seed it on the next run. + const dir = makeInstallDir(); + writeFileSync(join(dir, "admin.txt"), "username: admin\npassword: s3cret-pw\ngenerated: now\n"); + const calls: any[] = []; + const { deps } = makeDeps({ + seedArrAdmin: async (opts) => { + calls.push(opts); + return "seeded"; + }, + }); + + await silently(() => runUpdate(dir, deps)); + + expect(calls).toHaveLength(1); + expect(calls[0].username).toBe("admin"); + expect(calls[0].password).toBe("s3cret-pw"); + expect(calls[0].baseUrl).toBe("http://localhost:8989"); + expect(calls[0].apiVersion).toBe("v3"); + const logTxt = readFileSync(join(dir, "update.log"), "utf-8"); + expect(logTxt).toContain("sonarr: seeded admin credentials"); +}); + +test("runUpdate skips arr admin seeding when admin.txt is missing", async () => { + const dir = makeInstallDir(); + let called = false; + const { deps } = makeDeps({ + seedArrAdmin: async () => { + called = true; + return "seeded"; + }, + }); + + await silently(() => runUpdate(dir, deps)); + + expect(called).toBe(false); + const logTxt = readFileSync(join(dir, "update.log"), "utf-8"); + expect(logTxt).toContain("admin.txt missing or unreadable"); +}); + +test("runUpdate logs a warning and continues when arr admin seeding fails", async () => { + const dir = makeInstallDir(); + writeFileSync(join(dir, "admin.txt"), "username: admin\npassword: pw\ngenerated: now\n"); + const { deps } = makeDeps({ + seedArrAdmin: async () => { + throw new Error("HTTP 500"); + }, + }); + + await silently(() => runUpdate(dir, deps)); + + const logTxt = readFileSync(join(dir, "update.log"), "utf-8"); + expect(logTxt).toContain("warning: could not verify sonarr admin login"); + expect(logTxt).toContain("done."); +}); + test("runUpdate summary reports changed service images", async () => { const dir = makeInstallDir(); let captureCount = 0; diff --git a/tests/wiring/arr-auth.test.ts b/tests/wiring/arr-auth.test.ts new file mode 100644 index 0000000..3d97240 --- /dev/null +++ b/tests/wiring/arr-auth.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test, afterEach } from "bun:test"; +import { seedArrAdmin, ARR_AUTH_TARGETS, type ArrAuthOptions } from "../../src/wiring/arr-auth"; + +const BASE_OPTS: ArrAuthOptions = { + apiKey: "test-api-key", + username: "admin", + password: "test-password", + baseUrl: "http://arr.test", + apiVersion: "v3", +}; + +const origFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = origFetch; +}); + +interface RecordedCall { + url: string; + method: string; + body?: any; +} + +function mockHostConfig( + current: Record, + putStatus = 200, +): RecordedCall[] { + const calls: RecordedCall[] = []; + globalThis.fetch = (async (url: any, init: any) => { + const u = String(url); + const method = init?.method ?? "GET"; + calls.push({ url: u, method, body: init?.body ? JSON.parse(init.body) : undefined }); + if (method === "GET") { + return new Response(JSON.stringify(current), { status: 200 }); + } + return new Response(JSON.stringify({ ...current, ...JSON.parse(init.body) }), { + status: putStatus, + }); + }) as any; + return calls; +} + +describe("seedArrAdmin", () => { + test("writes forms auth credentials to an unseeded instance", async () => { + const calls = mockHostConfig({ username: "", password: null }); + + await expect(seedArrAdmin(BASE_OPTS)).resolves.toBe("seeded"); + + expect(calls).toHaveLength(2); + expect(calls[0].url).toBe("http://arr.test/api/v3/config/host"); + expect(calls[0].method).toBe("GET"); + expect(calls[1].method).toBe("PUT"); + expect(calls[1].body.authenticationMethod).toBe("forms"); + expect(calls[1].body.authenticationRequired).toBe("enabled"); + expect(calls[1].body.username).toBe("admin"); + expect(calls[1].body.password).toBe("test-password"); + expect(calls[1].body.passwordConfirmation).toBe("test-password"); + }); + + test("accepts 202 Accepted from Sonarr and Radarr", async () => { + mockHostConfig({ username: "", password: null }, 202); + await expect(seedArrAdmin(BASE_OPTS)).resolves.toBe("seeded"); + }); + + test("does not overwrite an existing admin", async () => { + const calls = mockHostConfig({ username: "admin", password: "existing-hash" }); + await expect(seedArrAdmin(BASE_OPTS)).resolves.toBe("exists"); + expect(calls.filter((c) => c.method === "PUT")).toHaveLength(0); + }); + + test("does not overwrite credentials the user changed in the UI", async () => { + // Regression guard for update-time recovery: a non-default username means + // the user owns the account now, so the installer password must not reset it. + const calls = mockHostConfig({ username: "bob", password: "existing-hash" }); + await expect(seedArrAdmin(BASE_OPTS)).resolves.toBe("exists"); + expect(calls.filter((c) => c.method === "PUT")).toHaveLength(0); + }); + + test("rejects when the PUT fails", async () => { + mockHostConfig({ username: "", password: null }, 400); + await expect(seedArrAdmin(BASE_OPTS)).rejects.toThrow(/PUT .* failed: HTTP 400/); + }); + + test("targets are the three arr apps with their API versions", () => { + expect(ARR_AUTH_TARGETS).toEqual([ + { id: "prowlarr", port: 9696, apiVersion: "v1" }, + { id: "sonarr", port: 8989, apiVersion: "v3" }, + { id: "radarr", port: 7878, apiVersion: "v3" }, + ]); + }); +}); From 6b8405fdbd1941e14cb0700628ae065cce736069 Mon Sep 17 00:00:00 2001 From: Laszlo Toth Date: Mon, 19 Oct 2026 09:45:34 +0200 Subject: [PATCH 2/3] chore(release): v1.2.1 --- package.json | 2 +- src/version.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 3748972..96a88c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "arrstack-installer", - "version": "1.2.0", + "version": "1.2.1", "type": "module", "bin": { "arrstack": "src/cli.ts" diff --git a/src/version.ts b/src/version.ts index a3a25fb..f92f80d 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "1.2.0"; +export const VERSION = "1.2.1"; From cfdc06c56bf51defc38a14d0dc0d2fe718e86613 Mon Sep 17 00:00:00 2001 From: Laszlo Toth Date: Mon, 19 Oct 2026 09:47:52 +0200 Subject: [PATCH 3/3] test: make wiring rejection tests network-free The unreachable-API tests relied on connection-refused errors being non-retryable. On GitHub's runners the error matched withRetry's retry predicate, so each test burned the full 15.5s backoff and tripped the 5s test timeout. Mock fetch instead: failure and 403 paths are now deterministic and cannot hit a real arrstack install. --- tests/wiring/qbittorrent.test.ts | 41 ++++++++--------- tests/wiring/sonarr-radarr.test.ts | 72 +++++++++++++----------------- 2 files changed, 49 insertions(+), 64 deletions(-) diff --git a/tests/wiring/qbittorrent.test.ts b/tests/wiring/qbittorrent.test.ts index 894d4da..7b4f859 100644 --- a/tests/wiring/qbittorrent.test.ts +++ b/tests/wiring/qbittorrent.test.ts @@ -1,36 +1,33 @@ import { describe, expect, test, afterEach } from "bun:test"; import { configureQbit } from "../../src/wiring/qbittorrent"; -// NEVER call configureQbit() without an explicit base URL in these tests. -// The default is http://localhost:8080 — on any developer host with an -// arrstack install running, hitting that repeatedly will exceed qBittorrent's -// failed-login threshold (5 attempts) and get the host IP banned until the -// container is restarted. The ban is shared across all future login attempts -// including the installer's own, so the *next* `arrstack install` run fails -// with "qBittorrent login failed: 403". Always point tests at an unreachable -// port so they only exercise the connection-refused path. -const UNREACHABLE = "http://127.0.0.1:19999"; - +// NEVER call configureQbit() against the default localhost:8080 in these +// tests. On any developer host with an arrstack install running, repeated +// failed logins exceed qBittorrent's threshold and ban the host IP until the +// container restarts. Every test here mocks fetch or supplies a fake base URL. describe("configureQbit", () => { - test("is an async function", () => { - expect(configureQbit).toBeInstanceOf(Function); - const result = configureQbit("admin", "pass", UNREACHABLE); - expect(result).toBeInstanceOf(Promise); - result.catch(() => {}); + const origFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = origFetch; }); - test("accepts a custom base URL", () => { - const result = configureQbit("admin", "pass", "http://127.0.0.1:19998"); - expect(result).toBeInstanceOf(Promise); - result.catch(() => {}); + test("is an async function", () => { + expect(configureQbit).toBeInstanceOf(Function); + expect(configureQbit.constructor.name).toBe("AsyncFunction"); }); - test("rejects when the API is unreachable", async () => { - await expect(configureQbit("admin", "pass", UNREACHABLE)).rejects.toThrow(); + test("rejects when the request layer fails", async () => { + globalThis.fetch = (async () => { + throw new Error("boom"); + }) as any; + await expect(configureQbit("admin", "pass", "http://qbit.test")).rejects.toThrow("boom"); }); test("rejects when login is rejected", async () => { - await expect(configureQbit("baduser", "badpass", UNREACHABLE)).rejects.toThrow(); + globalThis.fetch = (async () => new Response("Forbidden", { status: 403 })) as any; + await expect(configureQbit("baduser", "badpass", "http://qbit.test")).rejects.toThrow( + /login failed: 403/, + ); }); }); diff --git a/tests/wiring/sonarr-radarr.test.ts b/tests/wiring/sonarr-radarr.test.ts index 7994b48..a1f2afb 100644 --- a/tests/wiring/sonarr-radarr.test.ts +++ b/tests/wiring/sonarr-radarr.test.ts @@ -2,55 +2,43 @@ import { describe, expect, test, mock, afterEach } from "bun:test"; import { configureArr } from "../../src/wiring/sonarr-radarr"; describe("configureArr", () => { + const origFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = origFetch; + }); + + const optsFor = (category: "tv" | "movies") => ({ + rootFolder: category === "tv" ? "/data/media/tv" : "/data/media/movies", + extraFolders: [], + qbitUser: "admin", + qbitPass: "pass", + category, + }); + test("is an async function", () => { expect(configureArr).toBeInstanceOf(Function); - // Calling with bad args should return a Promise (even if it rejects) - const result = configureArr("sonarr", "key", { - rootFolder: "/data/media/tv", - extraFolders: [], - qbitUser: "admin", - qbitPass: "pass", - category: "tv", - }); - expect(result).toBeInstanceOf(Promise); - // Suppress unhandled rejection for this test - result.catch(() => {}); + expect(configureArr.constructor.name).toBe("AsyncFunction"); }); - test("uses port 8989 for sonarr and 7878 for radarr", () => { - // We verify the port selection logic indirectly: both calls return a - // Promise, proving the function runs and doesn't throw synchronously. - const sonarrPromise = configureArr("sonarr", "key", { - rootFolder: "/data/media/tv", - extraFolders: [], - qbitUser: "admin", - qbitPass: "pass", - category: "tv", - }); - sonarrPromise.catch(() => {}); - expect(sonarrPromise).toBeInstanceOf(Promise); + test("uses port 8989 for sonarr and 7878 for radarr", async () => { + const urls: string[] = []; + globalThis.fetch = (async (url: any) => { + urls.push(String(url)); + return new Response("[]", { status: 200 }); + }) as any; + + await configureArr("sonarr", "key", optsFor("tv")); + await configureArr("radarr", "key", optsFor("movies")); - const radarrPromise = configureArr("radarr", "key", { - rootFolder: "/data/media/movies", - extraFolders: [], - qbitUser: "admin", - qbitPass: "pass", - category: "movies", - }); - radarrPromise.catch(() => {}); - expect(radarrPromise).toBeInstanceOf(Promise); + expect(urls.some((u) => u.startsWith("http://localhost:8989/"))).toBe(true); + expect(urls.some((u) => u.startsWith("http://localhost:7878/"))).toBe(true); }); - test("rejects when the API is unreachable", async () => { - await expect( - configureArr("sonarr", "key", { - rootFolder: "/data/media/tv", - extraFolders: [], - qbitUser: "admin", - qbitPass: "pass", - category: "tv", - }) - ).rejects.toThrow(); + test("rejects when the request layer fails", async () => { + globalThis.fetch = (async () => { + throw new Error("boom"); + }) as any; + await expect(configureArr("sonarr", "key", optsFor("tv"))).rejects.toThrow("boom"); }); });