mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 13:40:21 +01:00
424 lines
17 KiB
Python
424 lines
17 KiB
Python
"""Tests for the SHELFMARK_API_KEY environment-variable authentication."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import logging
|
|
import os
|
|
import sqlite3
|
|
import tempfile
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from shelfmark.core import api_key
|
|
|
|
|
|
@pytest.fixture
|
|
def user_db():
|
|
from shelfmark.core.user_db import UserDB
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
db = UserDB(os.path.join(tmpdir, "users.db"))
|
|
db.initialize()
|
|
yield db
|
|
|
|
|
|
class TestExtractCandidates:
|
|
def test_bearer_only(self):
|
|
assert api_key.extract_api_key_candidates("Bearer a", None) == ["a"]
|
|
|
|
def test_bearer_case_insensitive_and_trimmed(self):
|
|
assert api_key.extract_api_key_candidates("bearer a ", None) == ["a"]
|
|
|
|
def test_x_api_key_only(self):
|
|
assert api_key.extract_api_key_candidates(None, "b") == ["b"]
|
|
|
|
def test_both_present(self):
|
|
assert api_key.extract_api_key_candidates("Bearer a", "b") == ["a", "b"]
|
|
|
|
def test_non_bearer_scheme_plus_x_api_key(self):
|
|
assert api_key.extract_api_key_candidates("Basic dXNlcjpwYXNz", "b") == ["b"]
|
|
|
|
def test_empty(self):
|
|
assert api_key.extract_api_key_candidates("Bearer ", "") == []
|
|
assert api_key.extract_api_key_candidates(None, None) == []
|
|
|
|
|
|
class TestMatches:
|
|
def test_unset_never_matches(self, monkeypatch):
|
|
monkeypatch.setattr(api_key, "SHELFMARK_API_KEY", "")
|
|
assert api_key.matches_api_key("anything") is False
|
|
assert api_key.matches_api_key("") is False
|
|
|
|
def test_match(self, monkeypatch):
|
|
monkeypatch.setattr(api_key, "SHELFMARK_API_KEY", "s3cret")
|
|
assert api_key.matches_api_key("s3cret") is True
|
|
|
|
def test_mismatch_and_prefix(self, monkeypatch):
|
|
monkeypatch.setattr(api_key, "SHELFMARK_API_KEY", "s3cret")
|
|
assert api_key.matches_api_key("s3cre") is False
|
|
assert api_key.matches_api_key("s3cret ") is False
|
|
assert api_key.matches_api_key("") is False
|
|
|
|
|
|
class TestFirstAdmin:
|
|
def test_none_when_no_admin(self, user_db):
|
|
user_db.create_user(username="alice")
|
|
assert user_db.get_first_admin() is None
|
|
|
|
def test_first_admin_by_id(self, user_db):
|
|
user_db.create_user(username="alice")
|
|
root = user_db.create_user(username="root", role="admin")
|
|
user_db.create_user(username="root2", role="admin")
|
|
assert user_db.get_first_admin()["id"] == root["id"]
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def main_module():
|
|
"""Import `shelfmark.main` with background startup disabled."""
|
|
with patch("shelfmark.download.orchestrator.start"):
|
|
import shelfmark.main as main
|
|
|
|
importlib.reload(main)
|
|
return main
|
|
|
|
|
|
@pytest.fixture
|
|
def wired(main_module, user_db, monkeypatch):
|
|
monkeypatch.setattr(main_module, "user_db", user_db)
|
|
monkeypatch.setattr(api_key, "SHELFMARK_API_KEY", "s3cret")
|
|
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
|
yield main_module
|
|
|
|
|
|
def _bearer(value):
|
|
return {"Authorization": f"Bearer {value}"}
|
|
|
|
|
|
def _x_api_key(value):
|
|
return {"X-Api-Key": value}
|
|
|
|
|
|
def _identity_for_key(wired):
|
|
"""Run the middleware directly against the isolated `user_db` and return the resulting session.
|
|
|
|
Going through a real request/response round trip would need a route
|
|
that reveals identity, and the only such route (`/api/users/me/edit-context`)
|
|
is closed over the real per-worker `users.db` at registration time, not
|
|
the isolated temp DB the `wired` fixture monkeypatches onto
|
|
`main_module.user_db` -- so it can't see admins created here. Calling
|
|
the middleware in a request context sidesteps that entirely.
|
|
"""
|
|
with wired.app.test_request_context("/api/downloads/active", headers=_bearer("s3cret")):
|
|
assert wired.api_key_auth_middleware() is None
|
|
from flask import session
|
|
|
|
return dict(session)
|
|
|
|
|
|
def _cookie_client(app, user, *, is_admin=False, permanent=False):
|
|
client = app.test_client()
|
|
with client.session_transaction() as sess:
|
|
sess["user_id"] = user["username"]
|
|
sess["is_admin"] = is_admin
|
|
sess["db_user_id"] = user["id"]
|
|
sess.permanent = permanent
|
|
return client
|
|
|
|
|
|
class TestKeyedRequests:
|
|
def test_match_reaches_login_required_route(self, wired, user_db):
|
|
user_db.create_user(username="root", role="admin")
|
|
assert (
|
|
wired.app.test_client()
|
|
.get("/api/downloads/active", headers=_bearer("s3cret"))
|
|
.status_code
|
|
== 200
|
|
)
|
|
|
|
def test_match_is_admin(self, wired, user_db):
|
|
user_db.create_user(username="root", role="admin")
|
|
assert (
|
|
wired.app.test_client().get("/api/settings", headers=_bearer("s3cret")).status_code
|
|
== 200
|
|
)
|
|
|
|
def test_match_without_any_admin_user_is_still_admin(self, wired):
|
|
assert (
|
|
wired.app.test_client().get("/api/settings", headers=_bearer("s3cret")).status_code
|
|
== 200
|
|
)
|
|
|
|
def test_match_no_user_db(self, main_module, monkeypatch):
|
|
monkeypatch.setattr(main_module, "user_db", None)
|
|
monkeypatch.setattr(api_key, "SHELFMARK_API_KEY", "s3cret")
|
|
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
|
assert (
|
|
main_module.app.test_client()
|
|
.get("/api/downloads/active", headers=_bearer("s3cret"))
|
|
.status_code
|
|
== 200
|
|
)
|
|
|
|
def test_x_api_key(self, wired, user_db):
|
|
user_db.create_user(username="root", role="admin")
|
|
assert (
|
|
wired.app.test_client()
|
|
.get("/api/downloads/active", headers={"X-Api-Key": "s3cret"})
|
|
.status_code
|
|
== 200
|
|
)
|
|
|
|
def test_wrong_bearer_plus_correct_x_api_key_authenticates(self, wired, user_db):
|
|
"""A proxy's own Authorization header must not shadow a correct X-Api-Key."""
|
|
user_db.create_user(username="root", role="admin")
|
|
headers = {**_bearer("wrong"), **_x_api_key("s3cret")}
|
|
assert wired.app.test_client().get("/api/settings", headers=headers).status_code == 200
|
|
|
|
def test_correct_bearer_plus_wrong_x_api_key_authenticates(self, wired, user_db):
|
|
user_db.create_user(username="root", role="admin")
|
|
headers = {**_bearer("s3cret"), **_x_api_key("wrong")}
|
|
assert wired.app.test_client().get("/api/settings", headers=headers).status_code == 200
|
|
|
|
def test_no_set_cookie_even_when_handler_dirties_session(self, wired, user_db, monkeypatch):
|
|
user_db.create_user(username="root", role="admin")
|
|
original = wired.app.view_functions["api_active_downloads"]
|
|
|
|
def dirty(*args, **kwargs):
|
|
from flask import session
|
|
|
|
session["dirty"] = True
|
|
return original(*args, **kwargs)
|
|
|
|
monkeypatch.setitem(wired.app.view_functions, "api_active_downloads", dirty)
|
|
response = wired.app.test_client().get("/api/downloads/active", headers=_bearer("s3cret"))
|
|
assert response.status_code == 200
|
|
assert "Set-Cookie" not in response.headers
|
|
|
|
def test_incoming_non_admin_cookie_is_ignored(self, wired, user_db):
|
|
user_db.create_user(username="root", role="admin")
|
|
alice = user_db.create_user(username="alice")
|
|
client = _cookie_client(wired.app, alice)
|
|
assert client.get("/api/settings", headers=_bearer("s3cret")).status_code == 200
|
|
|
|
def test_matched_key_leaves_browser_cookie_usable(self, wired, user_db):
|
|
user_db.create_user(username="root", role="admin")
|
|
alice = user_db.create_user(username="alice")
|
|
client = _cookie_client(wired.app, alice)
|
|
assert client.get("/api/settings", headers=_bearer("s3cret")).status_code == 200
|
|
|
|
assert client.get("/api/downloads/active").status_code == 200
|
|
assert client.get("/api/settings").status_code == 403
|
|
|
|
def test_security_headers_present(self, wired, user_db):
|
|
user_db.create_user(username="root", role="admin")
|
|
response = wired.app.test_client().get("/api/downloads/active", headers=_bearer("s3cret"))
|
|
assert response.headers.get("X-Content-Type-Options") == "nosniff"
|
|
|
|
def test_store_error_is_500_not_anonymous(self, wired, user_db, monkeypatch):
|
|
monkeypatch.setattr(
|
|
user_db,
|
|
"get_first_admin",
|
|
lambda: (_ for _ in ()).throw(sqlite3.OperationalError("boom")),
|
|
)
|
|
response = wired.app.test_client().get("/api/downloads/active", headers=_bearer("s3cret"))
|
|
assert response.status_code == 500
|
|
assert response.get_json() == {"error": "Authentication error"}
|
|
|
|
def test_store_error_does_not_refresh_browser_cookie(self, wired, user_db, monkeypatch):
|
|
alice = user_db.create_user(username="alice")
|
|
monkeypatch.setattr(
|
|
user_db,
|
|
"get_first_admin",
|
|
lambda: (_ for _ in ()).throw(sqlite3.OperationalError("boom")),
|
|
)
|
|
client = _cookie_client(wired.app, alice, permanent=True)
|
|
response = client.get("/api/downloads/active", headers=_bearer("s3cret"))
|
|
assert response.status_code == 500
|
|
assert "Set-Cookie" not in response.headers
|
|
|
|
# The browser's own session is untouched and still usable afterwards.
|
|
assert client.get("/api/downloads/active").status_code == 200
|
|
|
|
|
|
class TestMismatchFallsThrough:
|
|
def test_mismatch_no_cookie_is_plain_unauthorized(self, wired):
|
|
response = wired.app.test_client().get("/api/downloads/active", headers=_bearer("wrong"))
|
|
assert response.status_code == 401
|
|
assert response.get_json() == {"error": "Unauthorized"}
|
|
|
|
def test_mismatch_is_indistinguishable_from_no_credential(self, wired):
|
|
client = wired.app.test_client()
|
|
no_credential = client.get("/api/downloads/active")
|
|
with_wrong_bearer = client.get("/api/downloads/active", headers=_bearer("wrong"))
|
|
|
|
assert no_credential.status_code == with_wrong_bearer.status_code
|
|
assert no_credential.get_json() == with_wrong_bearer.get_json()
|
|
|
|
ignored_headers = {"date", "content-length", "server"}
|
|
|
|
def header_names(response):
|
|
return {name.lower() for name in response.headers.keys()} - ignored_headers
|
|
|
|
assert header_names(no_credential) == header_names(with_wrong_bearer)
|
|
assert "WWW-Authenticate" not in no_credential.headers
|
|
assert "WWW-Authenticate" not in with_wrong_bearer.headers
|
|
|
|
def test_mismatch_writes_no_log(self, wired, caplog):
|
|
with caplog.at_level(logging.INFO, logger="shelfmark"):
|
|
wired.app.test_client().get("/api/downloads/active", headers=_bearer("wrong"))
|
|
|
|
for record in caplog.records:
|
|
message = record.getMessage()
|
|
assert "API key" not in message
|
|
assert "wrong" not in message
|
|
|
|
def test_mismatch_with_cookie_uses_cookie(self, wired, user_db):
|
|
alice = user_db.create_user(username="alice")
|
|
client = _cookie_client(wired.app, alice)
|
|
assert client.get("/api/downloads/active", headers=_bearer("wrong")).status_code == 200
|
|
assert client.get("/api/settings", headers=_bearer("wrong")).status_code == 403
|
|
|
|
def test_mismatch_leaves_browser_session_untouched(self, wired, user_db):
|
|
alice = user_db.create_user(username="alice")
|
|
client = _cookie_client(wired.app, alice, permanent=True)
|
|
response = client.get("/api/downloads/active", headers=_bearer("wrong"))
|
|
assert response.status_code == 200
|
|
assert client.get("/api/downloads/active").status_code == 200
|
|
|
|
def test_unset_key_is_noop(self, main_module, user_db, monkeypatch):
|
|
monkeypatch.setattr(main_module, "user_db", user_db)
|
|
monkeypatch.setattr(api_key, "SHELFMARK_API_KEY", "")
|
|
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
|
response = main_module.app.test_client().get(
|
|
"/api/downloads/active", headers=_bearer("s3cret")
|
|
)
|
|
assert response.status_code == 401
|
|
assert response.get_json() == {"error": "Unauthorized"}
|
|
|
|
alice = user_db.create_user(username="alice")
|
|
client = _cookie_client(main_module.app, alice, permanent=True)
|
|
cookie_response = client.get("/api/downloads/active", headers=_bearer("s3cret"))
|
|
assert cookie_response.status_code == 200
|
|
assert "Set-Cookie" in cookie_response.headers
|
|
|
|
|
|
class TestScopeAndModes:
|
|
def test_health_and_auth_paths_and_root_ignore_key(self, wired):
|
|
client = wired.app.test_client()
|
|
assert client.get("/api/health", headers=_bearer("s3cret")).status_code == 200
|
|
assert (
|
|
client.get("/api/auth/check", headers=_bearer("s3cret")).get_json()["authenticated"]
|
|
is False
|
|
)
|
|
assert client.get("/", headers=_bearer("s3cret")).status_code != 401
|
|
|
|
def test_none_mode_noop(self, main_module, user_db, monkeypatch):
|
|
monkeypatch.setattr(main_module, "user_db", user_db)
|
|
monkeypatch.setattr(api_key, "SHELFMARK_API_KEY", "s3cret")
|
|
with patch.object(main_module, "get_auth_mode", return_value="none"):
|
|
assert (
|
|
main_module.app.test_client()
|
|
.get("/api/downloads/active", headers=_bearer("s3cret"))
|
|
.status_code
|
|
== 200
|
|
)
|
|
|
|
def test_proxy_mode_keyed_request_needs_no_proxy_header(
|
|
self, main_module, user_db, monkeypatch
|
|
):
|
|
monkeypatch.setattr(main_module, "user_db", user_db)
|
|
monkeypatch.setattr(api_key, "SHELFMARK_API_KEY", "s3cret")
|
|
user_db.create_user(username="root", role="admin", auth_source="proxy")
|
|
with patch.object(main_module, "get_auth_mode", return_value="proxy"):
|
|
assert (
|
|
main_module.app.test_client()
|
|
.get("/api/downloads/active", headers=_bearer("s3cret"))
|
|
.status_code
|
|
== 200
|
|
)
|
|
|
|
|
|
class TestIdentityAndRouting:
|
|
def test_deleted_first_admin_changes_identity(self, wired, user_db):
|
|
root = user_db.create_user(username="root", role="admin")
|
|
root2 = user_db.create_user(username="root2", role="admin")
|
|
|
|
identity = _identity_for_key(wired)
|
|
assert identity["user_id"] == "root"
|
|
assert identity["db_user_id"] == root["id"]
|
|
assert identity["is_admin"] is True
|
|
|
|
user_db.delete_user(root["id"])
|
|
|
|
identity = _identity_for_key(wired)
|
|
assert identity["user_id"] == "root2"
|
|
assert identity["db_user_id"] == root2["id"]
|
|
|
|
user_db.delete_user(root2["id"])
|
|
|
|
# No admin row left at all; the key still authenticates a bare
|
|
# admin identity for routes that don't need a local user row.
|
|
identity = _identity_for_key(wired)
|
|
assert identity["user_id"] == "api"
|
|
assert identity["is_admin"] is True
|
|
assert "db_user_id" not in identity
|
|
|
|
def test_demoted_first_admin_changes_identity(self, wired, user_db):
|
|
root = user_db.create_user(username="root", role="admin")
|
|
|
|
identity = _identity_for_key(wired)
|
|
assert identity["user_id"] == "root"
|
|
|
|
user_db.update_user(root["id"], role="user")
|
|
|
|
# No admin row left to match; the key falls back to a bare identity.
|
|
identity = _identity_for_key(wired)
|
|
assert identity["user_id"] == "api"
|
|
assert identity["is_admin"] is True
|
|
assert "db_user_id" not in identity
|
|
|
|
# Bare admin identity still reaches a route that doesn't need a
|
|
# local user row.
|
|
response = wired.app.test_client().get("/api/settings", headers=_bearer("s3cret"))
|
|
assert response.status_code == 200
|
|
|
|
def test_path_probes(self, wired, user_db):
|
|
user_db.create_user(username="root", role="admin")
|
|
client = wired.app.test_client()
|
|
|
|
# Flask routing is case-sensitive: /API/... doesn't match the /api/
|
|
# prefix the middleware checks, so it falls through to the SPA
|
|
# catch-all route instead of the JSON handler with elevated state.
|
|
response = client.get("/API/downloads/active", headers=_bearer("s3cret"))
|
|
assert response.status_code != 200 or response.content_type != "application/json"
|
|
|
|
# A trailing slash must not silently reach the handler either.
|
|
response = client.get("/api/downloads/active/", headers=_bearer("s3cret"))
|
|
assert response.status_code != 200 or not response.data
|
|
|
|
# A dot-segment path trick must not resolve to a live route.
|
|
response = client.get("/api/auth/../downloads/active", headers=_bearer("s3cret"))
|
|
assert response.status_code == 404
|
|
|
|
def test_keyed_post_reaches_guarded_route(self, wired, user_db):
|
|
"""A keyed POST passes the login_required guard and reaches the handler's own validation.
|
|
|
|
Uses /api/releases/inspect rather than a settings-save route: that
|
|
handler does no persistence at all, so this proves the request got
|
|
past the auth guard into real handler logic without touching the
|
|
process-wide Config singleton or the on-disk settings files other
|
|
tests (and other workers, under xdist) share.
|
|
"""
|
|
user_db.create_user(username="root", role="admin")
|
|
|
|
response = wired.app.test_client().post(
|
|
"/api/releases/inspect",
|
|
json={},
|
|
headers=_bearer("s3cret"),
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.get_json() == {"error": "source_id is required"}
|
|
assert "Set-Cookie" not in response.headers
|