diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0722eb..1120a79 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -161,7 +161,7 @@ jobs: repository: devluigi06/ipmideck readme-filepath: ./README.md - # ---- Draft GitHub Release (D-10) — user presses Publish ---- + # ---- Draft GitHub Release (D-10) — body from CHANGELOG.md, user presses Publish ---- release-draft: if: ${{ github.event_name == 'push' }} needs: [guard, tests] @@ -169,8 +169,26 @@ jobs: permissions: contents: write # required to create the release steps: + - uses: actions/checkout@v6 + - name: Slice the CHANGELOG section for this tag into the release body + run: | + VER="${GITHUB_REF_NAME#v}" # v2.0.1 -> 2.0.1 + # Emit the lines under `## [VER]` up to (not incl.) the next version header or the + # link-reference block. index()==1 is an exact prefix match, so the [ and . in the + # version string stay literal (no regex escaping needed). + awk -v hdr="## [$VER]" ' + index($0, hdr) == 1 { f = 1; next } + /^## \[/ { f = 0 } + /^\[.*\]: http/ { f = 0 } + f + ' CHANGELOG.md > release-notes.md + if [ ! -s release-notes.md ]; then + echo "::error::CHANGELOG.md has no section for $VER — promote [Unreleased] before tagging." + exit 1 + fi + echo "---- release body ----"; cat release-notes.md - uses: softprops/action-gh-release@v2 with: - draft: true - generate_release_notes: true + draft: true # option B: body pre-filled from CHANGELOG, maintainer Publishes + body_path: release-notes.md # tag_name defaults to the pushed tag (github.ref_name) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..35f3464 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,51 @@ +# Changelog + +All notable changes to IPMIDeck are recorded here. The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +At release time the release workflow slices the `## []` section out of this file and uses +it as the GitHub Release body. Before tagging a version, promote the relevant `[Unreleased]` items +into a new dated `## [] - YYYY-MM-DD` section. + +## [Unreleased] + +## [2.0.1] - 2026-07-25 + +### Fixed + +- Session expiry is now honored. `IPMIDECK_AUTH_SESSION_EXPIRY` (and the `auth.session_expiry` + config key) now set the session token and cookie lifetime; previously the setting had no effect + and the lifetime was always 24 hours. +- FanPilot status no longer reports "active" for monitoring-only vendors (HPE, Lenovo, and unknown + BMCs). Their fans stay under the BMC's own control, and the dashboard now shows that instead of a + false "FanPilot active" state. + +## [2.0.0] - 2026-07-13 + +Complete rewrite. v1 was a single-page app that pushed fan commands at one Dell PowerEdge; v2 is a +self-hosted IPMI platform — a Python/FastAPI backend serving a React dashboard, talking to any +number of BMCs over ipmitool. Everything runs locally: SQLite on disk, no cloud, no telemetry. + +### Added + +- Multi-server dashboard with live sensors (temperature, fan RPM, voltage, power) over a WebSocket, + history charts, and a drag-and-drop widget grid. +- FanPilot: a backend fan-curve engine with hysteresis, a non-negotiable safety override at the + critical threshold, and fail-safe handling when a BMC becomes unreachable. +- Power control (on, soft off, hard off, reset, cycle) with an audit log and per-server energy-cost + tracking. +- Hardware event log (SEL) and FRU inventory, both browsable, searchable, and exportable to CSV/JSON. +- 12 languages, dark and light themes, optional local authentication, HTTPS with self-signed + certificates, and one-click backup/restore. +- Ships as a multi-arch Docker image (`devluigi06/ipmideck`) and the `ipmideck` package on PyPI. + +### Notes + +- Fan control is vendor-specific: Dell is tested on real hardware; Supermicro and IBM are + experimental; HPE, Lenovo, and unknown BMCs are monitoring-only (full sensors, power, SEL, and + FRU, but no fan writes). + +[Unreleased]: https://github.com/ipmideck/IPMIDeck/compare/v2.0.1...HEAD +[2.0.1]: https://github.com/ipmideck/IPMIDeck/compare/v2.0.0...v2.0.1 +[2.0.0]: https://github.com/ipmideck/IPMIDeck/compare/84df472...v2.0.0 diff --git a/README.md b/README.md index 0b4df7a..7bd7a81 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ License: Apache 2.0 Docker Hub pulls PyPI version - Supported BMCs: Dell, Supermicro, IBM, HPE, Lenovo + Supported BMCs: Dell, Supermicro, IBM, HPE, Lenovo, Generic

Documentation: docs.ipmideck.com

@@ -267,7 +267,9 @@ ipmideck/ ## Security - Local authentication with bcrypt password hashing -- Opaque session tokens, HMAC-SHA256 signed with a per-install secret, with configurable expiry +- Opaque session tokens, HMAC-SHA256 signed with a per-install secret, with configurable + expiry (`IPMIDECK_AUTH_SESSION_EXPIRY` / the `auth.session_expiry` config key — e.g. `24h`, + `90m`, `1h`; default `24h`) - BMC credentials encrypted at rest with AES-256-CBC. The 32-byte key is randomly generated and stored in `/encryption.key` — deliberately **outside** the database, so a stolen DB alone decrypts nothing (back the key file up separately) diff --git a/backend/api/auth_routes.py b/backend/api/auth_routes.py index dacbdf4..d33cc0f 100644 --- a/backend/api/auth_routes.py +++ b/backend/api/auth_routes.py @@ -11,7 +11,7 @@ router = APIRouter() -def _set_session_cookie(response: Response, request: Request, token: str) -> None: +def _set_session_cookie(response: Response, request: Request, token: str, max_age: int) -> None: """Issue the session cookie, setting secure=True when the request arrived over HTTPS. Decision R (04-W4-03): all three cookie issuers (login / setup / configure) route through @@ -19,13 +19,17 @@ def _set_session_cookie(response: Response, request: Request, token: str) -> Non request.url.scheme == "https" — true when uvicorn terminates TLS (config.server.https on) or a TLS-terminating reverse proxy forwards the scheme. On plain HTTP it stays False so LAN-only HTTP deployments keep working. + + SX0-A: ``max_age`` is the configured session lifetime in seconds + (auth.session_expiry_seconds), so the cookie Max-Age matches the token exp instead of a + hardcoded 24h. """ response.set_cookie( key="session", value=token, httponly=True, samesite="lax", - max_age=86400, + max_age=max_age, secure=request.url.scheme == "https", ) @@ -122,7 +126,7 @@ async def login(body: LoginRequest, request: Request, response: Response, lang: # 3. Success: clear any prior failure counter, issue session. await auth.reset_failures(body.username) token = auth.create_session_token(body.username) - _set_session_cookie(response, request, token) + _set_session_cookie(response, request, token, auth.session_expiry_seconds) return {"success": True, "username": body.username} @@ -139,7 +143,7 @@ async def setup(body: SetupRequest, request: Request, response: Response, lang: return {"success": False, "error": t("user_already_exists", lang)} await auth.create_user(body.username, body.password) token = auth.create_session_token(body.username) - _set_session_cookie(response, request, token) + _set_session_cookie(response, request, token, auth.session_expiry_seconds) return {"success": True, "username": body.username} @@ -161,7 +165,7 @@ async def configure_auth(body: ConfigureRequest, request: Request, response: Res return {"success": False, "error": str(e)} await auth.set_auth_enabled(True) token = auth.create_session_token(body.username) - _set_session_cookie(response, request, token) + _set_session_cookie(response, request, token, auth.session_expiry_seconds) return {"success": True, "username": body.username} diff --git a/backend/core/auth.py b/backend/core/auth.py index 73d2de1..6e3eb33 100644 --- a/backend/core/auth.py +++ b/backend/core/auth.py @@ -21,12 +21,17 @@ logger = logging.getLogger("ipmideck.auth") -SESSION_EXPIRY_SECONDS = 86400 # 24h default +SESSION_EXPIRY_SECONDS = 86400 # 24h — fallback default when config supplies no/invalid value class AuthManager: def __init__(self, db: Database): self.db = db + # SX0-A: session-token / cookie lifetime in seconds. Defaults to the 24h fallback + # constant; main.py lifespan overwrites it from config.auth.session_expiry + # (IPMIDECK_AUTH_SESSION_EXPIRY) via parse_duration_seconds. Kept == the module + # constant by default so the auth_manager fixture (no config) stays at 24h. + self.session_expiry_seconds: int = SESSION_EXPIRY_SECONDS # Session-token HMAC signing secret (kept in memory after initialize()). # Persisted separately under app_config['session_secret'] — distinct from the # at-rest credential encryption key, which lives in data/encryption.key. @@ -398,7 +403,7 @@ def create_session_token(self, username: str) -> str: payload = { "sub": username, "iat": int(time.time()), - "exp": int(time.time()) + SESSION_EXPIRY_SECONDS, + "exp": int(time.time()) + self.session_expiry_seconds, } data = json.dumps(payload, separators=(",", ":")) sig = hmac.new(self._secret.encode(), data.encode(), hashlib.sha256).hexdigest() diff --git a/backend/core/branding.py b/backend/core/branding.py index 9d40ead..c260f60 100644 --- a/backend/core/branding.py +++ b/backend/core/branding.py @@ -14,7 +14,7 @@ # stable public release): tag == dist == METADATA == this literal, zero per-surface normalization # surprises. pyproject derives the wheel version from THIS via attr: (D-05). # Bump this + tag the same commit to cut a release (firing the tag is a USER action, D-21). -_VERSION_FALLBACK = "2.0.0" +_VERSION_FALLBACK = "2.0.1" # Runtime resolution (D-02): an installed dist (pip/Docker) reports what was ACTUALLY shipped; # a raw source checkout (`python -m backend.main`) falls back to the literal. The dist name diff --git a/backend/core/config.py b/backend/core/config.py index 4242b57..20978bc 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -3,11 +3,30 @@ from __future__ import annotations import os +import re from dataclasses import dataclass, field from pathlib import Path import yaml +_DURATION_UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400} +_DURATION_RE = re.compile(r"^(\d+)([smhd]?)$") + + +def parse_duration_seconds(value: str | int | None, default: int = 86400) -> int: + """Parse a duration like '24h', '90m', '1d', '45s', or a bare integer (seconds) into + seconds. Invalid / non-positive input returns ``default`` (never raises).""" + if value is None or isinstance(value, bool): + # bool is an int subclass — reject it explicitly so True/False can't slip through. + return default + if isinstance(value, int): + return value if value > 0 else default + match = _DURATION_RE.match(value.strip().lower()) + if not match: + return default + seconds = int(match.group(1)) * _DURATION_UNITS[match.group(2) or "s"] + return seconds if seconds > 0 else default + def _data_dir() -> Path: return Path(os.environ.get("IPMIDECK_DATA_DIR", "/data" if os.name != "nt" else "./data")) diff --git a/backend/main.py b/backend/main.py index d695d14..89dd1d8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -21,7 +21,13 @@ from backend.core.auth import AuthManager, require_auth from backend.core.branding import APP_NAME, VERSION, credits_line, render_banner_safe -from backend.core.config import AppConfig, load_config, save_default_config, update_server_yaml +from backend.core.config import ( + AppConfig, + load_config, + parse_duration_seconds, + save_default_config, + update_server_yaml, +) from backend.core.logging_util import suppress_noisy_loggers from backend.core.database import Database from backend.core.modules import ModuleLoader @@ -246,6 +252,11 @@ async def lifespan(app: FastAPI): # Initialize auth auth = AuthManager(db) await auth.initialize() + # SX0-A: thread the configured session lifetime (config.auth.session_expiry / + # IPMIDECK_AUTH_SESSION_EXPIRY) into BOTH the token exp and the cookie max_age. This is + # the single parse point; invalid values fall back to the 24h default without raising. + # Only consulted when auth is enabled — no change to the is_auth_enabled() gating. + auth.session_expiry_seconds = parse_duration_seconds(config.auth.session_expiry) # 08-04 (D-16): in demo mode, seed one synthetic server per canonical vendor so the # per-vendor journeys (tier badges, monitoring-only warnings, loop-skip, argv routing) diff --git a/backend/modules/fanpilot/routes.py b/backend/modules/fanpilot/routes.py index e6214b5..599ebe6 100644 --- a/backend/modules/fanpilot/routes.py +++ b/backend/modules/fanpilot/routes.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, Field from backend.core.i18n import get_lang, t +from backend.core.ipmi_service import is_fan_capable from backend.modules import get_ctx from backend.modules.fanpilot.tasks import get_last_state, set_last_state, wake_loop from backend.modules.sensors.tasks import wake_loop as wake_sensor_loop @@ -152,7 +153,8 @@ async def delete_profile(profile_id: int, lang: str = Depends(get_lang)): async def get_fanpilot_status(server_id: str, lang: str = Depends(get_lang)): ctx = get_ctx() # Fresh lookup — live ctx (Decision J) server = await ctx.db.fetchone( - "SELECT fanpilot_enabled, fanpilot_profile_id FROM servers WHERE id = ?", (server_id,) + "SELECT fanpilot_enabled, fanpilot_profile_id, vendor FROM servers WHERE id = ?", + (server_id,), ) if not server: return {"success": False, "error": t("server_not_found", lang)} @@ -168,7 +170,10 @@ async def get_fanpilot_status(server_id: str, lang: str = Depends(get_lang)): # FanPilot is enabled, trust the DB (the loop will refresh `speed_pct` shortly). cached = get_last_state(server_id) mode = cached["mode"] - if mode == "auto" and server["fanpilot_enabled"]: + # SX0-B: only report "fanpilot" for fan-capable vendors. A monitoring-only vendor (HPE, + # Lenovo, generic) never has FanPilot actively driving fans, so reporting "fanpilot" would + # be a false-active. Default a NULL/empty vendor to "dell" to match /mode's Decision G. + if mode == "auto" and server["fanpilot_enabled"] and is_fan_capable(server["vendor"] or "dell"): mode = "fanpilot" return { diff --git a/scripts/check-wheel.py b/scripts/check-wheel.py index 7c3bcd1..7f0ba7b 100644 --- a/scripts/check-wheel.py +++ b/scripts/check-wheel.py @@ -1,15 +1,26 @@ -"""Assert the built wheel ships static/** + per-module *.sql + version 2.0.0 (SC-4). +"""Assert the built wheel ships static/** + per-module *.sql + the branding version (SC-4). Run `python -m build` first (writes dist/, gitignored). Then `python scripts/check-wheel.py`. +The expected version is read from backend/core/branding.py (_VERSION_FALLBACK), the single source +of truth, so a version bump needs no edit here. + Gitignore note: dist/ + ipmideck.egg-info/ are gitignored build artifacts — never `git add` them. """ from __future__ import annotations import glob +import pathlib +import re import sys import zipfile +_branding = pathlib.Path(__file__).resolve().parent.parent / "backend" / "core" / "branding.py" +_match = re.search(r'_VERSION_FALLBACK\s*=\s*"([^"]+)"', _branding.read_text(encoding="utf-8")) +if not _match: + sys.exit("could not read _VERSION_FALLBACK from backend/core/branding.py") +VERSION = _match.group(1) + whls = sorted(glob.glob("dist/ipmideck-*.whl")) if not whls: sys.exit("no wheel in dist/ — run `python -m build` first") @@ -17,5 +28,7 @@ n = z.namelist() assert any(p.startswith("backend/static/") for p in n), "no SPA (backend/static/) in wheel" assert any(p.endswith(".sql") and "/migrations/" in p for p in n), "no *.sql migrations in wheel" -assert any(p == "ipmideck-2.0.0.dist-info/METADATA" for p in n), "version drift (expected 2.0.0)" +assert any( + p == f"ipmideck-{VERSION}.dist-info/METADATA" for p in n +), f"version drift (expected {VERSION})" print(f"wheel OK: {len(n)} entries") diff --git a/scripts/smoke-docker.ps1 b/scripts/smoke-docker.ps1 index 98a0e63..32b1a5c 100644 --- a/scripts/smoke-docker.ps1 +++ b/scripts/smoke-docker.ps1 @@ -2,6 +2,10 @@ $ErrorActionPreference = "Stop" $img = "ipmideck:smoke"; $name = "ipmideck-smoke"; $vol = "ipmideck-smoke-data" $rev = (git rev-parse HEAD).Trim() +# Version from the single source of truth (backend/core/branding.py) — no hardcoded literal, so a +# bump needs no edit here. Same _VERSION_FALLBACK the wheel build and release.yml guard read. +$ver = ([regex]::Match((Get-Content -Raw backend/core/branding.py), '_VERSION_FALLBACK\s*=\s*"([^"]+)"')).Groups[1].Value +if (-not $ver) { throw "could not read _VERSION_FALLBACK from backend/core/branding.py" } # Host port for the smoke: prefer 3000, but fall back to a free ephemeral port if it is already # in use (e.g. another dev server holds 3000). The container always serves on its internal 3000, @@ -14,7 +18,7 @@ if (Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyCon } # 1. build from clean checkout with the same build-args CI's metadata-action would pass -docker build --build-arg VERSION=2.0.0 --build-arg REVISION=$rev -t $img . +docker build --build-arg VERSION=$ver --build-arg REVISION=$rev -t $img . # 2. run demo mode, throwaway volume, PORT MAPPING (host networking is a no-op on Windows) # best-effort pre-clean of any leftover from a prior run — tolerate "not found" on a clean first @@ -33,9 +37,9 @@ foreach ($i in 1..30) { } if (-not $ok) { throw "health never 200" } -# 4. version consistency: /api/health reports 2.0.0 (wheel via importlib.metadata) +# 4. version consistency: /api/health reports the branding version (wheel via importlib.metadata) $health = Invoke-RestMethod "http://localhost:$port/api/health" -if ($health.version -ne "2.0.0") { throw "health version $($health.version) != 2.0.0" } +if ($health.version -ne $ver) { throw "health version $($health.version) != $ver" } # 5. SPA served (index references hashed assets/) $root = (Invoke-WebRequest "http://localhost:$port/" -UseBasicParsing).Content diff --git a/tests/integration/test_api_routes.py b/tests/integration/test_api_routes.py index d0be82f..0fac976 100644 --- a/tests/integration/test_api_routes.py +++ b/tests/integration/test_api_routes.py @@ -667,3 +667,49 @@ def test_demo_dell_write_echoes_argv_to_command_log(client): assert resp.json()["success"] is True detail = _latest_log_text(client, server_id) assert "0x30 0x30 0x02 0xff" in detail, detail + + +# --- SX0-B: /status must not report a false 'fanpilot active' for monitoring-only vendors ---- + + +def _enable_fanpilot_in_db(server_id: str) -> None: + """Flip servers.fanpilot_enabled=1 directly on the live lifespan DB (mirrors conftest).""" + loop = asyncio.get_event_loop() + loop.run_until_complete( + bm.db.execute("UPDATE servers SET fanpilot_enabled=1 WHERE id=?", (server_id,)) + ) + loop.run_until_complete(bm.db.commit()) + + +def test_status_monitoring_only_vendor_reports_auto_not_fanpilot(client): + """HPE (is_fan_capable False) + fanpilot_enabled + cached 'auto' -> /status mode == 'auto'. + + Without the SX0-B guard the cold-start fallback would overwrite 'auto' -> 'fanpilot' even + though HPE has no IPMI fan control, so /status would falsely claim FanPilot is driving fans. + """ + from backend.modules.fanpilot.tasks import set_last_state + + server_id = _create_server(client, "hpe", "192.0.2.80") + _enable_fanpilot_in_db(server_id) + set_last_state(server_id, "auto") # cache at its default 'auto' + + resp = client.get(f"/api/modules/fanpilot/{server_id}/status") + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["enabled"] is True + assert body["mode"] == "auto", "monitoring-only vendor must NOT report a false 'fanpilot active'" + + +def test_status_fan_capable_vendor_still_reports_fanpilot(client): + """Companion: a fan-capable vendor (dell) with the same setup still resolves 'fanpilot'.""" + from backend.modules.fanpilot.tasks import set_last_state + + server_id = _create_server(client, "dell", "192.0.2.81") + _enable_fanpilot_in_db(server_id) + set_last_state(server_id, "auto") + + resp = client.get(f"/api/modules/fanpilot/{server_id}/status") + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["enabled"] is True + assert body["mode"] == "fanpilot", "fan-capable vendor keeps the cold-start 'fanpilot' fallback" diff --git a/tests/unit/test_branding.py b/tests/unit/test_branding.py index 79fa677..9978590 100644 --- a/tests/unit/test_branding.py +++ b/tests/unit/test_branding.py @@ -11,6 +11,7 @@ from __future__ import annotations +import re from importlib.metadata import PackageNotFoundError, version from pathlib import Path @@ -71,11 +72,12 @@ def test_health_version_literal_absent_from_source(): def test_fallback_is_pep440_canonical(): - """The single edited literal is the PEP 440 canonical form (stable "2.0.0"). - Pure string assert — `packaging` is intentionally NOT imported (not a declared runtime dep). - Canonical form keeps tag == dist == METADATA == metadata-action semver with zero per-surface - normalization (D-03).""" - assert branding._VERSION_FALLBACK == "2.0.0" + """The fallback literal is a PEP 440 canonical X.Y.Z release version (shape, not a pinned + literal — so a future _VERSION_FALLBACK bump stays committable through the pre-commit hook + + CI gate). Pure regex assert — `packaging` is intentionally NOT imported (not a declared + runtime dep). Canonical X.Y.Z keeps tag == dist == METADATA == metadata-action semver with + zero per-surface normalization (D-03).""" + assert re.fullmatch(r"\d+\.\d+\.\d+", branding._VERSION_FALLBACK) def test_version_fallback_when_uninstalled(monkeypatch): @@ -85,12 +87,17 @@ def test_version_fallback_when_uninstalled(monkeypatch): def _raise(_name): raise PackageNotFoundError(_name) monkeypatch.setattr("backend.core.branding.version", _raise) - # Re-run the same resolution the module performs at import time: + # Re-run the same resolution the module performs at import time. Prove the fallback branch + # was ACTUALLY taken (else the assert is vacuous — resolved IS _VERSION_FALLBACK there). + took_fallback = False try: resolved = branding.version("ipmideck") except PackageNotFoundError: + took_fallback = True resolved = branding._VERSION_FALLBACK - assert resolved == branding._VERSION_FALLBACK == "2.0.0" + assert took_fallback, "resolver should hit the PackageNotFoundError fallback path" + assert resolved == branding._VERSION_FALLBACK + assert re.fullmatch(r"\d+\.\d+\.\d+", resolved) def test_version_matches_installed_dist(): diff --git a/tests/unit/test_session_expiry.py b/tests/unit/test_session_expiry.py new file mode 100644 index 0000000..7dfb6fb --- /dev/null +++ b/tests/unit/test_session_expiry.py @@ -0,0 +1,109 @@ +"""Session-expiry configurability tests (SX0-A). + +Proves the three legs of `IPMIDECK_AUTH_SESSION_EXPIRY` / `auth.session_expiry`: + 1. parse_duration_seconds is a pure, never-raising string/int -> seconds parser with a 24h + fallback for anything invalid (unit tests, no I/O). + 2. AuthManager mints a token whose exp - iat == its session_expiry_seconds (unit, no config). + 3. End-to-end: a non-default env value drives the issued cookie Max-Age through the real app + lifespan (config re-load + wiring), using the conftest env-before-import pattern. + +asyncio_mode="auto" (pyproject) => async tests need NO decorator. +""" + +from __future__ import annotations + +import base64 +import json + +import pytest +from fastapi.testclient import TestClient + +from backend.core.config import parse_duration_seconds + + +# === 1. pure parser === + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("24h", 86400), + ("90m", 5400), + ("1h", 3600), + ("1d", 86400), + ("45s", 45), + ("3600", 3600), # bare integer string = seconds + (3600, 3600), # bare integer + (" 1H ", 3600), # whitespace + case-insensitive + ("2D", 172800), + ], +) +def test_parse_duration_valid(value, expected): + assert parse_duration_seconds(value, default=86400) == expected + + +@pytest.mark.parametrize( + "value", + ["", "abc", "-5", "0", "12x", None, "1.5h", "h", " ", "0s", "0h", True, False], +) +def test_parse_duration_invalid_returns_default(value): + """Invalid / non-positive input falls back to the default and never raises.""" + assert parse_duration_seconds(value, default=86400) == 86400 + + +def test_parse_duration_respects_custom_default(): + assert parse_duration_seconds("nonsense", default=1234) == 1234 + + +# === 2. token exp reflects the instance session_expiry_seconds === + + +def _decode_payload(token: str) -> dict: + """Decode the base64url-encoded JSON payload half of a session token.""" + b64 = token.rsplit(".", 1)[0] + raw = base64.urlsafe_b64decode(b64 + "=" * (-len(b64) % 4)).decode() + return json.loads(raw) + + +async def test_token_exp_matches_session_expiry_seconds(auth_manager): + am, _db = auth_manager + am.session_expiry_seconds = 3600 + payload = _decode_payload(am.create_session_token("alice")) + assert payload["exp"] - payload["iat"] == 3600 + + +async def test_token_exp_default_is_24h(auth_manager): + """A fresh AuthManager (no config wiring) keeps the 24h fallback default.""" + am, _db = auth_manager + payload = _decode_payload(am.create_session_token("alice")) + assert payload["exp"] - payload["iat"] == 86400 + + +# === 3. end-to-end: env value -> cookie Max-Age === + + +def test_configured_expiry_drives_cookie_max_age(tmp_path, monkeypatch): + """IPMIDECK_AUTH_SESSION_EXPIRY="1h" (auth ON, fresh temp DB) yields Max-Age=3600 on the + session cookie AND a token whose exp - iat == 3600. + + Env is set BEFORE importing backend.main (conftest Pitfall 3): the lifespan re-runs + load_config() so the override + SX0-A wiring apply. Throwaway synthetic creds only. + """ + monkeypatch.setenv("IPMIDECK_DATA_DIR", str(tmp_path)) + monkeypatch.setenv("IPMIDECK_DEMO", "true") + monkeypatch.setenv("IPMIDECK_DATA_DB_PATH", str(tmp_path / "ipmideck.db")) + monkeypatch.setenv("IPMIDECK_AUTH_SESSION_EXPIRY", "1h") + + from backend.main import app # import AFTER env is set + + with TestClient(app) as c: + # Auth defaults ON in the fresh temp DB, so /setup issues the first session cookie. + r = c.post("/api/auth/setup", json={"username": "admin", "password": "correcthorse"}) + assert r.status_code == 200, r.text + assert r.json()["success"] is True + set_cookie = r.headers["set-cookie"] + assert "Max-Age=3600" in set_cookie, set_cookie + # The token payload exp window matches too (not just the cookie attribute). + token = c.cookies["session"] + payload = _decode_payload(token) + assert payload["exp"] - payload["iat"] == 3600