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 @@
-
+
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