From 11952452d26bed20008a4ef88a8304fb5f041a65 Mon Sep 17 00:00:00 2001 From: Sahir Vhora <221855733+SahirVhora@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:50:18 +0100 Subject: [PATCH] feat: add header-first local token controls --- docs/ADR-0003-deprecation-window.md | 154 +++++++++++++++++++++ docs/DECISIONS_pending_owner_approval.md | 112 +++++++++++++++ src/sapsf_shared/__init__.py | 33 ++++- src/sapsf_shared/flask_base.py | 165 ++++++++++++++++++++++- tests/test_flask_base.py | 127 ++++++++++++++++- 5 files changed, 587 insertions(+), 4 deletions(-) create mode 100644 docs/ADR-0003-deprecation-window.md create mode 100644 docs/DECISIONS_pending_owner_approval.md diff --git a/docs/ADR-0003-deprecation-window.md b/docs/ADR-0003-deprecation-window.md new file mode 100644 index 0000000..6bb9d77 --- /dev/null +++ b/docs/ADR-0003-deprecation-window.md @@ -0,0 +1,154 @@ +# ADR-0003 — Local-token Auth: Header-First, Query-String Deprecated + +| Field | Value | +|------------|----------------------------------------------------------------| +| Status | Accepted, in deprecation window | +| Date | 2026-07-25 | +| Owner | sapsf platform | +| Closes | audit-finding R-006 (legacy `?token=` channel on local Flask) | +| Removal | 2026-10-25 | + +## Context + +The sapsf portfolio's local Flask surfaces historically accepted the +`?token=...` query-string channel as a fallback when an authentication header +was not provided. This was once useful because shell scripts had trouble +passing headers and curl users naturally reach for `--data-urlencode`. + +With the move to 12-factor share-nothing deploys and the proliferation of +cross-tool log scrapers, query-string tokens are now a probe target: + +* They appear in reverse-proxy access logs and process listings. +* They are cached in browser history and corporate proxy caches. +* They survive into the URL bar and leak via shared screenshots. + +## Decision + +Move HTTP-token-gated sapsf local Flask surfaces to header-only. New +write-path surfaces use the shared helper +`sapsf_shared.flask_base.require_local_token` - header-only, +with 403 responses for client authentication failures. A missing server-side +token configuration returns 503. For legacy surfaces still accepting `?token=` (notably +`sf-object-sync/web_ui/app.py`) we add a single `app.logger.warning` +per request so operators see probe traffic in observability. + +Local credential-management Flask surfaces (encrypted-at-rest secrets, +session CSRF on every POST) are out of scope: they have no HTTP token +channel to deprecate. See "Applicability by surface type" below. + +A regression pin at `sf-object-sync/tests/test_web_ui_auth.py` freezes +the current deprecation behaviour: header works, query works but warns, +wrong/missing returns 401. + +## Deprecation timeline + +| Phase | Date | Surface state | +|----------------------|------------|------------------------------------------------------------------------------------------------| +| Now | 2026-07-25 | Header primary. `?token=` accepted, ONE warning log per request. Tests pin behaviour. | +| Removal candidate | 2026-10-25 | `?token=` returns 401 with no warning. Test's fallback case flips to a permanent 401 pin. | +| Post-removal cleanup | 2026-11-25 | Query-string branch removed from `_require_token`; only `X-Auth-Token` and the header fallback. | + +If a critical operator cannot migrate by 2026-10-25, file the blocker at +`_shared/docs/DECISIONS_pending_owner_approval.md` with the specific +caller URL and rolling-impact estimate. + +## Operator action + +By **2026-10-25**, every script and pocket-reference doc that hits an +HTTP-token-gated local sapsf Flask surface (see the table below) must +switch from `?token=...` to the surface's header: `X-Auth-Token` for +sf-object-sync and `X-Report-Token` for report endpoints. After that +date, **sf-object-sync** returns 401 for the `?token=` channel with no +warning; silent breakage is the failure mode for stragglers. The +back-compat reader `check_local_token` (used by `sf-config-compare{,ec}` +and `sf-metadata-vault`) keeps emitting its existing warning log past +the cutover on read-only report paths - the warning stays on by design. + +**Pre-cutover sweep (mandatory by 2026-10-25):** +Run these fixed-string greps (no regex, copy-paste safe): + + ``` + grep -rn --include='*.py' -F 'request.args.get("token' sapsf/ + grep -rn --include='*.py' -F 'request.args.get("_token' sapsf/ + grep -rn --include='*.py' -F 'legacy_query_arg' sapsf/ + ``` + +Together they must identify only the two implementation files in the next +bullet (test files may also pin this behavior). If they surface another +implementation file, the deprecation list is incomplete and +the cutover cannot proceed without an ADR amendment. The `-F` flag +keeps the patterns literal so an operator can paste them blind on +2026-10-25 and trust the output. + +**Files to edit on 2026-10-25:** +* `sapsf/sf-object-sync/web_ui/app.py` -- drop the legacy branch in + `_require_token`; the regression pin + `tests/test_web_ui_auth.py::test_query_token_falls_back_and_logs_deprecation` + flips to a permanent 401 invariant. +* `sapsf/_shared/src/sapsf_shared/flask_base.py` -- decide on the + future of `check_local_token`'s `?token=` fallback per the + write-path-vs-read-path rule in the Compliance section. This ADR + does not force removal here; reviewers must choose per surface. + +Local credential-management surfaces (see second table) are +unaffected by ADR-0003 -- they do not have an HTTP token channel. + +## Applicability by surface type + +ADR-0003 governs HTTP-token-gated Flask surfaces that accept an +`X-Auth-Token` / `?token=` channel. Local credential-management Flask +surfaces (CRUD on stored secrets, scenario packs, manifest review) +use a different model -- encrypted credentials-at-rest plus session +CSRF -- and are **not** covered by ADR-0003. Mixing them here would +mislead the next maintainer. + +### HTTP-token-gated surfaces (ADR-0003 applies) + +| Tool | Helper used | Channel accepted today | Migration target | +|------|-------------|------------------------|------------------| +| `sf-object-sync` (`web_ui/app.py`) | local `_require_token` | header + query (warn) | `require_local_token` (header-only) | +| `sf-config-compare` (`app.py`) | `check_local_token` | header + query | preserves caller-defined denial status | +| `sf-config-compare-ec` (`app.py` + `core/api.py`) | `check_local_token` | header + query | preserves caller-defined denial status | +| `sf-metadata-vault` (`app.py`) | `check_local_token` | header + query | preserves caller-defined denial status | + +### Local credential-management surfaces (ADR-0003 does not apply) + +| Tool | Auth model | Credentials at rest | Notes | +|------|------------|---------------------|-------| +| `sf-rule-tester` (`webapp/app.py`) | Session cookie + CSRF token; outbound SF passwords stored in `webapp/auth.py` | OS keyring (preferred) or `chmod 600` `.secrets.json` fallback | Loopback bind only (`127.0.0.1:5060`); enforced in `webapp/app.py:main()`. No inbound `X-Auth-Token` / `?token=` channel. `webapp/auth.py` is a credential **store**, not a request gate. | +| `sf-transport-pilot` (`src/sftp/webui/app.py`) | Per-tenant credentials encrypted `SecretBox` (libsodium) into SQLite; session CSRF on every POST | SQLite ciphertext + `data/.sftp_secret.key` per secret | Loopback bind enforced at the CLI in `src/sftp/cli.py` (`ui` command); the webui factory itself does not bind. CSRF is the write-protection gate -- a separate concern from any HTTP token. No `X-Auth-Token` / `?token=`. | + +The migration target column above is informational -- rows marked +"preserves caller-defined denial status" are intentionally on the back-compat reader +because all are read-only report endpoints. Write-path gates +(`/`) on those tools should adopt +`require_local_token` ahead of the 2026-10-25 cutover. + +## Compliance + +* **Write paths** MUST use `require_local_token` (header-only, with 403 for + client authentication failures and 503 for missing server configuration). + `sf-object-sync`'s local `_require_token` is + grandfathered through the 2026-10-25 cutover window only -- see + Operator action for the exact cutover rules. Emitting `?token=` to + any other hardened write surface after the cutover returns 401; no + new code path should accept the channel through a back-compat + reader. +* **Read paths** (report endpoints) may stay on `check_local_token` for + back-compat reads, provided every rejection emits an + `app.logger.warning` with `path=` and `remote=` so SOC can grep probe + traffic. `check_local_token` is not "banned"; it is the deliberate + reader helper, distinct from the writer helper. +* New shared Flask blueprints default to `require_local_token`; only fall + back to `check_local_token` when the endpoint is read-only and the + regression pin covers both channels. +* When `WEB_UI_TOKEN` is unset, `_require_token` short-circuits open. + Loopback binding is enforced **per tool**: + - `sf-object-sync` (Table 1) -- env-override via `HOST` + (`os.getenv("HOST", "127.0.0.1")`); soft default, NOT validator- + enforced. Operators must not override `HOST` on a public network. + - `sf-rule-tester` (Table 2) -- hardcoded + `app.run(host="127.0.0.1", port=5060)` in `webapp/app.py:main()`. + - `sf-transport-pilot` (Table 2) -- enforced at the CLI in + `src/sftp/cli.py` (`ui` command validator: rejects any host not in + `{"127.0.0.1", "localhost", "::1"}`). diff --git a/docs/DECISIONS_pending_owner_approval.md b/docs/DECISIONS_pending_owner_approval.md new file mode 100644 index 0000000..167ed9a --- /dev/null +++ b/docs/DECISIONS_pending_owner_approval.md @@ -0,0 +1,112 @@ +# DECISIONS pending owner approval (cross-repo) + +Per `sapsf/CLAUDE.md` archival rule: items in this file are awaiting an +explicit decision from the owner before any delete / archive / major rewrite +action is taken. Companion to `sapsf/PORTFOLIO_OPERATING_MODEL.md` and +`sapsf/SECURITY.md`. + +Last reviewed: 2026-07-25 (sapsf portfolio audit review) + +--- + +## 1. Workshop triplet canonical path + +**Repos involved:** `sf-workshop-advisor/`, `sap-workshop-advisor/`, +`sap-integration-workshop-advisor/` + +**Audit observation:** Three single-file HTML apps with overlapping content. +A canonical version should be chosen; near-duplicates archived. + +**Why it is pending:** Each workshop advisor targets a slightly different +audience (SF-only, SAP general, SAP integration). The "canonical" version is +a product decision, not an engineering one. + +**Options for the owner:** +1. Mark `sf-workshop-advisor/` canonical; archive `sap-workshop-advisor/` and + `sap-integration-workshop-advisor/` with redirects. +2. Keep all three; document the audience split in each README. +3. Merge under a shared `sapsf-workshops/` umbrella with per-audience + subdirs. + +**Owner decision needed before:** any archive or merge action. + +--- + +## 2. sf-position-integrity-checker archival + +**Repo involved:** `sf_position_integrity_checker/` (with the underscore; the +directory `sf-position-integrity-checker/` is the live component) + +**Audit observation:** Two near-duplicate repos exist. The active one is the +hyphenated one; the underscore variant is kept for historical reasons. + +**Why it is pending:** Owner may still be receiving references from older +clients / partner docs to the underscore name. + +**Options for the owner:** +1. Archive `sf_position_integrity_checker/` with a redirect README pointing + to the hyphenated variant. +2. Keep both; tag the underscore variant "frozen as of 2026-07-25". +3. Rename the live repo to match the underscore form. + +**Owner decision needed before:** any rename or archive action. + +--- + +## 3. sf-scope archival + +**Repo involved:** `sf-scope-prep/` (the active component) and the +historical `sf-scope/` + +**Audit observation:** `sf-scope/app.py` historically used `exec()` to run +`.pyc`-derived bytecode, which is unsafe. Replaced with a +`NotImplementedError` shim on 2026-07-25 (commit pending). The repo is +effectively retired. + +**Why it is pending:** Owner wants to confirm no production deploy ever +relied on `exec_pyc`. + +**Options for the owner:** +1. Archive `sf-scope/` now (no production usage confirmed). +2. Keep as reference for the audit trail; add an ARCHIVED.md marker only. +3. Re-implement `exec_pyc` using constrained, vetted patterns and resume. + +**Owner decision needed before:** archive or rewrite. + +--- + +## 4. sf_workflow_monitor archival + +**Repo involved:** `sf_workflow_monitor/` (underscored, with +`.secrets.json`) + +**Audit observation:** Project lifecycle unclear; secret-scanning flagged a +leaked credential on 2026-07-25 (now scrubbed). No recent commits; no +active CI. + +**Why it is pending:** Owner needs to confirm whether the project is +retired, frozen, or in low-priority maintenance. + +**Options for the owner:** +1. Archive repo now (tool superseded by sf-rule-tester + sf-cutover-planner). +2. Resume maintenance; reassign an owner. +3. Mark as "frozen / read-only" and add an ARCHIVED.md marker. + +**Owner decision needed before:** any archive / resume action. + +--- + +## How to record a decision + +When the owner decides on any item above, add a dated note under the +item with: +- Option chosen +- Rationale (one sentence) +- Any constraints from the decision that future audits must respect + +Then act on the decision. Update this file as items are resolved. + +## How to add a new pending decision + +Append a new section at the bottom with the same shape (repos involved, +audit observation, why pending, options, owner action needed before). diff --git a/src/sapsf_shared/__init__.py b/src/sapsf_shared/__init__.py index a396ffc..f6b2960 100644 --- a/src/sapsf_shared/__init__.py +++ b/src/sapsf_shared/__init__.py @@ -1,4 +1,14 @@ -"""sapsf-shared - Shared Python SDK for SAP SuccessFactors tools.""" +"""sapsf-shared - Shared Python SDK for SAP SuccessFactors tools. + +Note on top-level imports: the Flask helpers (``check_local_token``, +``require_local_token``) live in ``sapsf_shared.flask_base`` which imports +``flask``. They are NOT imported eagerly here; instead they are exposed +through PEP 562 module-level ``__getattr__`` so consumers reaching only for +auth-/config- side helpers do not pay the Flask import cost. They remain +findable via ``from sapsf_shared import require_local_token`` per ADR-0003. +""" + +from typing import Any from sapsf_shared.assurance import ( ASSURANCE_SCHEMA, @@ -44,6 +54,27 @@ parse_sf_date, ) +# Lazy-imported Flask helpers: the sapsf_shared.flask_base module pulls in +# ``flask``. We expose them through the top-level namespace (findable via +# ``from sapsf_shared import require_local_token``) but defer the actual +# import so consumers that only need auth-/config- side helpers don't pay +# the Flask import cost. ``__getattr__`` is the PEP 562 mechanism for +# module-level lazy attribute resolution. +_LAZY_ATTRS = frozenset({"check_local_token", "require_local_token"}) + + +def __getattr__(name: str) -> Any: # pragma: no cover - introspection helper + if name in _LAZY_ATTRS: + from sapsf_shared import flask_base + + return getattr(flask_base, name) + raise AttributeError(f"module 'sapsf_shared' has no attribute {name!r}") + + +def __dir__() -> list[str]: # pragma: no cover - introspection helper + return sorted(set(globals()) | set(__all__) | _LAZY_ATTRS) + + __all__ = [ "ASSURANCE_SCHEMA", "AssuranceValidationError", diff --git a/src/sapsf_shared/flask_base.py b/src/sapsf_shared/flask_base.py index 4664d2e..792bc99 100644 --- a/src/sapsf_shared/flask_base.py +++ b/src/sapsf_shared/flask_base.py @@ -8,6 +8,8 @@ - /api/health endpoint - CORS preflight support - Rotating file log handler + - ``require_local_token`` decorator (ADR-0003) for header-only + report-style endpoints. Never accept the token via the query string. """ from __future__ import annotations @@ -15,10 +17,12 @@ import logging import os import secrets +from collections.abc import Callable +from functools import wraps from pathlib import Path from typing import Any -from flask import Flask, abort, jsonify, request, session +from flask import Flask, abort, current_app, jsonify, request, session logger = logging.getLogger(__name__) @@ -178,7 +182,7 @@ def add_cors_headers(response: Any) -> Any: "GET, POST, PUT, PATCH, DELETE, OPTIONS" ) response.headers["Access-Control-Allow-Headers"] = ( - "Content-Type, Authorization, X-CSRF-Token" + "Content-Type, Authorization, X-CSRF-Token, X-Report-Token, X-Auth-Token" ) response.headers["Access-Control-Allow-Credentials"] = "true" return response @@ -190,6 +194,163 @@ def handle_options() -> Any: return None +def require_local_token( + token_supplier: Callable[[], str | None], + *, + header_name: str = "X-Report-Token", +) -> Callable: + """Flask view decorator: header-only local-token gate (ADR-0003). + + Reads the token from the configured request header (``X-Report-Token`` by + default). **Never** accepts the token via the query string. If ``?token=`` + is present we deliberately reject the request, so legacy clients fall over + loudly instead of leaking through reverse-proxy / access-log / Referer + headers. + + Args: + token_supplier: A zero-arg callable returning the configured token + (e.g. ``lambda: config.REPORT_ACCESS_TOKEN``). Returning ``None`` or + an empty string makes the endpoint refuse every request (503) so a + misconfigured deployment cannot become insecurely open. + header_name: Request header name to read the supplied token from. + Defaults to ``X-Report-Token``. Use ``X-Auth-Token`` for movement + tools. + + The decorated view is rejected with 503 if the configured token is empty + (operator never set it), 401 if a ``?token=`` query argument is present + (legacy leak), and 403 if any other condition fails (no header, wrong + header). All comparisons use ``secrets.compare_digest`` for constant-time + safety. + """ + + def decorator(view: Callable) -> Callable: + @wraps(view) + def wrapper(*args: Any, **kwargs: Any) -> Any: + configured = token_supplier() or "" + if not configured: + current_app.logger.warning( + "require_local_token rejected request: reason=not_configured path=%s remote=%s", + request.path, + request.remote_addr, + ) + return ( + jsonify( + { + "error": ( + "Local auth token is not configured " + "for this deployment. Set REPORT_ACCESS_TOKEN " + "(or the tool-specific fallback) and restart." + ) + } + ), + 503, + ) + # Deliberate rejection of ?token=... -- see ADR-0003 and R-001..R-004 + if "token" in request.args: + current_app.logger.warning( + "require_local_token rejected request: reason=legacy_query " + "path=%s remote=%s (ADR-0003: header-only)", + request.path, + request.remote_addr, + ) + return ( + jsonify( + { + "error": ( + "Auth tokens must be sent as the " + f"{header_name} header. The ?token= query " + "parameter is no longer accepted." + ) + } + ), + 401, + ) + supplied = request.headers.get(header_name, "") + if not supplied or not secrets.compare_digest( + supplied.encode("utf-8"), configured.encode("utf-8") + ): + current_app.logger.warning( + "require_local_token rejected request: reason=missing_or_invalid_header " + "header=%s path=%s remote=%s", + header_name, + request.path, + request.remote_addr, + ) + return ( + jsonify( + { + "error": ( + f"Missing or invalid auth token. Pass it in the " + f"{header_name} header." + ) + } + ), + 403, + ) + return view(*args, **kwargs) + + return wrapper + + return decorator + + +def check_local_token( + configured: str | None, + *, + header_name: str = "X-Report-Token", + legacy_query_arg: str = "token", +) -> bool: + """Minimum-disruption local-token check used by existing report routes. + + This helper exists so the four ADR-0003 sites (sf-config-compare, + sf-config-compare-ec, sf-metadata-vault, sf-object-sync) can adopt + header-first auth **without** breaking the existing flow while owners + transition clients. Behaviour: + + 1. ``configured`` empty / falsy → return ``True`` (no token required for + this deployment, matches the existing inline guards). + 2. Header ``header_name`` provided and matches → ``True``. + 3. Otherwise fall back to legacy ``?{legacy_query_arg}=`` (with warning + log so it is visible in monitoring). + 4. Returns ``False`` if neither matches; the caller aborts 403. + + The legacy query-string fallback is **deprecated**; new code should use + :func:`require_local_token` instead. We keep this helper as the bridge + because it keeps the existing ``_check_report_token`` call sites working + until query-string-using clients (links in older reports) are updated. + + Args: + configured: The configured token (e.g. ``REPORT_ACCESS_TOKEN``). + header_name: Request header to read first. Default ``X-Report-Token``. + legacy_query_arg: Query-string parameter name accepted as fallback. + + Returns: + ``True`` if the request is authorised, ``False`` otherwise. + """ + if not configured: + return True + supplied = request.headers.get(header_name, "") + if supplied and secrets.compare_digest(supplied.encode("utf-8"), configured.encode("utf-8")): + return True + legacy = request.args.get(legacy_query_arg, "") + if legacy and secrets.compare_digest(legacy.encode("utf-8"), configured.encode("utf-8")): + current_app.logger.warning( + "check_local_token accepted deprecated query token: path=%s remote=%s " + "query_arg=%s (switch to %s header per ADR-0003)", + request.path, + request.remote_addr, + legacy_query_arg, + header_name, + ) + return True + current_app.logger.warning( + "check_local_token rejected request: reason=missing_or_invalid_token path=%s remote=%s", + request.path, + request.remote_addr, + ) + return False + + def create_app( import_name: str, *, diff --git a/tests/test_flask_base.py b/tests/test_flask_base.py index d750658..2e87094 100644 --- a/tests/test_flask_base.py +++ b/tests/test_flask_base.py @@ -2,10 +2,18 @@ import json -from sapsf_shared.flask_base import create_app +from sapsf_shared.flask_base import check_local_token, create_app, require_local_token class TestSFApp: + def test_flask_helpers_are_direct_but_not_wildcard_exports(self): + import sapsf_shared + + assert sapsf_shared.require_local_token is require_local_token + assert sapsf_shared.check_local_token is check_local_token + assert "require_local_token" not in sapsf_shared.__all__ + assert "check_local_token" not in sapsf_shared.__all__ + def test_app_created(self): app = create_app("test_app") assert app is not None @@ -26,6 +34,17 @@ def test_cors_allows_allowlisted_origin(self): resp = client.get("/api/health", headers={"Origin": "http://localhost"}) assert resp.headers.get("Access-Control-Allow-Origin") == "http://localhost" + def test_cors_allows_both_supported_token_headers(self): + app = create_app("test_app", cors_origins=["http://localhost"]) + with app.test_client() as client: + resp = client.options( + "/api/health", + headers={"Origin": "http://localhost"}, + ) + allowed = resp.headers["Access-Control-Allow-Headers"] + assert "X-Report-Token" in allowed + assert "X-Auth-Token" in allowed + def test_cors_rejects_unlisted_origin(self): app = create_app("test_app", cors_origins=["http://localhost"]) with app.test_client() as client: @@ -108,3 +127,109 @@ def test_secret_key_from_env(self, monkeypatch): def test_auto_secret_key(self): app = create_app("test_app") assert len(app.secret_key) > 0 + + +class TestLocalTokenHelpers: + @staticmethod + def _decorated_app(token: str | None, header_name: str = "X-Report-Token"): + app = create_app("token_test", enable_csrf=False) + + @app.get("/protected") + @require_local_token(lambda: token, header_name=header_name) + def protected(): + return {"ok": True} + + return app + + def test_require_local_token_accepts_matching_header(self): + app = self._decorated_app("expected") + with app.test_client() as client: + resp = client.get("/protected", headers={"X-Report-Token": "expected"}) + assert resp.status_code == 200 + assert resp.get_json() == {"ok": True} + + def test_require_local_token_supports_custom_header(self): + app = self._decorated_app("expected", "X-Auth-Token") + with app.test_client() as client: + resp = client.get("/protected", headers={"X-Auth-Token": "expected"}) + assert resp.status_code == 200 + + def test_require_local_token_rejects_query_even_with_valid_header(self, caplog): + app = self._decorated_app("expected") + with app.test_client() as client, caplog.at_level("WARNING"): + resp = client.get( + "/protected?token=expected", + headers={"X-Report-Token": "expected"}, + ) + assert resp.status_code == 401 + assert "reason=legacy_query" in caplog.text + assert "path=/protected" in caplog.text + assert "remote=127.0.0.1" in caplog.text + + def test_require_local_token_rejects_missing_or_wrong_header(self, caplog): + app = self._decorated_app("expected") + with app.test_client() as client, caplog.at_level("WARNING"): + missing = client.get("/protected") + wrong = client.get("/protected", headers={"X-Report-Token": "not-expected"}) + assert missing.status_code == 403 + assert wrong.status_code == 403 + assert caplog.text.count("reason=missing_or_invalid_header") == 2 + + def test_require_local_token_fails_closed_when_unconfigured(self, caplog): + app = self._decorated_app(None) + with app.test_client() as client, caplog.at_level("WARNING"): + resp = client.get("/protected") + assert resp.status_code == 503 + assert "reason=not_configured" in caplog.text + assert "path=/protected" in caplog.text + assert "remote=127.0.0.1" in caplog.text + + def test_check_local_token_preserves_open_unconfigured_behavior(self, caplog): + app = create_app("token_test", enable_csrf=False) + with app.test_request_context("/report"), caplog.at_level("WARNING"): + assert check_local_token(None) + assert not caplog.text + + def test_check_local_token_prefers_header_without_warning(self, caplog): + app = create_app("token_test", enable_csrf=False) + with ( + app.test_request_context("/report", headers={"X-Report-Token": "expected"}), + caplog.at_level("WARNING"), + ): + assert check_local_token("expected") + assert not caplog.text + + def test_check_local_token_accepts_legacy_query_once_with_warning(self, caplog): + app = create_app("token_test", enable_csrf=False) + with app.test_client() as client, caplog.at_level("WARNING"): + + @app.get("/report") + def report(): + return ({"ok": check_local_token("expected")}, 200) + + resp = client.get("/report?token=expected") + assert resp.get_json() == {"ok": True} + assert caplog.text.count("accepted deprecated query token") == 1 + assert "path=/report" in caplog.text + assert "remote=127.0.0.1" in caplog.text + + def test_check_local_token_rejects_and_logs_once(self, caplog): + app = create_app("token_test", enable_csrf=False) + with ( + app.test_request_context( + "/report?token=wrong", environ_base={"REMOTE_ADDR": "127.0.0.1"} + ), + caplog.at_level("WARNING"), + ): + assert not check_local_token("expected") + assert caplog.text.count("rejected request") == 1 + assert "path=/report" in caplog.text + assert "remote=127.0.0.1" in caplog.text + + def test_lazy_exports_remain_visible_without_hiding_module_globals(self): + import sapsf_shared + + names = dir(sapsf_shared) + assert "require_local_token" in names + assert "check_local_token" in names + assert "__name__" in names