diff --git a/AGENTS.md b/AGENTS.md index 23cb3f9..f239836 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,3 +5,7 @@ The Importarr review site at must always reflect th - After making local code changes, run `deploy/review/review.sh update` from the repository root. - Before finishing, verify that `http://172.20.30.35:18765/` responds successfully. - If deployment or URL verification fails, report the failure clearly; do not claim the review site is current. + +## Release workflow + +Generic requests such as "release app", "release to live", or "release importarr" mean the complete release-to-live flow: verify a clean release candidate, run tests, commit the intended changes, create and push a SemVer tag and repository release, call the live bearer's protected `POST /api/control/update?expected_tag=` with the required exact release tag, then verify health and that the reported installed version matches the release. Follow any host-local operational runbook for credentials and live URLs; never put tokens or other secrets in this repository, commands shown in logs, release notes, or commits. diff --git a/Dockerfile b/Dockerfile index 723a16b..365a470 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ WORKDIR /app COPY pyproject.toml README.md LICENSE ./ COPY importarr ./importarr COPY --from=frontend /build/importarr/static ./importarr/static -ARG IMPORTARR_VERSION=0.1.0 +ARG IMPORTARR_VERSION=0.1.1 ARG IMPORTARR_BUILD_DATE=unknown ARG IMPORTARR_GIT_SHA=unknown ENV IMPORTARR_VERSION=$IMPORTARR_VERSION \ diff --git a/Makefile b/Makefile index 2e13fcf..564e83c 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ SERVICE ?= importarr.service IMPORTARR_PREFIX ?= /opt/importarr IMPORTARR_REPO_DIR ?= $(CURDIR) IMPORTARR_URL ?= http://127.0.0.1:8765 +RELEASE ?= vMAJOR.MINOR.PATCH .PHONY: test install-systemd install-from-repo repo-upgrade verify @@ -17,7 +18,7 @@ install-from-repo: sudo -n $(IMPORTARR_PREFIX)/venv/bin/pip install --upgrade $(IMPORTARR_REPO_DIR) repo-upgrade: - sudo -n IMPORTARR_PREFIX=$(IMPORTARR_PREFIX) IMPORTARR_REPO_DIR=$(IMPORTARR_REPO_DIR) sh deploy/repo-upgrade.sh + sudo -n IMPORTARR_PREFIX=$(IMPORTARR_PREFIX) IMPORTARR_REPO_DIR=$(IMPORTARR_REPO_DIR) sh deploy/repo-upgrade.sh $(RELEASE) verify: curl -fsS $(IMPORTARR_URL)/health diff --git a/README.md b/README.md index b5acc66..26c6814 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,12 @@ The installer creates the `importarr` system user when needed, installs a virtua For a machine that should stay current with the repository, use the installed repo-upgrade helper: ```sh -sudo -n sh /opt/importarr/repo-upgrade.sh +sudo -n sh /opt/importarr/repo-upgrade.sh v1.2.3 ``` -The helper refuses to run when the checkout has uncommitted changes, then performs `git pull --ff-only`, reinstalls the package from the repo, restarts `importarr.service`, and prints service status. Use it after changes have been committed and pushed to `main`. +The helper requires the intended release tag and a clean Git checkout at `/opt/importarr/repo` (override with `IMPORTARR_REPO_DIR` only for a nonstandard installation). It fetches tags, checks out that exact tag in detached-HEAD state, reinstalls the package, records tag/SHA provenance, and restarts `importarr.service`; it never installs an arbitrary branch head. -Installed deployments can expose the same operation through the authenticated API. `GET /api/control/update-check` queries the latest release from `IMPORTARR_UPDATE_RELEASE_URL` (default: this repository's Gitea latest-release API) and compares it with the running `IMPORTARR_VERSION`. `POST /api/control/update` performs the same check and only runs the update command when a newer release tag exists. Configure `IMPORTARR_UPDATE_COMMAND` when the default `sh deploy/repo-upgrade.sh` is not correct for the service working directory. The web UI Start, Stop, and Restart controls target `manual-media-import.service` by default; configure `IMPORTARR_START_COMMAND`, `IMPORTARR_STOP_COMMAND`, or `IMPORTARR_RESTART_COMMAND` when those defaults need a wrapper such as sudo. +Installed deployments can expose the same operation through the authenticated API. `GET /api/control/update-check` queries the latest release from `IMPORTARR_UPDATE_RELEASE_URL` (default: this repository's Gitea latest-release API) and compares it with the installed release recorded in `/opt/importarr/build.env`. `POST /api/control/update?expected_tag=v1.2.3` verifies that the latest release is the caller's expected tag, appends that exact tag to the configured command, and schedules it in a detached process after returning. A mismatch returns `409` without scheduling an upgrade. The upgrade helper holds an exclusive lock across fetch, checkout, install, provenance update, and service restart, so concurrent requests cannot overlap. Poll `/health` and `update-check` until the new process is healthy and reports the expected tag. The systemd default is the working-directory-independent `/bin/sh /opt/importarr/repo-upgrade.sh`; configure `IMPORTARR_UPDATE_COMMAND` only when the helper is installed elsewhere. The web UI Start, Stop, and Restart controls target `manual-media-import.service` by default; configure `IMPORTARR_START_COMMAND`, `IMPORTARR_STOP_COMMAND`, or `IMPORTARR_RESTART_COMMAND` when those defaults need a wrapper such as sudo. Release-worthy changes should be committed, tagged with SemVer (`v0.1.1`, `v0.2.0`, ...), pushed with tags, then installed from the tagged checkout or artifact. @@ -53,7 +53,7 @@ Release-worthy changes should be committed, tagged with SemVer (`v0.1.1`, `v0.2. 2. Set its completed folder to the same path mounted as `IMPORTARR_DOWNLOAD_ROOT`. 3. Set `IMPORTARR_SAB_URL` and `IMPORTARR_SAB_API_KEY_FILE` or `IMPORTARR_SAB_API_KEY`. 4. Mount/configure `IMPORTARR_MOVIES_ROOT` and `IMPORTARR_TV_ROOT` read/write. -5. Set `IMPORTARR_AUTH_TOKEN` unless write endpoints are protected by a reverse proxy. +5. Set `IMPORTARR_AUTH_TOKEN_FILE` or `IMPORTARR_AUTH_TOKEN` for every non-loopback bind. Importarr fails closed on write/control endpoints when a non-loopback service has no token; tokenless local development remains available on `127.0.0.1`, `::1`, or `localhost`. 6. Check `GET /health`, then inspect `/api/preview` before running imports. ## Safety model @@ -79,7 +79,7 @@ Release-worthy changes should be committed, tagged with SemVer (`v0.1.1`, `v0.2. - `POST /api/control/cancel-current` - `POST /api/control/restart` - `GET /api/control/update-check` -- `POST /api/control/update` +- `POST /api/control/update?expected_tag=v1.2.3` (`expected_tag` is required) - `POST /api/queue-items/{id}/action` with `{ "action": "retry|ignore|remove" }` - `POST /api/import/run-now` diff --git a/deploy/docker-compose.example.yml b/deploy/docker-compose.example.yml index 8b6e200..2164da7 100644 --- a/deploy/docker-compose.example.yml +++ b/deploy/docker-compose.example.yml @@ -1,6 +1,6 @@ services: importarr: - image: ghcr.io/OWNER/importarr:0.1.0 + image: ghcr.io/OWNER/importarr:0.1.1 env_file: importarr.env ports: - "8765:8765" diff --git a/deploy/importarr.env.example b/deploy/importarr.env.example index b15b06f..34ebddf 100644 --- a/deploy/importarr.env.example +++ b/deploy/importarr.env.example @@ -17,3 +17,5 @@ IMPORTARR_BIND_HOST=0.0.0.0 IMPORTARR_BIND_PORT=8765 IMPORTARR_POLL_SECONDS=60 # IMPORTARR_REPO_DIR=/path/to/importarr +# Override only when the installed helper is not at the standard systemd path. +# IMPORTARR_UPDATE_COMMAND=/bin/sh /opt/importarr/repo-upgrade.sh diff --git a/deploy/repo-upgrade.sh b/deploy/repo-upgrade.sh index 691771d..45a83ff 100644 --- a/deploy/repo-upgrade.sh +++ b/deploy/repo-upgrade.sh @@ -2,7 +2,7 @@ set -eu if [ "$(id -u)" -ne 0 ]; then - echo "Run as root: sudo sh /opt/importarr/repo-upgrade.sh" >&2 + echo "Run as root: sudo sh /opt/importarr/repo-upgrade.sh vMAJOR.MINOR.PATCH" >&2 exit 1 fi @@ -13,9 +13,22 @@ if [ -f "$ENV_FILE" ]; then fi PREFIX=${IMPORTARR_PREFIX:-/opt/importarr} -REPO_DIR=${IMPORTARR_REPO_DIR:-$(pwd)} +REPO_DIR=${IMPORTARR_REPO_DIR:-$PREFIX/repo} SERVICE=${IMPORTARR_SERVICE:-importarr.service} VENV=${IMPORTARR_VENV:-$PREFIX/venv} +RELEASE_TAG=${1:-} + +if [ -z "$RELEASE_TAG" ]; then + echo "Usage: $0 vMAJOR.MINOR.PATCH" >&2 + exit 2 +fi + +LOCK_FILE=${IMPORTARR_UPGRADE_LOCK_FILE:-$PREFIX/repo-upgrade.lock} +exec 9>"$LOCK_FILE" +if ! flock -n 9; then + echo "Refusing to upgrade: another Importarr upgrade is already running." >&2 + exit 1 +fi if [ ! -d "$REPO_DIR/.git" ]; then echo "Importarr repo not found at $REPO_DIR" >&2 @@ -29,14 +42,22 @@ if [ -n "$(git status --porcelain)" ]; then exit 1 fi -git fetch --prune origin -git pull --ff-only +git fetch --prune --tags origin +if ! git rev-parse --verify --quiet "refs/tags/$RELEASE_TAG" >/dev/null; then + echo "Release tag not found: $RELEASE_TAG" >&2 + exit 1 +fi +git checkout --detach "$RELEASE_TAG" +test "$(git describe --tags --exact-match HEAD)" = "$RELEASE_TAG" "$VENV/bin/pip" install --upgrade "$REPO_DIR" install -m 0644 "$REPO_DIR/deploy/importarr.service" /etc/systemd/system/importarr.service +install -m 0755 "$REPO_DIR/deploy/repo-upgrade.sh" "$PREFIX/repo-upgrade.sh" systemctl daemon-reload GIT_SHA="$(git rev-parse --short=12 HEAD 2>/dev/null || printf development)" BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +VERSION="$RELEASE_TAG" cat > "$PREFIX/build.env" <> /etc/importarr/importarr.env -fi install -m 0644 "$REPO_DIR/deploy/importarr.service" /etc/systemd/system/importarr.service install -m 0644 "$REPO_DIR/deploy/manual-media-import.service" /etc/systemd/system/manual-media-import.service install -m 0644 "$REPO_DIR/deploy/manual-media-import.timer" /etc/systemd/system/manual-media-import.timer install -m 0644 "$REPO_DIR/deploy/manual-media-import-failure.service" /etc/systemd/system/manual-media-import-failure.service install -m 0755 "$REPO_DIR/deploy/repo-upgrade.sh" /opt/importarr/repo-upgrade.sh +if [ "$REPO_DIR" != /opt/importarr/repo ]; then + if [ -e /opt/importarr/repo ] && [ ! -L /opt/importarr/repo ]; then + echo "Refusing to replace existing /opt/importarr/repo; move it or set IMPORTARR_REPO_DIR explicitly." >&2 + exit 1 + fi + ln -sfn "$REPO_DIR" /opt/importarr/repo +fi GIT_SHA="$(git -C "$REPO_DIR" rev-parse --short=12 HEAD 2>/dev/null || printf development)" BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +VERSION="$(git -C "$REPO_DIR" describe --tags --exact-match HEAD 2>/dev/null || /opt/importarr/venv/bin/python -c 'from importarr import __version__; print(__version__)')" cat > /opt/importarr/build.env <

Importarr

{status?.control?.queue_mode||"Connecting"}

{currentName||"No active import"}
{menu&& Service info}>
{status&&Object.entries({Version:status.build.version,"Build date":status.build.build_date,"Git SHA":status.build.git_sha,"SAB URL":status.sab_url,"Download root":status.download_root,"Movies root":status.movies_root,"TV root":status.tv_root,"Write auth":status.auth_enabled?"enabled":"disabled"}).map(([k,v])=>
{k}
{String(v)}
)}
}
- {update?.update_available&&Version {update.latest_version} is available} + {update?.update_available&&Version {update.latest_version} is available}
{[["Queue mode",status?.control?.queue_mode],["Current import",currentName||"Idle"],["Imported",status?.imported_total],["Failed",status?.failed_total],["Queue items",status?.queue_total]].map(([label,value])=>{value??"—"}{label})}
Current import

{currentName?`Running ${duration(current.elapsed_seconds)}`:"Waiting for an active import"}

{currentName||"No active copy."}

{currentName&&

{Number(current.percent||0).toFixed(1)}% · {bytes(current.bytes_copied)} / {bytes(current.total_bytes)}

}
Queue and history

Grouped by processing state

{jobs.length?groups.map(group=>{const items=jobs.filter(j=>j.group===group);return items.length?

{labels[group]} {items.length}

{items.map(j=>
{j.name}

{j.source_type} · attempts {j.attempt_count||0} · {j.relative_path||j.storage||j.source_id}

{j.state}{j.reason}
{j.can_run_now&&}{j.can_retry&&}{j.can_ignore&&}{j.can_remove&&}
)}
:null}):

No queue items.

}
diff --git a/importarr/__init__.py b/importarr/__init__.py index 3dc1f76..485f44a 100644 --- a/importarr/__init__.py +++ b/importarr/__init__.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/importarr/config.py b/importarr/config.py index 95115e4..ab95d1a 100644 --- a/importarr/config.py +++ b/importarr/config.py @@ -25,7 +25,7 @@ class Settings(BaseModel): start_command: list[str] = Field(default_factory=lambda: ["systemctl", "start", "manual-media-import.service"]) stop_command: list[str] = Field(default_factory=lambda: ["systemctl", "stop", "manual-media-import.service"]) restart_command: list[str] = Field(default_factory=lambda: ["systemctl", "restart", "manual-media-import.service"]) - update_command: list[str] = Field(default_factory=lambda: ["sh", "deploy/repo-upgrade.sh"]) + update_command: list[str] = Field(default_factory=lambda: ["/bin/sh", "/opt/importarr/repo-upgrade.sh"]) update_release_url: str = "https://gitea.delphas.dk/api/v1/repos/daniels/importarr/releases/latest" update_check_timeout_seconds: int = Field(default=15, ge=1) control_command_timeout_seconds: int = Field(default=120, ge=1) @@ -53,7 +53,7 @@ class Settings(BaseModel): start_command=_env_command("IMPORTARR_START_COMMAND", ["systemctl", "start", "manual-media-import.service"]), stop_command=_env_command("IMPORTARR_STOP_COMMAND", ["systemctl", "stop", "manual-media-import.service"]), restart_command=_env_command("IMPORTARR_RESTART_COMMAND", ["systemctl", "restart", "manual-media-import.service"]), - update_command=_env_command("IMPORTARR_UPDATE_COMMAND", ["sh", "deploy/repo-upgrade.sh"]), + update_command=_env_command("IMPORTARR_UPDATE_COMMAND", ["/bin/sh", "/opt/importarr/repo-upgrade.sh"]), update_release_url=os.getenv("IMPORTARR_UPDATE_RELEASE_URL", "https://gitea.delphas.dk/api/v1/repos/daniels/importarr/releases/latest"), update_check_timeout_seconds=int(os.getenv("IMPORTARR_UPDATE_CHECK_TIMEOUT_SECONDS", "15")), control_command_timeout_seconds=int(os.getenv("IMPORTARR_CONTROL_COMMAND_TIMEOUT_SECONDS", "120")), diff --git a/importarr/main.py b/importarr/main.py index 7381276..5c9a69e 100644 --- a/importarr/main.py +++ b/importarr/main.py @@ -95,7 +95,9 @@ load_ui_settings() def require_write_auth(authorization: Annotated[str | None, Header()] = None) -> None: if not settings.auth_token: - return + if settings.bind_host in {"127.0.0.1", "::1", "localhost"}: + return + raise HTTPException(status_code=503, detail="write endpoints require IMPORTARR_AUTH_TOKEN on a non-loopback bind") if authorization != f"Bearer {settings.auth_token}": raise HTTPException(status_code=401, detail="write endpoint requires bearer token") @@ -336,12 +338,18 @@ def restart_service(_: None = Depends(require_write_auth)) -> dict[str, object]: @app.post("/api/control/update") -def update_service(_: None = Depends(require_write_auth)) -> dict[str, object]: +def update_service(expected_tag: str, _: None = Depends(require_write_auth)) -> dict[str, object]: update = check_update_available() + if update["latest_version"] != expected_tag: + raise HTTPException( + status_code=409, + detail=f"latest release {update['latest_version']} does not match expected tag {expected_tag}", + ) if not update["update_available"]: return {**update, "command": settings.update_command, "stdout": "", "stderr": ""} - result = _run_control_command(settings.update_command) - return {**update, "command_result": result} + command = [*settings.update_command, str(update["latest_version"])] + _schedule_update(command) + return {**update, "status": "update_scheduled", "command": command} @app.get("/api/control/update-check") @@ -389,6 +397,21 @@ def _version_key(value: str) -> tuple[int, ...] | None: return tuple(int(part) for part in parts) +def _schedule_update(command: list[str]) -> None: + if not command: + raise HTTPException(status_code=500, detail="update command is not configured") + try: + subprocess.Popen( + ["/bin/sh", "-c", 'sleep 1; exec "$@"', "importarr-update", *command], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except OSError as exc: + raise HTTPException(status_code=500, detail=f"update command failed to start: {exc.__class__.__name__}") from exc + + def _run_control_command(command: list[str]) -> dict[str, object]: if not command: raise HTTPException(status_code=500, detail="control command is not configured") diff --git a/pyproject.toml b/pyproject.toml index d4724d3..37d2986 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "importarr" -version = "0.1.0" +version = "0.1.1" description = "Arr-style manual SABnzbd import service" readme = "README.md" requires-python = ">=3.12" diff --git a/tests/test_config.py b/tests/test_config.py index a61d1e2..40b9bb4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -15,3 +15,9 @@ def test_file_secret_env_vars_are_supported(tmp_path, monkeypatch): assert settings.sab_api_key == "sab-secret" assert settings.auth_token == "auth-secret" + + +def test_default_update_command_uses_installed_absolute_path(monkeypatch): + monkeypatch.delenv("IMPORTARR_UPDATE_COMMAND", raising=False) + + assert Settings.from_env().update_command == ["/bin/sh", "/opt/importarr/repo-upgrade.sh"] diff --git a/tests/test_deploy_scripts.py b/tests/test_deploy_scripts.py new file mode 100644 index 0000000..b677dcb --- /dev/null +++ b/tests/test_deploy_scripts.py @@ -0,0 +1,32 @@ +import fcntl +import os +import subprocess +from pathlib import Path + + +def test_repo_upgrade_refuses_concurrent_run(tmp_path): + script = Path(__file__).parents[1] / "deploy" / "repo-upgrade.sh" + lock_path = tmp_path / "repo-upgrade.lock" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + fake_id = bin_dir / "id" + fake_id.write_text("#!/bin/sh\nprintf '0\\n'\n") + fake_id.chmod(0o755) + + with lock_path.open("w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + result = subprocess.run( + ["sh", str(script), "v1.2.3"], + env={ + **os.environ, + "PATH": f"{bin_dir}:{os.environ['PATH']}", + "IMPORTARR_ENV_FILE": str(tmp_path / "missing.env"), + "IMPORTARR_PREFIX": str(tmp_path), + }, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 1 + assert "another Importarr upgrade is already running" in result.stderr diff --git a/tests/test_queue_controls.py b/tests/test_queue_controls.py index 62fd390..9c03595 100644 --- a/tests/test_queue_controls.py +++ b/tests/test_queue_controls.py @@ -453,27 +453,19 @@ def test_worker_failure_stops_retrying_after_limit(tmp_path, monkeypatch): assert updated["attempt_count"] == main.MAX_RETRY_ATTEMPTS -def test_control_update_runs_configured_command(tmp_path, monkeypatch): +def test_control_update_schedules_configured_command_with_release_tag(tmp_path, monkeypatch): main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch) main.settings.update_command = ["upgrade", "now"] monkeypatch.setattr(main, "check_update_available", lambda: {"status": "update_available", "current_version": "0.1.0", "latest_version": "0.2.0", "update_available": True, "release_url": None}) calls = [] + monkeypatch.setattr(main, "_schedule_update", calls.append) - def fake_run(command, **kwargs): - calls.append((command, kwargs)) - return main.subprocess.CompletedProcess(command, 0, stdout="updated", stderr="") + result = main.update_service(expected_tag="0.2.0") - monkeypatch.setattr(main.subprocess, "run", fake_run) - - result = main.update_service() - - assert result["status"] == "update_available" - assert result["command_result"]["status"] == "ok" - assert result["command_result"]["command"] == ["upgrade", "now"] - assert result["command_result"]["stdout"] == "updated" - assert calls[0][0] == ["upgrade", "now"] - assert calls[0][1].get("shell") is not True + assert result["status"] == "update_scheduled" + assert result["command"] == ["upgrade", "now", "0.2.0"] + assert calls == [["upgrade", "now", "0.2.0"]] def test_control_update_skips_command_when_current(tmp_path, monkeypatch): @@ -486,7 +478,7 @@ def test_control_update_skips_command_when_current(tmp_path, monkeypatch): monkeypatch.setattr(main.subprocess, "run", fake_run) - result = main.update_service() + result = main.update_service(expected_tag="v0.2.0") assert result["status"] == "current" assert result["command"] == ["upgrade", "now"] diff --git a/tests/test_status.py b/tests/test_status.py index 423b7bb..00a792f 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -1,3 +1,6 @@ +import pytest + + def test_health_contains_build_info(tmp_path, monkeypatch): monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db")) import importarr.main as main @@ -21,6 +24,16 @@ def test_build_date_is_rendered_in_local_time(monkeypatch): assert build_info.build_info()["build_date"] == "2026-07-29T14:00:00+02:00" +def test_build_info_prefers_installed_release_provenance(monkeypatch): + import importarr.build_info as build_info + + monkeypatch.setenv("IMPORTARR_VERSION", "v1.2.3") + monkeypatch.setenv("IMPORTARR_GIT_SHA", "abc123") + + assert build_info.build_info()["version"] == "v1.2.3" + assert build_info.build_info()["git_sha"] == "abc123" + + def test_status_contains_service_configuration(tmp_path, monkeypatch): monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db")) import importarr.main as main @@ -132,3 +145,83 @@ def test_connection_test_rejects_unknown_service(tmp_path, monkeypatch): ) assert response.status_code == 400 + + +def test_control_endpoints_require_configured_bearer_token(tmp_path, monkeypatch): + monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db")) + import importarr.main as main + + from fastapi.testclient import TestClient + + monkeypatch.setattr(main.settings, "auth_token", "test-token") + client = TestClient(main.app) + + for method, endpoint in (("get", "/api/control/update-check"), ("post", "/api/control/update"), ("post", "/api/control/restart")): + assert getattr(client, method)(endpoint).status_code == 401 + assert getattr(client, method)(endpoint, headers={"Authorization": "Bearer wrong"}).status_code == 401 + + +def test_write_endpoint_fails_closed_without_token_on_non_loopback_bind(tmp_path, monkeypatch): + monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db")) + import importarr.main as main + from fastapi.testclient import TestClient + + monkeypatch.setattr(main.settings, "auth_token", None) + monkeypatch.setattr(main.settings, "bind_host", "0.0.0.0") + assert TestClient(main.app).post("/api/control/restart").status_code == 503 + + +def test_tokenless_local_development_remains_available(tmp_path, monkeypatch): + monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db")) + import importarr.main as main + + monkeypatch.setattr(main.settings, "auth_token", None) + monkeypatch.setattr(main.settings, "bind_host", "127.0.0.1") + main.require_write_auth() + + +def test_update_schedules_exact_latest_release_tag(tmp_path, monkeypatch): + monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db")) + import importarr.main as main + + monkeypatch.setattr(main, "check_update_available", lambda: {"status": "update_available", "current_version": "v1.0.0", "latest_version": "v1.2.3", "update_available": True}) + monkeypatch.setattr(main.settings, "update_command", ["/opt/importarr/repo-upgrade.sh"]) + scheduled = [] + monkeypatch.setattr(main, "_schedule_update", scheduled.append) + + response = main.update_service(expected_tag="v1.2.3") + assert scheduled == [["/opt/importarr/repo-upgrade.sh", "v1.2.3"]] + assert response["status"] == "update_scheduled" + + +def test_update_endpoint_requires_expected_tag(tmp_path, monkeypatch): + monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db")) + import importarr.main as main + from fastapi.testclient import TestClient + + monkeypatch.setattr(main.settings, "auth_token", "test-token") + monkeypatch.setattr(main, "check_update_available", lambda: pytest.fail("release lookup must not run")) + + response = TestClient(main.app).post( + "/api/control/update", + headers={"Authorization": "Bearer test-token"}, + ) + + assert response.status_code == 422 + assert response.json()["detail"][0]["loc"] == ["query", "expected_tag"] + + +def test_update_rejects_unexpected_latest_release(tmp_path, monkeypatch): + monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db")) + import importarr.main as main + from fastapi import HTTPException + + monkeypatch.setattr(main, "check_update_available", lambda: {"latest_version": "v1.2.4", "update_available": True}) + scheduled = [] + monkeypatch.setattr(main, "_schedule_update", scheduled.append) + + with pytest.raises(HTTPException) as exc_info: + main.update_service(expected_tag="v1.2.3") + + assert exc_info.value.status_code == 409 + assert scheduled == []