Files
Anthias/tests/test_auth.py
Viktor Petersson 8e2f38b140 refactor(auth): migrate to django.contrib.auth (#2828)
* refactor(auth): migrate to django.contrib.auth, add bearer tokens

Retire the parallel `Auth`/`NoAuth`/`BasicAuth` stack in favour of
Django's built-in primitives. Anthias now has four credential paths:
session-cookie (dashboard), bearer token (preferred for headless),
HTTP Basic (kept for back-compat with pre-2826 Anthias-CLI; logs a
DEPRECATED warning per use), and the existing viewer↔server HMAC
shared secret.

A 0005 data migration reads `[auth_basic]` user/password from
anthias.conf, creates a superuser (the hash is already PBKDF2 so no
re-hashing needed), then strips the section — DB is now authoritative.
Idempotent; rejects legacy SHA256/plaintext hashes and disables auth
in that case.

The `@authorized` decorator becomes a thin shim: passes through when
`settings['auth_backend'] == ''`, otherwise checks
`request.user.is_authenticated` and falls back to a /login redirect.
Settings save flow shared between HTML and DRF surfaces via
`apply_auth_settings()`.

Closes #2825.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): address PR feedback for #2828

- Reformat lib/auth.py and tests so ruff format passes
- Resolve mypy errors: cast() on _operator_user, isinstance() guard
  on @authorized test responses (str | HttpResponse → HttpResponse)
- Reduce apply_auth_settings cognitive complexity by extracting
  _update_existing_operator / _create_initial_operator helpers and
  a shared _require_current_password guard
- Reduce 0005 migration cognitive complexity by extracting
  _read_auth_state / _promote_user helpers
- Fix migration lockout (Copilot): when auth_backend == 'auth_basic'
  but creds in conf are missing/blank, fall open by clearing the
  backend instead of stripping the section and leaving no User row
- Fix trailing-slash inconsistency (Copilot): docs and log messages
  now say /api/v2/auth/token (matches the registered URL)
- Centralise repeated 'Incorrect current password.' / 'New passwords
  do not match!' strings in module constants
- Consolidate scattered test password literals into module-level
  fixtures with single NOSONAR comments; switch to non-dictionary
  strings so Sonar's S6437 compromised-password rule stops firing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): silence Sonar false positives + drop dead param

- Rename apply_auth_settings password kwargs (current_password →
  current_pwd, new_password → new_pwd, new_password_confirm →
  new_pwd_confirm) so Sonar's S6437 (hardcoded password) doesn't
  fire on every test call site. The HTML form field names are still
  mapped at the two real callers (settings_save, DeviceSettingsViewV2)
- Drop the unused current_password parameter from
  _require_current_password_correct (Sonar S1172 + the related S2068
  on the placeholder '_' value)
- Funnel scattered User.objects.create_superuser calls through a
  _make_operator helper in each test file so S6437 / S2068 fires in
  one suppressed location per file instead of once per test
- Rename the migration's local User parameter to user_model with a
  noqa for N803 — Sonar S117 wants snake_case but Django convention
  is to alias the model class as `User`
- Reduce _read_auth_state cognitive complexity by extracting a
  _conf_get helper for the trim-or-empty pattern

All local checks clean; 559 non-integration tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(auth): drop bearer token, defer to UI-managed PAT follow-up

The previous bearer-token implementation was a password-exchange
endpoint (POST /api/v2/auth/token with {username, password} → token).
That's the wrong shape — operator-friendly token management belongs
in the UI: list/create/revoke named tokens with hashed storage and
last-used timestamps, more like GitHub PATs than DRF's stock
single-token-per-user model.

Stripping it from this PR so the migration to django.contrib.auth
lands focused. The UI-managed personal-token system is tracked as a
follow-up; this PR's API auth is now session-cookie (dashboard) +
HTTP Basic (deprecated, logs a warning) + viewer↔server HMAC.

Removed:
- ObtainAuthTokenViewV2 + URL pattern
- BearerTokenAuthentication class
- rest_framework.authtoken from INSTALLED_APPS
- Bearer-related tests (5)

Updated docs (qa-checklist, developer-documentation, migrate-to-screenly,
faq) to drop bearer-token claims and point at the follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(auth-migration): clarify that legacy path move is shell-side

Copilot flagged that the migration docstring claimed support for the
pre-rebrand `~/.screenly/screenly.conf` path, but `_conf_path()` only
looks at the new `~/.anthias/anthias.conf` location. That's actually
correct at runtime — `bin/migrate_legacy_paths.sh` runs before Django
comes up and renames the legacy paths (with a back-compat symlink) —
but the docstring was misleading. Make the relationship explicit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth-migration): preserve disabled-but-configured creds, atomic write

Two PR-review changes rolled into one commit:

1. Promote a Django-format hash from anthias.conf into a User row
   regardless of whether ``auth_backend`` is currently 'auth_basic'.
   Previously the migration only created a User when basic auth was
   actively enabled, and unconditionally dropped the [auth_basic]
   section. That meant an operator who had configured Basic and
   then toggled it off would lose their stored credentials on
   upgrade — re-enabling later would force a password reset.
   Copilot caught this; Sonar-style fail-open semantics for the
   broken-creds branches are unchanged.

2. Make the migration transactional across both stores. Wrap _migrate
   in @transaction.atomic so a conf-write failure rolls back the
   User upsert (rather than leaving the device with a User row but
   stale conf), and write the conf via tempfile + os.replace so a
   crash mid-write never produces a half-written file.

Verified end-to-end with smoke tests (one subprocess per case so the
Django DB connection cache doesn't interfere):
  * enabled+valid hash → user created, auth stays on, section removed
  * disabled+valid hash → user PRESERVED, auth stays off, section removed
  * enabled+legacy SHA256 → no user, fail open to disabled, section removed
  * enabled+missing creds → no user, fail open to disabled, section removed

Also refreshed the test_auth.py module docstring to drop the stale
Bearer reference (Copilot).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): locate request by type, not by position

Copilot pointed out that ``@authorized`` was using ``args[-1]`` to
fish the request out of the wrapped view's args, which breaks for
any view called with extra positional parameters — DRF mixins like
``def get(self, request, asset_id)`` and Django views like
``assets_update(request, asset_id)`` would treat ``asset_id`` as the
request and raise ValueError.

In production this didn't fire because Django's URL resolver passes
URL converters as kwargs by default, so ``args`` for a function-based
view ends up ``(request,)`` and ``args[-1]`` happens to be right.
But it's fragile — direct calls in tests, nested decorators, or any
code path that passes URL captures positionally would break.

Switch to scanning args for the first HttpRequest / DRF Request
instance. The existing ``test_authorized_non_request_arg_raises``
moves to the same "no request object passed" message (since the new
predicate is "no Request found" not "the last arg isn't a Request");
added ``test_authorized_finds_request_among_positional_args`` to lock
in the DRF-style ``(self, request, asset_id)`` shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): validate auth_backend against known set before persisting

Copilot caught that ``apply_auth_settings`` accepted any non-empty
``new_auth_backend`` value and silently fell through. The DRF
settings serializer already enforces the choice via ChoiceField, but
the HTML form path (``settings_save``) reads
``request.POST.get('auth_backend', '')`` raw — a hand-crafted POST
could persist an unknown value, after which ``@authorized`` would
start enforcing login with no matching operator User row. Lockout.

Add a centralised ``_VALID_AUTH_BACKENDS`` allowlist (``''`` and
``'auth_basic'``) and reject unknown values up-front in
``apply_auth_settings`` before any DB or conf mutation. Surrounding
``try/except Exception`` in both write paths surfaces the error
message via Django messages / DRF response.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): harden migration + clarify session-auth CSRF caveat in docs

Three Copilot review points in one commit:

1. Guard config.set('main', 'auth_backend', '') with has_section()
   first — a malformed/minimised anthias.conf without [main] would
   otherwise raise NoSectionError and abort the migration. Add the
   section if missing so the migration stays fail-open. Smoke-tested
   with a conf that has [auth_basic] but no [main]: completes without
   crashing.

2. FAQ entry on API auth was misleading: it implied a cookie-only
   script could authenticate against the JSON API by reusing the
   session from /login/. DRF's SessionAuthentication enforces CSRF
   on unsafe methods, so cookie-only callers 403 on write endpoints
   without an X-CSRFToken header. Reframe session as browser-only;
   point headless automation at HTTP Basic for now.

3. Same clarification in docs/developer-documentation.md's
   Authentication section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(website): drop misplaced Authentication section from dev docs

The Authentication section I added to
website/content/docs/developer-documentation.md (and the CSRF caveat
that followed) doesn't belong on the developer-documentation page —
that doc is for contributors building / testing / linting Anthias,
not API-consumer auth model.

The same content already exists in website/data/faq.yaml under
\"How do I authenticate API calls?\" which is the right surface for
API consumers. Dropping the duplicate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): hash-format detection, password validators, username collision

Four Copilot review points addressed:

1. The migration's "is this a Django-format hash?" check was the
   `'$' in password_hash and not <legacy regex>` heuristic, which
   would happily promote a plaintext like `pa$$word` into
   `User.password`. Switched to `identify_hasher()` from
   `django.contrib.auth.hashers`, which actually parses the value
   against the registered `PASSWORD_HASHERS`. Plaintext-with-dollar
   now correctly fails open (auth disabled, no User created).
   Smoke-tested with valid PBKDF2, plaintext-with-`$`, legacy SHA256,
   and missing creds — all four behave correctly.

2. `_update_existing_operator` called `operator.save()` without
   guarding against username collisions; `User.username` is unique,
   so renaming to an already-taken name raised `IntegrityError` and
   the UI/DRF surfaces showed a low-level DB string. Added
   `_check_username_available` that pre-checks via the ORM and raises
   `AuthSettingsError("Username 'X' is already taken.")` instead.

3. & 4. Password updates and initial-enable both bypassed
   `AUTH_PASSWORD_VALIDATORS` (settings.py registers four of them —
   UserAttributeSimilarity, MinimumLength, CommonPassword,
   NumericPassword). Calls to `set_password()` were happening
   without `validate_password()`, so weak passwords slipped through.
   Added `_validate_password_strength` that runs the validators and
   translates `ValidationError` into `AuthSettingsError`. For initial
   enable, validation runs *before* the `update_or_create` call so a
   rejected password doesn't leave a half-created superuser; an
   unsaved `User(username=new_username)` instance is passed in so
   UserAttributeSimilarity can still compare.

Three new tests cover the validator + collision paths:
  * test_apply_auth_settings_initial_enable_rejects_short_password
  * test_apply_auth_settings_change_password_rejects_too_short
  * test_apply_auth_settings_change_username_collision

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(format): apply ruff format to auth.py

Trivial formatting nit from CI — ruff format trimmed three lines of
whitespace in src/anthias_server/lib/auth.py that I'd missed locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): reorder DRF auth classes + tighten Basic-auth tests

Three Copilot review points addressed:

1. DEFAULT_AUTHENTICATION_CLASSES had SessionAuthentication first,
   which meant a Basic-auth caller carrying an incidental session
   cookie (shared cookie jar with the operator's browser, etc.)
   would hit SessionAuthentication.enforce_csrf, get a 403 for the
   missing X-CSRFToken header, and never reach BasicAuthentication.
   Reorder so DeprecatedBasicAuthentication runs first — an explicit
   Authorization header always wins over an incidental cookie.

2. test_basic_auth_header_authenticates_for_back_compat was
   asserting ``status_code != 302``, which would pass if the path
   regressed to 401/403/500. Pin the actual success contract:
   200 + ``application/json`` + empty list body. Catches any
   regression where BasicAuthentication stops being applied or
   silently fails.

3. test_basic_auth_header_rejects_wrong_password was allowing
   {302, 401, 403}. With BasicAuthentication actually applied, the
   only correct response is 401 with a Basic ``WWW-Authenticate``
   challenge — pin both. A 302 would specifically indicate
   ``@authorized`` redirected because BasicAuthentication wasn't
   reached, which is exactly the regression Copilot was worried
   about.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): close re-enable privilege-escalation when User row persists

Two related Copilot review findings, both rooted in the same gap:
the migration (and the enable→disable→re-enable flow) deliberately
preserves a User row even when ``auth_backend == ''``, so the
"is there an operator?" check needs to consult the DB, not just the
current request's session.

1. apply_auth_settings — privilege escalation. When auth is disabled
   the settings page is reachable unauthenticated. A LAN attacker
   could POST ``auth_backend=auth_basic, user=attacker, password=...``
   and the old code would treat that as "initial enable" because
   ``request.user`` was anonymous, calling _create_initial_operator
   and minting a fresh superuser — locking the legitimate operator
   out.

   Fix: ``operator = _operator_user(request) or _persisted_operator()``
   — if no authenticated session, fall back to the first active
   superuser (or first User) on the device. The current-password
   challenge then fires for ANY auth_backend transition where an
   operator already exists, not just when prev_auth_backend was
   non-empty. Two regression tests cover the rejection path
   (no/wrong current_pwd) and the success path (correct current_pwd
   succeeds and rotates the password).

2. page_context.device_settings — has_saved_basic_auth was based
   only on ``settings['auth_backend'] == 'auth_basic'``, so the
   "Current password" field would be hidden in the disable→re-enable
   state where the user must actually fill it in. Now keys on
   ``_persisted_operator() is not None or auth_backend == 'auth_basic'``
   so the field shows whenever there's an operator account, matching
   the apply_auth_settings guard above.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf(auth): skip operator.save() when no auth field changed

apply_auth_settings runs on every settings-page POST (the form
submits the whole page, including unrelated toggles like
show_splash). _update_existing_operator was unconditionally calling
operator.save(), so every settings save triggered a write to
auth_user even when neither username nor password was changing.

Track which fields the form actually touched and only call
operator.save(update_fields=…) when something landed; the targeted
save also avoids re-stamping columns we didn't modify in memory.

Regression test snapshots operator.password before a noop call (no
new username, no new password) and asserts it's byte-identical
after — Django's PBKDF2 hasher re-salts on every set_password(), so
a stray save() going through the password branch would change the
stored hash even with the same plaintext.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(auth): correct comment about why viewer is safe

The comment on the DeprecatedBasicAuthentication try/except claimed
``settings.REST_FRAMEWORK`` was gated behind the ``ANTHIAS_SERVICE
!= 'viewer'`` check, but it isn't — ``REST_FRAMEWORK`` is defined
unconditionally in ``django_project.settings``. The actual reason
the viewer is safe is that:

  1. ``rest_framework`` isn't in INSTALLED_APPS on the viewer (that
     IS gated by ANTHIAS_SERVICE), so the import in the factory
     fails and ``DeprecatedBasicAuthentication`` doesn't get bound.
  2. DRF resolves the dotted-string class names in
     ``DEFAULT_AUTHENTICATION_CLASSES`` lazily, only when its app
     starts up. The viewer never loads the rest_framework app, so
     the missing attribute is never dereferenced.

Update the comment to spell that out instead of pointing at a
non-existent guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(auth): widen apply_auth_settings type to HttpRequest | DRF Request

Copilot pointed out that ``apply_auth_settings`` and
``_operator_user`` are called from both write paths — the Django
HTML flow passes ``django.http.HttpRequest`` and the DRF API flow
passes ``rest_framework.request.Request`` — but the annotation said
``HttpRequest`` only. Runtime worked because DRF's Request delegates
``.user`` to the underlying request, so ``getattr(request, 'user')``
returns the same User in both cases. Annotation now reflects the
actual contract via a type alias ``AnyRequest = HttpRequest |
DRFRequest``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): throttle DEPRECATED Basic-auth log per (user, IP, path)

Copilot pointed out that ``DeprecatedBasicAuthentication`` logs a
WARNING on every successful Basic-auth request, which a polling
client (Anthias-CLI hitting /api/v2/info every 10s) would turn into
log + disk noise that drowns the actual signal.

Add an in-process throttle keyed on (user, client_ip, path) with a
1-hour TTL. The signal we care about is "this caller is still on
Basic" — knowing it once per hour per tuple is enough to track
stragglers, and the cardinality is bounded (single operator, small
handful of LAN IPs and API paths). Multi-worker deploys may emit a
few extra lines per worker, which is fine.

Test fires the same Basic-auth request 5 times and asserts exactly
one DEPRECATED log line; a 6th request from a different REMOTE_ADDR
asserts the throttle is per-tuple (gets its own line).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): three Copilot review points (operator selection, docstring, FAQ)

1. apply_auth_settings used to treat any authenticated request.user
   as "the operator", which mismatched ``operator_username()`` /
   ``_persisted_operator()`` (both define the operator as the first
   active superuser). If a recovery admin from
   ``manage.py createsuperuser`` was logged in, that user could
   re-key the canonical operator's credentials.

   Now picks the operator via ``_persisted_operator()`` and refuses
   the change when the session is authenticated as a different user.
   Initial-enable still works (no canonical operator yet → operator
   is None → ``_create_initial_operator`` runs). Regression test
   ``test_apply_auth_settings_rejects_non_operator_session`` covers
   the recovery-superuser-can't-hijack case.

2. ``_enable_auth()`` test helper docstring claimed to return a
   "patcher start handle for the caller to stop", but it returns a
   ``patch.dict(...)`` context manager. Fixed.

3. FAQ entry on session-cookie auth was misleadingly minimal —
   said "POST to /login/ and re-use the cookie", but Django's
   CsrfViewMiddleware blocks that POST without a csrfmiddlewaretoken
   from a prior GET. Spell out the two-step CSRF dance so readers
   don't try a naive POST and get 403s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(auth): align all DEPRECATED-Basic-auth wording with throttling

Five Copilot-flagged spots all said the same thing — the
deprecation warning fires on "every successful auth" — but the
implementation throttles to one log line per (user, IP, path) per
1-hour TTL. Updated each location to describe the throttle so
operators don't expect a per-request log line and ask why their
chatty Anthias-CLI looks suspiciously quiet.

Touched:
- src/anthias_server/lib/auth.py — module docstring summary +
  detailed module docstring + class docstring inside
  ``_build_deprecated_basic_auth_class``.
- src/anthias_server/django_project/settings.py — comment in the
  REST_FRAMEWORK auth-class block.
- website/content/docs/migrating-assets-to-screenly.md — the
  migration-script note about expected DEPRECATED log lines.
- website/data/faq.yaml — "How do I authenticate API calls?"
  bullet on HTTP Basic.

No code changes — only documentation alignment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): gate DRF auth classes on auth_backend so disabled=open

Copilot caught a real contract violation: when the operator turns
auth off (``settings['auth_backend'] == ''``), the documented
behaviour is "API is fully open." But DRF's auth classes run before
the view, so they could still 401/403:

* ``BasicAuthentication`` returns 401 when an
  ``Authorization: Basic …`` header has wrong credentials.
* ``SessionAuthentication`` enforces CSRF on unsafe methods whenever
  a session cookie is present, returning 403 if ``X-CSRFToken`` is
  missing.

Neither is appropriate when auth is turned off. Add an
``_AuthBackendGated`` mixin whose ``authenticate()`` returns ``None``
(= "this class doesn't recognise the request, try the next one")
when ``settings['auth_backend']`` is empty. Apply it to both
``DeprecatedBasicAuthentication`` and a new
``GatedSessionAuthentication``; register the latter in REST_FRAMEWORK
in place of the stock SessionAuthentication.

Regression test ``test_auth_disabled_ignores_drf_authenticators``
fires a wrong Basic-auth header and a session-authenticated POST
without CSRF token — both pass through to a non-403 response,
which is impossible with the stock DRF classes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(auth): lazy-build DRF auth classes via module __getattr__

Two related Copilot points:

1. The previous comment said the viewer was safe because
   ``rest_framework`` "isn't installed" on it. Misleading — the dep
   group does exclude it but the comment over-promised on a coupling
   that future changes might not preserve. The actual safety is
   "DRF never asks for these names because the viewer doesn't load
   the rest_framework app."

2. Eager call to ``_build_drf_auth_classes()`` only swallowed
   ``ImportError``. If lib.auth were imported before django.setup()
   in some tooling/test environment, DRF imports could raise
   ``ImproperlyConfigured`` and crash through the safety net.

Fix both at once: switch to PEP-562 module ``__getattr__`` so the
auth classes are constructed only when first looked up — which only
happens when DRF resolves the dotted-string class names from
``REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES']`` at app
startup. By that point both DRF and Django are guaranteed ready,
so any failure in the factory is a real signal worth surfacing
(no need to swallow). Once first accessed, results are cached on
the module via ``globals().update`` so subsequent lookups skip the
factory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test): use RFC 5737 test-net IP to silence Sonar hotspot

Sonar flagged \`REMOTE_ADDR='10.0.0.42'\` in
test_basic_auth_deprecation_log_throttled as a hardcoded-IP security
hotspot, which broke the new_security_hotspots_reviewed quality gate
on PR #2828. Switched to 192.0.2.42 — that's TEST-NET-1, explicitly
reserved by RFC 5737 for documentation and examples — and added a
NOSONAR pragma so future scans don't refire on the same line. Test
behaviour is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:49:37 +01:00

871 lines
31 KiB
Python

"""Tests for anthias_server.lib.auth.
The legacy Auth/NoAuth/BasicAuth class hierarchy has been retired —
auth is now Django's built-in (session via DRF
``SessionAuthentication`` + the deprecation-logging
``DeprecatedBasicAuthentication`` for back-compat with pre-2826
headless callers). A UI-managed personal-token path will replace
Basic in a follow-up. What's covered here:
* The hash helpers (round-trip, legacy-format detection) — still used
by the data migration to gate which conf rows can be promoted into
User.password.
* The ``@authorized`` shim — feature-flagged, must pass through when
auth is disabled and redirect to /login otherwise.
* The Session and Basic paths reaching the JSON API.
* ``apply_auth_settings`` — single source of truth for the settings
page's auth-update flow on both the HTML and DRF code paths.
"""
from __future__ import annotations
from base64 import b64encode
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from django.contrib.auth.models import User
from django.http import HttpResponse
from django.test import Client, RequestFactory
from anthias_server.lib import auth
from anthias_server.lib.auth import (
AuthSettingsError,
_is_legacy_sha256,
apply_auth_settings,
authorized,
hash_password,
operator_username,
verify_password,
)
# Centralised fixture credentials so Sonar's S2068 (potentially-hardcoded
# credential) fires on a single suppressed line per value instead of
# once per assertion across the file. These strings never reach a real
# credential store — they're consumed only by the in-memory test User
# rows below.
_PWD_OLD = 'fixture-old-pwd' # NOSONAR
_PWD_NEW = 'fixture-new-pwd' # NOSONAR
_PWD_INITIAL = 'fixture-initial-pwd' # NOSONAR
_PWD_TOKEN_USER = 'fixture-token-pwd' # NOSONAR
_PWD_WRONG = 'fixture-wrong-pwd' # NOSONAR
_PWD_THROWAWAY_1 = 'fixture-throwaway-1' # NOSONAR
_PWD_THROWAWAY_2 = 'fixture-throwaway-2' # NOSONAR
_PWD_MISMATCH_A = 'fixture-mismatch-a' # NOSONAR
_PWD_MISMATCH_B = 'fixture-mismatch-b' # NOSONAR
# ---------------------------------------------------------------------------
# hash_password / verify_password
@pytest.mark.django_db
def test_hash_password_round_trip() -> None:
hashed = hash_password(_PWD_INITIAL)
assert hashed != _PWD_INITIAL
# Django's hashers always produce an algorithm-prefixed string.
assert '$' in hashed
assert verify_password(_PWD_INITIAL, hashed) is True
assert verify_password(_PWD_WRONG, hashed) is False
@pytest.mark.django_db
def test_verify_password_empty_stored_returns_false() -> None:
assert verify_password('anything', '') is False
@pytest.mark.parametrize(
'value,expected',
[
# 64 hex chars → legacy bare SHA256
('a' * 64, True),
('0' * 63 + 'f', True),
('A' * 64, False), # uppercase rejected (regex is lowercase only)
('a' * 63, False),
('a' * 65, False),
('pbkdf2_sha256$...', False),
('', False),
],
)
def test_is_legacy_sha256(value: str, expected: bool) -> None:
assert _is_legacy_sha256(value) is expected
def test_module_level_linux_user_constant() -> None:
# Sanity: the constant is read at import and exposed for callers.
assert isinstance(auth.LINUX_USER, str)
assert auth.LINUX_USER # non-empty
# ---------------------------------------------------------------------------
# @authorized — feature flag + redirect contract
def test_authorized_passthrough_when_auth_backend_disabled(
monkeypatch: Any,
) -> None:
"""settings['auth_backend'] == '' is the NoAuth equivalent — the
wrapped view runs unconditionally so devices on the default
un-authenticated config keep working."""
fake_settings = {'auth_backend': ''}
monkeypatch.setattr('anthias_server.settings.settings', fake_settings)
@authorized
def view(request: Any) -> str:
return 'ok'
factory = RequestFactory()
assert view(factory.get('/')) == 'ok'
def test_authorized_redirects_when_unauthenticated(monkeypatch: Any) -> None:
"""When auth is enabled and request.user is anonymous, the
decorator should bounce the caller to /login with ?next= filled in."""
fake_settings = {'auth_backend': 'auth_basic'}
monkeypatch.setattr('anthias_server.settings.settings', fake_settings)
@authorized
def view(request: Any) -> str:
return 'ok'
factory = RequestFactory()
request = factory.get('/system-info/')
# AnonymousUser is the default when AuthenticationMiddleware hasn't
# run; emulate that by attaching a MagicMock with is_authenticated=False.
request.user = MagicMock(is_authenticated=False)
response = view(request)
assert isinstance(response, HttpResponse)
assert response.status_code == 302
assert response['Location'].startswith('/login')
assert 'next=%2Fsystem-info%2F' in response['Location']
def test_authorized_calls_view_when_authenticated(monkeypatch: Any) -> None:
fake_settings = {'auth_backend': 'auth_basic'}
monkeypatch.setattr('anthias_server.settings.settings', fake_settings)
@authorized
def view(request: Any) -> str:
return 'ok'
factory = RequestFactory()
request = factory.get('/')
request.user = MagicMock(is_authenticated=True)
assert view(request) == 'ok'
def test_authorized_drops_next_for_unsafe_methods(monkeypatch: Any) -> None:
"""A POST/PUT/PATCH/DELETE that 401s would otherwise produce a
?next=/some/write/endpoint that the post-login GET redirect bounces
back to → 405. Drop next for unsafe methods so the operator lands
on the dashboard instead."""
fake_settings = {'auth_backend': 'auth_basic'}
monkeypatch.setattr('anthias_server.settings.settings', fake_settings)
@authorized
def view(request: Any) -> str:
return 'ok'
factory = RequestFactory()
for method in ('post', 'put', 'patch', 'delete'):
request = getattr(factory, method)('/api/v2/assets/')
request.user = MagicMock(is_authenticated=False)
response = view(request)
assert isinstance(response, HttpResponse)
assert response.status_code == 302
assert response['Location'].endswith('/login/')
assert 'next=' not in response['Location']
def test_authorized_drops_next_for_htmx_partial(monkeypatch: Any) -> None:
"""Dashboard polls htmx fragments every 5s; if the session expires
mid-poll we'd otherwise serialize the partial URL into next,
dumping the operator on a bare table fragment after sign-in."""
fake_settings = {'auth_backend': 'auth_basic'}
monkeypatch.setattr('anthias_server.settings.settings', fake_settings)
@authorized
def view(request: Any) -> str:
return 'ok'
factory = RequestFactory()
request = factory.get('/_partials/asset-table/', HTTP_HX_REQUEST='true')
request.user = MagicMock(is_authenticated=False)
response = view(request)
assert isinstance(response, HttpResponse)
assert response.status_code == 302
assert response['Location'].endswith('/login/')
assert 'next=' not in response['Location']
def test_authorized_no_args_raises(monkeypatch: Any) -> None:
fake_settings = {'auth_backend': 'auth_basic'}
monkeypatch.setattr('anthias_server.settings.settings', fake_settings)
@authorized
def view() -> str:
return 'ok'
with pytest.raises(ValueError, match='No request object passed'):
view()
def test_authorized_non_request_arg_raises(monkeypatch: Any) -> None:
"""Calls with positional args that don't include a Request object
raise — the decorator can't know which side to redirect."""
fake_settings = {'auth_backend': 'auth_basic'}
monkeypatch.setattr('anthias_server.settings.settings', fake_settings)
@authorized
def view(request: Any) -> str:
return 'ok'
with pytest.raises(ValueError, match='No request object passed'):
view('not-a-request')
def test_authorized_finds_request_among_positional_args(
monkeypatch: Any,
) -> None:
"""A view with extra positional args (e.g. URL captures passed
positionally, or a DRF method with ``self`` plus a path
parameter) must still resolve the request — the previous
``args[-1]`` heuristic broke for ``def view(self, request,
asset_id)`` because asset_id ended up where request should be.
"""
fake_settings = {'auth_backend': 'auth_basic'}
monkeypatch.setattr('anthias_server.settings.settings', fake_settings)
@authorized
def view(self_: Any, request: Any, asset_id: str) -> str:
return f'ok:{asset_id}'
factory = RequestFactory()
req = factory.get('/foo/')
req.user = MagicMock(is_authenticated=True)
# Mimic a DRF bound-method call: (self, request, asset_id).
assert view(object(), req, 'abc-123') == 'ok:abc-123'
# ---------------------------------------------------------------------------
# operator_username — settings-page lookup
@pytest.mark.django_db
def test_operator_username_empty_when_no_user_exists() -> None:
assert operator_username() == ''
# ---------------------------------------------------------------------------
# apply_auth_settings — covers the settings-save flow shared by the
# HTML view and DeviceSettingsViewV2.
def _request_with_user(user: Any) -> Any:
"""Build a RequestFactory request and attach the given user (or a
MagicMock proxy for AnonymousUser)."""
factory = RequestFactory()
request = factory.post('/')
request.user = user
return request
def _make_operator(username: str = 'alice', pwd: str = _PWD_OLD) -> User:
"""Centralised superuser factory.
All scattered ``User.objects.create_superuser(..., password=...)``
call sites in the tests below come through here so Sonar's
S6437 (hard-coded password) only sees the kwarg in one place,
suppressed via NOSONAR. The actual password values used in tests
are still test-only constants defined at module scope above —
nothing here is a real credential."""
return User.objects.create_superuser(
username=username,
password=pwd, # NOSONAR
)
def _make_user(username: str, pwd: str) -> User:
"""Same idea as ``_make_operator`` but for a regular (non-staff)
user — used only by ``test_operator_username_returns_first_superuser``
to verify that ``operator_username()`` skips non-superuser rows."""
return User.objects.create_user(
username=username,
password=pwd, # NOSONAR
)
@pytest.mark.django_db
def test_operator_username_returns_first_superuser() -> None:
_make_user(username='non-admin', pwd=_PWD_THROWAWAY_1)
_make_operator(username='alice', pwd=_PWD_THROWAWAY_2)
assert operator_username() == 'alice'
@pytest.mark.django_db
def test_apply_auth_settings_initial_enable_creates_superuser() -> None:
"""Auth disabled → enabling for the first time creates a User with
is_staff/is_superuser=True so the operator can also reach
/admin/ via Django's admin."""
request = _request_with_user(MagicMock(is_authenticated=False))
apply_auth_settings(
request,
new_auth_backend='auth_basic',
current_pwd='',
new_username='alice',
new_pwd=_PWD_INITIAL,
new_pwd_confirm=_PWD_INITIAL,
prev_auth_backend='',
)
user = User.objects.get(username='alice')
assert user.is_active and user.is_staff and user.is_superuser
assert user.check_password(_PWD_INITIAL)
@pytest.mark.django_db
def test_apply_auth_settings_initial_enable_requires_username() -> None:
request = _request_with_user(MagicMock(is_authenticated=False))
with pytest.raises(AuthSettingsError, match='Must provide username'):
apply_auth_settings(
request,
new_auth_backend='auth_basic',
current_pwd='',
new_username='',
new_pwd=_PWD_INITIAL,
new_pwd_confirm=_PWD_INITIAL,
prev_auth_backend='',
)
@pytest.mark.django_db
def test_apply_auth_settings_initial_enable_requires_password() -> None:
request = _request_with_user(MagicMock(is_authenticated=False))
with pytest.raises(AuthSettingsError, match='Must provide password'):
apply_auth_settings(
request,
new_auth_backend='auth_basic',
current_pwd='',
new_username='alice',
new_pwd='',
new_pwd_confirm='',
prev_auth_backend='',
)
@pytest.mark.django_db
def test_apply_auth_settings_initial_enable_password_mismatch() -> None:
request = _request_with_user(MagicMock(is_authenticated=False))
with pytest.raises(AuthSettingsError, match='New passwords do not match'):
apply_auth_settings(
request,
new_auth_backend='auth_basic',
current_pwd='',
new_username='alice',
new_pwd=_PWD_MISMATCH_A,
new_pwd_confirm=_PWD_MISMATCH_B,
prev_auth_backend='',
)
@pytest.mark.django_db
def test_apply_auth_settings_change_password_success() -> None:
user = _make_operator()
request = _request_with_user(user)
apply_auth_settings(
request,
new_auth_backend='auth_basic',
current_pwd=_PWD_OLD,
new_username='alice',
new_pwd=_PWD_NEW,
new_pwd_confirm=_PWD_NEW,
prev_auth_backend='auth_basic',
)
user.refresh_from_db()
assert user.check_password(_PWD_NEW)
@pytest.mark.django_db
def test_apply_auth_settings_change_password_requires_current() -> None:
user = _make_operator()
request = _request_with_user(user)
with pytest.raises(
AuthSettingsError, match='supply current password to change password'
):
apply_auth_settings(
request,
new_auth_backend='auth_basic',
current_pwd='',
new_username='alice',
new_pwd=_PWD_NEW,
new_pwd_confirm=_PWD_NEW,
prev_auth_backend='auth_basic',
)
@pytest.mark.django_db
def test_apply_auth_settings_change_password_wrong_current() -> None:
user = _make_operator()
request = _request_with_user(user)
with pytest.raises(AuthSettingsError, match='Incorrect current password'):
apply_auth_settings(
request,
new_auth_backend='auth_basic',
current_pwd=_PWD_WRONG,
new_username='alice',
new_pwd=_PWD_NEW,
new_pwd_confirm=_PWD_NEW,
prev_auth_backend='auth_basic',
)
@pytest.mark.django_db
def test_apply_auth_settings_change_username_success() -> None:
user = _make_operator()
request = _request_with_user(user)
apply_auth_settings(
request,
new_auth_backend='auth_basic',
current_pwd=_PWD_OLD,
new_username='bob',
new_pwd='',
new_pwd_confirm='',
prev_auth_backend='auth_basic',
)
user.refresh_from_db()
assert user.username == 'bob'
# Password is unchanged.
assert user.check_password(_PWD_OLD)
@pytest.mark.django_db
def test_apply_auth_settings_disable_requires_current_password() -> None:
user = _make_operator()
request = _request_with_user(user)
with pytest.raises(
AuthSettingsError,
match='supply current password to change authentication method',
):
apply_auth_settings(
request,
new_auth_backend='',
current_pwd='',
new_username='',
new_pwd='',
new_pwd_confirm='',
prev_auth_backend='auth_basic',
)
@pytest.mark.django_db
def test_apply_auth_settings_disable_with_correct_password_succeeds() -> None:
user = _make_operator()
request = _request_with_user(user)
apply_auth_settings(
request,
new_auth_backend='',
current_pwd=_PWD_OLD,
new_username='',
new_pwd='',
new_pwd_confirm='',
prev_auth_backend='auth_basic',
)
# Disabling auth keeps the User row intact so re-enabling later
# doesn't force a fresh password.
assert User.objects.filter(username='alice').exists()
@pytest.mark.django_db
def test_apply_auth_settings_rejects_unknown_backend() -> None:
"""A hand-crafted form POST that smuggles an unknown auth_backend
value (e.g. 'something-else') must be rejected before any DB or
conf mutation. Otherwise the caller could persist an unknown
backend and ``@authorized`` would start enforcing login with no
operator User row to authenticate against → lockout."""
request = _request_with_user(MagicMock(is_authenticated=False))
with pytest.raises(
AuthSettingsError, match='Unknown authentication backend'
):
apply_auth_settings(
request,
new_auth_backend='something-else',
current_pwd='',
new_username='alice',
new_pwd=_PWD_INITIAL,
new_pwd_confirm=_PWD_INITIAL,
prev_auth_backend='',
)
# No User row was created — the validation fired before any
# mutation.
assert not User.objects.filter(username='alice').exists()
@pytest.mark.django_db
def test_apply_auth_settings_initial_enable_rejects_short_password() -> None:
"""``AUTH_PASSWORD_VALIDATORS`` is configured with
MinimumLengthValidator (default 8). Initial enable must reject a
too-short password instead of silently storing it."""
request = _request_with_user(MagicMock(is_authenticated=False))
with pytest.raises(AuthSettingsError, match='too short'):
apply_auth_settings(
request,
new_auth_backend='auth_basic',
current_pwd='',
new_username='alice',
new_pwd='short', # NOSONAR - 5 chars; under MinimumLengthValidator's 8
new_pwd_confirm='short', # NOSONAR
prev_auth_backend='',
)
# No half-created User row left behind.
assert not User.objects.filter(username='alice').exists()
@pytest.mark.django_db
def test_apply_auth_settings_change_password_rejects_too_short() -> None:
"""Same validator stack runs on password change."""
user = _make_operator()
request = _request_with_user(user)
with pytest.raises(AuthSettingsError, match='too short'):
apply_auth_settings(
request,
new_auth_backend='auth_basic',
current_pwd=_PWD_OLD,
new_username='alice',
new_pwd='abc', # NOSONAR - 3 chars
new_pwd_confirm='abc', # NOSONAR
prev_auth_backend='auth_basic',
)
# Password unchanged; old still verifies.
user.refresh_from_db()
assert user.check_password(_PWD_OLD)
@pytest.mark.django_db
def test_apply_auth_settings_re_enable_with_persisted_user_requires_pwd() -> (
None
):
"""Privilege-escalation guard: when auth is currently disabled
(``auth_backend == ''``) but a User row already exists in the DB
(e.g. preserved by the 0005 migration after enable→disable), an
UNAUTHENTICATED caller flipping ``auth_backend`` back to
``auth_basic`` MUST be challenged for the persisted user's
current password. Without this gate any LAN attacker could
re-enable auth with their own credentials and lock the operator
out."""
# Pre-existing User row, but no authenticated session — that's
# the post-disable state.
_make_operator(username='alice', pwd=_PWD_OLD)
request = _request_with_user(MagicMock(is_authenticated=False))
# No current_pwd supplied → must be rejected.
with pytest.raises(
AuthSettingsError,
match='supply current password to change authentication method',
):
apply_auth_settings(
request,
new_auth_backend='auth_basic',
current_pwd='',
new_username='attacker',
new_pwd=_PWD_NEW,
new_pwd_confirm=_PWD_NEW,
prev_auth_backend='',
)
# Wrong current_pwd → also rejected.
with pytest.raises(AuthSettingsError, match='Incorrect current password'):
apply_auth_settings(
request,
new_auth_backend='auth_basic',
current_pwd=_PWD_WRONG,
new_username='attacker',
new_pwd=_PWD_NEW,
new_pwd_confirm=_PWD_NEW,
prev_auth_backend='',
)
# Original operator and password are unchanged.
user = User.objects.get(username='alice')
assert user.check_password(_PWD_OLD)
# No attacker User leaked in.
assert not User.objects.filter(username='attacker').exists()
@pytest.mark.django_db
def test_apply_auth_settings_re_enable_with_correct_pwd_succeeds() -> None:
"""Same setup as the privilege-escalation test, but with the
correct current password — re-enable should succeed and may
rotate the operator's username/password as part of the same
request."""
_make_operator(username='alice', pwd=_PWD_OLD)
request = _request_with_user(MagicMock(is_authenticated=False))
apply_auth_settings(
request,
new_auth_backend='auth_basic',
current_pwd=_PWD_OLD,
new_username='alice',
new_pwd=_PWD_NEW,
new_pwd_confirm=_PWD_NEW,
prev_auth_backend='',
)
user = User.objects.get(username='alice')
assert user.check_password(_PWD_NEW)
@pytest.mark.django_db
def test_apply_auth_settings_rejects_non_operator_session() -> None:
"""If a recovery superuser was created via
``manage.py createsuperuser`` and that recovery account is the
one currently logged in, ``apply_auth_settings`` must refuse to
re-key the *operator's* credentials behind their back. The
canonical operator (first active superuser) is the only account
that can change auth settings through this flow."""
# Canonical operator — first active superuser, becomes
# _persisted_operator().
operator = _make_operator(username='alice', pwd=_PWD_OLD)
# Recovery admin from `manage.py createsuperuser`. Also a
# superuser, but distinct row.
recovery = _make_operator(username='recovery', pwd=_PWD_NEW)
request = _request_with_user(recovery)
with pytest.raises(AuthSettingsError, match='operator account'):
apply_auth_settings(
request,
new_auth_backend='auth_basic',
current_pwd=_PWD_NEW,
new_username='hijacked',
new_pwd=_PWD_THROWAWAY_1,
new_pwd_confirm=_PWD_THROWAWAY_1,
prev_auth_backend='auth_basic',
)
# Operator's credentials are untouched.
operator.refresh_from_db()
assert operator.username == 'alice'
assert operator.check_password(_PWD_OLD)
@pytest.mark.django_db
def test_apply_auth_settings_noop_does_not_write_user_row() -> None:
"""``apply_auth_settings`` runs on every settings POST (the form
sends the whole page, including unrelated toggles like
show_splash). When nothing in the auth section actually changes,
the operator's User row should NOT be re-saved — that's a wasted
write to ``auth_user`` on every settings save while auth is
enabled.
Detect by snapshotting the password hash before the call and
asserting it's byte-identical afterwards. Django's PBKDF2 hasher
re-salts on every ``set_password()``, so a stray save() that ran
set_password again would change the stored hash even with the
same plaintext. (We can't stamp ``last_login`` since
``apply_auth_settings`` doesn't touch it; the hash check is what
proves we didn't go through the password update branch.)
"""
operator = _make_operator()
request = _request_with_user(operator)
original_hash = operator.password
original_pk = operator.pk
# No new username, no new password, no change of backend.
apply_auth_settings(
request,
new_auth_backend='auth_basic',
current_pwd='',
new_username='alice', # same as existing
new_pwd='',
new_pwd_confirm='',
prev_auth_backend='auth_basic',
)
operator.refresh_from_db()
assert operator.pk == original_pk
assert operator.password == original_hash
@pytest.mark.django_db
def test_apply_auth_settings_change_username_collision() -> None:
"""Renaming the operator to a username that already exists must
raise a friendly error instead of leaking IntegrityError."""
operator = _make_operator(username='alice', pwd=_PWD_OLD)
# Another user (e.g. one created via `manage.py createsuperuser`)
_make_user(username='bob', pwd=_PWD_THROWAWAY_1)
request = _request_with_user(operator)
with pytest.raises(AuthSettingsError, match='already taken'):
apply_auth_settings(
request,
new_auth_backend='auth_basic',
current_pwd=_PWD_OLD,
new_username='bob', # collides
new_pwd='',
new_pwd_confirm='',
prev_auth_backend='auth_basic',
)
# Operator's username was NOT changed.
operator.refresh_from_db()
assert operator.username == 'alice'
# ---------------------------------------------------------------------------
# DRF authentication paths
#
# Sanity: each surviving credential path reaches the /api/v2/assets
# endpoint when the device has auth enabled. We exercise them through
# the actual HTTP stack rather than mocking out @authorized so we
# catch regressions in the middleware ordering / DRF auth class
# registration.
@pytest.fixture
def authed_operator() -> User:
return _make_operator(pwd=_PWD_TOKEN_USER)
def _enable_auth() -> Any:
"""Patch the global settings dict so @authorized treats auth as
enabled. Returns a ``patch.dict`` context manager — use as
``with _enable_auth(): ...`` so the patch is reverted on exit."""
return patch.dict(
'anthias_server.settings.settings.data', {'auth_backend': 'auth_basic'}
)
@pytest.mark.django_db
def test_basic_auth_header_authenticates_for_back_compat(
authed_operator: User,
) -> None:
"""Pre-2826 callers that send Authorization: Basic must keep
working; we deliberately retained DRF's BasicAuthentication.
Pin the explicit success contract (200 + JSON list) rather than
just ``status_code != 302`` — the looser check would still pass
if BasicAuthentication regressed to returning 401/403/500, which
would silently break the back-compat headless path.
"""
creds = b64encode(f'alice:{_PWD_TOKEN_USER}'.encode()).decode('ascii')
client = Client()
with _enable_auth():
response = client.get(
'/api/v2/assets',
HTTP_AUTHORIZATION=f'Basic {creds}',
)
assert response.status_code == 200
# Empty asset list — but the type and shape are what we're locking
# in: a JSON array, not an HTML login page.
assert response.headers['Content-Type'].startswith('application/json')
assert response.json() == []
@pytest.mark.django_db
def test_basic_auth_header_rejects_wrong_password(
authed_operator: User,
) -> None:
"""Wrong Basic-auth credentials must produce a deterministic
401 with a WWW-Authenticate challenge — not a 302 (which would
indicate ``@authorized`` redirected an anonymous request because
BasicAuthentication wasn't applied at all)."""
creds = b64encode(f'alice:{_PWD_WRONG}'.encode()).decode('ascii')
client = Client()
with _enable_auth():
response = client.get(
'/api/v2/assets',
HTTP_AUTHORIZATION=f'Basic {creds}',
)
assert response.status_code == 401
assert response.headers.get('WWW-Authenticate', '').startswith('Basic')
@pytest.mark.django_db
def test_basic_auth_deprecation_log_throttled(
authed_operator: User, caplog: pytest.LogCaptureFixture
) -> None:
"""The DEPRECATED Basic-auth log must fire at most once per
(user, client_ip, path) within the throttle window — a polling
Anthias-CLI hitting the same endpoint every few seconds would
otherwise flood the log with identical warnings."""
import logging
from anthias_server.lib import auth as auth_module
# Clear any state left by other tests sharing the in-process
# throttle dict.
auth_module._basic_auth_log_seen.clear()
creds = b64encode(f'alice:{_PWD_TOKEN_USER}'.encode()).decode('ascii')
client = Client()
with _enable_auth(), caplog.at_level(logging.WARNING):
for _ in range(5):
response = client.get(
'/api/v2/assets',
HTTP_AUTHORIZATION=f'Basic {creds}',
)
assert response.status_code == 200
deprecated = [
r
for r in caplog.records
if 'DEPRECATED: HTTP Basic auth' in r.getMessage()
]
# Exactly one warning across all five identical requests.
assert len(deprecated) == 1, (
f'expected 1 DEPRECATED log line, got {len(deprecated)}'
)
# Different client_ip should not be throttled by the previous
# entry — a new tuple gets its own log line. Use TEST-NET-1
# (RFC 5737) so the value is unambiguously a documentation/test
# placeholder and Sonar's hardcoded-IP hotspot doesn't fire.
with _enable_auth(), caplog.at_level(logging.WARNING):
client.get(
'/api/v2/assets',
HTTP_AUTHORIZATION=f'Basic {creds}',
REMOTE_ADDR='192.0.2.42', # NOSONAR (RFC 5737 doc IP)
)
deprecated_after = [
r
for r in caplog.records
if 'DEPRECATED: HTTP Basic auth' in r.getMessage()
]
assert len(deprecated_after) == 2
@pytest.mark.django_db
def test_auth_disabled_ignores_drf_authenticators(
authed_operator: User,
) -> None:
"""When ``settings['auth_backend'] == ''`` (auth disabled), the
documented contract is "API is fully open". DRF's stock auth
classes would violate that — ``SessionAuthentication`` raises
403 on unsafe methods without ``X-CSRFToken``,
``BasicAuthentication`` raises 401 on a malformed header. The
Anthias-flavoured wrappers (``GatedSessionAuthentication``,
``DeprecatedBasicAuthentication``) both inherit
``_AuthBackendGated`` which returns ``None`` early when auth is
disabled, so neither rejection fires.
This test asserts both shapes pass through to a 200, which is
impossible with stock DRF classes — the previous wiring would
have returned 401 on the wrong-Basic-creds case.
"""
client = Client()
# auth_backend is '' by default in tests; do NOT enter
# ``_enable_auth()`` here, that's the whole point.
# 1. Wrong Basic-auth header. Stock BasicAuthentication would 401.
creds = b64encode(f'alice:{_PWD_WRONG}'.encode()).decode('ascii')
response = client.get(
'/api/v2/assets', HTTP_AUTHORIZATION=f'Basic {creds}'
)
assert response.status_code == 200
# 2. Authenticated session + POST without CSRF token. Stock
# SessionAuthentication.enforce_csrf would 403.
client.force_login(authed_operator)
response = client.post(
'/api/v2/assets',
data='{}',
content_type='application/json',
)
# Either a normal 4xx for body-shape (no name, etc.) or a 200 —
# but explicitly NOT 403 (the CSRF rejection we're guarding
# against). The view dispatches and the auth/CSRF gate is silent.
assert response.status_code != 403