"""Auth bootstrap reads CYCLONE_ADMIN_*_FILE env vars when set. Mirrors the Docker-secret posture in ``docker-compose.yml``: secrets mounted at ``/run/secrets/`` with the ``*_FILE`` env var pointing at the path. The bootstrap should prefer the file over the bare env var when both are present (file = Docker secret wins). """ from __future__ import annotations from pathlib import Path import pytest from cyclone import db from cyclone.auth import bootstrap, users from cyclone.auth.permissions import Role @pytest.fixture def tmp_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: db_path = tmp_path / "test.db" monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{db_path}") db.init_db() return db_path def _write_secrets(tmp_path: Path, *, username: str, password: str) -> tuple[Path, Path]: user_file = tmp_path / "admin_username" pw_file = tmp_path / "admin_pw" user_file.write_text(f"{username}\n") pw_file.write_text(f"{password}\n") return user_file, pw_file def test_bootstrap_creates_admin_from_file_env_vars( tmp_path: Path, tmp_db: Path, monkeypatch: pytest.MonkeyPatch ) -> None: user_file, pw_file = _write_secrets( tmp_path, username="deploy-admin", password="super-secret-password-123" ) monkeypatch.delenv("CYCLONE_ADMIN_USERNAME", raising=False) monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD", raising=False) monkeypatch.setenv("CYCLONE_ADMIN_USERNAME_FILE", str(user_file)) monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD_FILE", str(pw_file)) bootstrap.run() with db.SessionLocal()() as session: user = users.get_by_username(session, "deploy-admin") assert user is not None assert user.role == Role.ADMIN.value assert users.verify_password("super-secret-password-123", user.password_hash) def test_file_env_var_overrides_plain_env_var( tmp_path: Path, tmp_db: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """When both _FILE and bare env vars are set, _FILE wins.""" user_file, pw_file = _write_secrets( tmp_path, username="file-user", password="file-password-12345" ) monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "env-user") monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "env-password-12345") monkeypatch.setenv("CYCLONE_ADMIN_USERNAME_FILE", str(user_file)) monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD_FILE", str(pw_file)) bootstrap.run() with db.SessionLocal()() as session: file_user = users.get_by_username(session, "file-user") env_user = users.get_by_username(session, "env-user") assert file_user is not None assert env_user is None, "bare env var should be ignored when _FILE is set" def test_bootstrap_falls_back_to_plain_env_var( tmp_path: Path, tmp_db: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.delenv("CYCLONE_ADMIN_USERNAME_FILE", raising=False) monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD_FILE", raising=False) monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "env-user") monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "env-password-12345") bootstrap.run() with db.SessionLocal()() as session: user = users.get_by_username(session, "env-user") assert user is not None assert user.role == Role.ADMIN.value def test_bootstrap_strips_trailing_whitespace_from_file( tmp_path: Path, tmp_db: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Secret files often have a trailing newline from `printf`/`echo`. The bootstrap must strip it so bcrypt verify doesn't see whitespace.""" user_file = tmp_path / "admin_username" pw_file = tmp_path / "admin_pw" user_file.write_text("deploy-admin\n\n") pw_file.write_text("super-secret-password-123\n") monkeypatch.delenv("CYCLONE_ADMIN_USERNAME", raising=False) monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD", raising=False) monkeypatch.setenv("CYCLONE_ADMIN_USERNAME_FILE", str(user_file)) monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD_FILE", str(pw_file)) bootstrap.run() with db.SessionLocal()() as session: user = users.get_by_username(session, "deploy-admin") assert user is not None # Should verify cleanly with no trailing whitespace. assert users.verify_password("super-secret-password-123", user.password_hash) assert not users.verify_password( "super-secret-password-123\n", user.password_hash ) def test_bootstrap_raises_when_file_path_missing( tmp_path: Path, tmp_db: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.delenv("CYCLONE_ADMIN_USERNAME", raising=False) monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD", raising=False) monkeypatch.setenv( "CYCLONE_ADMIN_USERNAME_FILE", str(tmp_path / "does-not-exist") ) monkeypatch.setenv( "CYCLONE_ADMIN_PASSWORD_FILE", str(tmp_path / "also-missing") ) with pytest.raises(RuntimeError, match="failed to read"): bootstrap.run()