Publish curated ITWorx-ModelForge source from 990401737f96c8d46a9f7b0d84994516cadcb6a0
This commit is contained in:
1 parent
7082ab955a
commit
848f0d8f8a
434 files changed
+67298
-4315
No files matched your search
@@ -0,0 +1,311 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import redis
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from modelforge_api.api.authorization import authenticate_operator_token
|
||||
from modelforge_api.api.routes import operator_login
|
||||
from modelforge_api.main import app
|
||||
from modelforge_api.services.operator_password import (
|
||||
hash_password,
|
||||
issue_session,
|
||||
parse_password_hash,
|
||||
verify_password,
|
||||
verify_session,
|
||||
)
|
||||
from modelforge_api.services.startup_validation import validate_settings
|
||||
from modelforge_api.settings import Settings, get_settings
|
||||
|
||||
PASSWORD = "synthetic password fixture" # noqa: S105 - synthetic test input
|
||||
BOOTSTRAP = "synthetic-bootstrap-fixture-" + "x" * 32
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def verifier() -> str:
|
||||
return hash_password(PASSWORD)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings(verifier: str) -> Settings:
|
||||
return Settings(
|
||||
_env_file=None,
|
||||
env="test",
|
||||
operator_api_key=SecretStr(BOOTSTRAP),
|
||||
operator_password_hash=SecretStr(verifier),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(settings: Settings, monkeypatch: pytest.MonkeyPatch):
|
||||
connection = Mock()
|
||||
connection.__enter__ = Mock(return_value=connection)
|
||||
connection.__exit__ = Mock(return_value=False)
|
||||
connection.eval.return_value = 1
|
||||
monkeypatch.setattr(operator_login.redis.Redis, "from_url", Mock(return_value=connection))
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
yield TestClient(app), connection
|
||||
app.dependency_overrides.pop(get_settings, None)
|
||||
|
||||
|
||||
def test_salted_password_and_wrong_value(verifier: str):
|
||||
assert verify_password(PASSWORD, verifier)
|
||||
assert not verify_password("wrong", verifier)
|
||||
assert hash_password(PASSWORD) != verifier
|
||||
assert len(parse_password_hash(verifier)[0]) == 16
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"encoded",
|
||||
[
|
||||
"",
|
||||
"scrypt$2$8$1$a$b",
|
||||
"x" * 10000,
|
||||
"scrypt$999999999$8$1$a$b",
|
||||
"scrypt$32768$8$1$" + "a" * 66,
|
||||
],
|
||||
)
|
||||
def test_malformed_or_weak_hash_rejected(encoded: str):
|
||||
with pytest.raises(ValueError):
|
||||
parse_password_hash(encoded)
|
||||
configured = Settings(_env_file=None, operator_password_hash=SecretStr(encoded))
|
||||
problems = validate_settings(configured).problems
|
||||
assert (
|
||||
any(p.setting == "MODELFORGE_OPERATOR_PASSWORD_HASH" for p in problems)
|
||||
if encoded
|
||||
else not any(p.setting == "MODELFORGE_OPERATOR_PASSWORD_HASH" for p in problems)
|
||||
)
|
||||
assert all(encoded not in p.message for p in problems) if encoded else True
|
||||
|
||||
|
||||
def test_sessions_signed_expiring_rotating(verifier: str):
|
||||
token, expiry = issue_session(BOOTSTRAP, verifier, now=1000)
|
||||
assert expiry == 1900
|
||||
assert verify_session(token, BOOTSTRAP, verifier, now=1000)
|
||||
assert verify_session(token, BOOTSTRAP, verifier, now=1899)
|
||||
assert not verify_session(token, BOOTSTRAP, verifier, now=1900)
|
||||
assert not verify_session(token, BOOTSTRAP, verifier, now=999)
|
||||
assert not verify_session(token, BOOTSTRAP + "rotated", verifier, now=1100)
|
||||
assert not verify_session(token, BOOTSTRAP, hash_password(PASSWORD), now=1100)
|
||||
assert not verify_session(token.replace("1900", "9900"), BOOTSTRAP, verifier, now=1100)
|
||||
for malformed in [
|
||||
"mfpw_",
|
||||
"mfpw_" + "x" * 2000,
|
||||
token + ".",
|
||||
"mfsvc_" + token,
|
||||
"mfpw_1.901.😃.😃",
|
||||
]:
|
||||
assert not verify_session(malformed, BOOTSTRAP, verifier, now=1100)
|
||||
|
||||
|
||||
def test_login_session_and_legacy_key(client, settings: Settings):
|
||||
http, connection = client
|
||||
response = http.post("/api/v1/operator/login", json={"password": PASSWORD})
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
result = response.json()
|
||||
assert result["token_type"] == "bearer" # noqa: S105 - protocol type
|
||||
assert PASSWORD not in response.text and BOOTSTRAP not in response.text
|
||||
assert result["expires_in"] == 900
|
||||
token = result["access_token"]
|
||||
principal = authenticate_operator_token(settings, token)
|
||||
assert principal.authentication_method == "password_session"
|
||||
assert principal.role == "admin"
|
||||
assert principal.expires_at is not None
|
||||
identity = http.get("/api/v1/operator/identity", headers={"Authorization": f"Bearer {token}"})
|
||||
assert identity.status_code == 200
|
||||
assert identity.json()["authentication_method"] == "password_session"
|
||||
assert identity.json()["credential_expires_at"] == result["expires_at"]
|
||||
assert (
|
||||
authenticate_operator_token(settings, BOOTSTRAP).authentication_method
|
||||
== "legacy_admin_token"
|
||||
)
|
||||
for headers in [{"Authorization": f"Bearer {token}"}, {"X-ModelForge-Admin-Token": token}]:
|
||||
# Routing 404 proves the protected request passed the authentication boundary.
|
||||
assert http.get("/api/v1/not-a-route", headers=headers).status_code == 404
|
||||
assert (
|
||||
http.get("/api/v1/not-a-route", headers={"X-ModelForge-Admin-Token": PASSWORD}).status_code
|
||||
== 401
|
||||
)
|
||||
connection.eval.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{},
|
||||
{"password": ""},
|
||||
{"password": None},
|
||||
{"password": 123},
|
||||
{"password": ["do-not-echo"]},
|
||||
{"password": "z" * 1025},
|
||||
{"password": PASSWORD, "do-not-echo": PASSWORD},
|
||||
],
|
||||
)
|
||||
def test_invalid_login_redacted(client, payload):
|
||||
http, connection = client
|
||||
response = http.post("/api/v1/operator/login", json=payload)
|
||||
assert response.status_code == 422
|
||||
assert PASSWORD not in response.text and "do-not-echo" not in response.text
|
||||
assert response.json()["error"]["details"] == {}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
connection.eval.assert_called_once()
|
||||
|
||||
|
||||
def test_wrong_password_and_no_config(client, settings: Settings):
|
||||
http, _ = client
|
||||
response = http.post("/api/v1/operator/login", json={"password": "wrong"})
|
||||
assert response.status_code == 401 and "wrong" not in response.text
|
||||
settings.operator_password_hash = None
|
||||
assert http.post("/api/v1/operator/login", json={"password": PASSWORD}).status_code == 503
|
||||
|
||||
|
||||
def test_limiter_runs_before_parser_and_ignores_forwarded_ip(client):
|
||||
http, connection = client
|
||||
connection.eval.side_effect = [1, 0, 0]
|
||||
assert http.post("/api/v1/operator/login", content="not json").status_code == 422
|
||||
assert (
|
||||
http.post(
|
||||
"/api/v1/operator/login",
|
||||
json={"password": PASSWORD},
|
||||
headers={"X-Forwarded-For": "203.0.113.1"},
|
||||
).status_code
|
||||
== 429
|
||||
)
|
||||
assert (
|
||||
http.post(
|
||||
"/api/v1/operator/login",
|
||||
json={"password": PASSWORD},
|
||||
headers={"X-Forwarded-For": "203.0.113.2"},
|
||||
).status_code
|
||||
== 429
|
||||
)
|
||||
assert len({call.args[-1] for call in connection.eval.call_args_list}) == 1
|
||||
script = connection.eval.call_args.args[0]
|
||||
assert "redis.call('INCR', KEYS[1])" in script
|
||||
assert "redis.call('EXPIRE', KEYS[1], 60)" in script
|
||||
assert "global > 30" in script and "peer > 5" in script
|
||||
|
||||
|
||||
def test_redis_failure_fails_closed(client):
|
||||
http, connection = client
|
||||
connection.eval.side_effect = redis.ConnectionError("secret topology must not leak")
|
||||
response = http.post("/api/v1/operator/login", json={"password": PASSWORD})
|
||||
assert response.status_code == 503
|
||||
assert "topology" not in response.text and PASSWORD not in response.text
|
||||
|
||||
|
||||
def test_invalid_session_never_falls_back(settings: Settings):
|
||||
for token in ["mfpw_fake", PASSWORD, None]:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
authenticate_operator_token(settings, token)
|
||||
assert exc.value.status_code == 401
|
||||
token, _ = issue_session(
|
||||
BOOTSTRAP, settings.operator_password_hash.get_secret_value(), now=int(time.time()) - 1000
|
||||
)
|
||||
with pytest.raises(HTTPException):
|
||||
authenticate_operator_token(settings, token)
|
||||
|
||||
|
||||
def test_rotations_reject_existing_session(client, settings: Settings):
|
||||
http, _ = client
|
||||
token = http.post("/api/v1/operator/login", json={"password": PASSWORD}).json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
settings.operator_password_hash = SecretStr(hash_password(PASSWORD))
|
||||
assert http.get("/api/v1/not-a-route", headers=headers).status_code == 401
|
||||
token = http.post("/api/v1/operator/login", json={"password": PASSWORD}).json()["access_token"]
|
||||
settings.operator_api_key = SecretStr(BOOTSTRAP + "rotated")
|
||||
assert (
|
||||
http.get("/api/v1/not-a-route", headers={"Authorization": f"Bearer {token}"}).status_code
|
||||
== 401
|
||||
)
|
||||
|
||||
|
||||
def test_login_body_bounded_before_parsing(client):
|
||||
http, connection = client
|
||||
response = http.post("/api/v1/operator/login", content="x" * 9000)
|
||||
assert response.status_code == 413
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert "x" * 100 not in response.text
|
||||
connection.eval.assert_called_once()
|
||||
|
||||
|
||||
def test_login_requires_strong_bootstrap(client, settings: Settings):
|
||||
http, _ = client
|
||||
settings.operator_api_key = SecretStr("short fixture")
|
||||
assert http.post("/api/v1/operator/login", json={"password": PASSWORD}).status_code == 503
|
||||
|
||||
|
||||
def test_cli_stdin_outputs_hash_only():
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
result = subprocess.run( # noqa: S603 - fixed local CLI and synthetic stdin
|
||||
[sys.executable, str(root / "scripts/operator_password.py")],
|
||||
input=PASSWORD + "\n",
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0 and result.stderr == ""
|
||||
assert PASSWORD not in result.stdout
|
||||
assert verify_password(PASSWORD, result.stdout.strip())
|
||||
bad = subprocess.run( # noqa: S603 - fixed local CLI and synthetic stdin
|
||||
[sys.executable, str(root / "scripts/operator_password.py")],
|
||||
input="z" * 10000,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert bad.returncode == 2 and bad.stdout == "" and "z" * 10 not in bad.stderr
|
||||
|
||||
|
||||
def test_empty_optional_password_hash_is_unset():
|
||||
for value in ["", SecretStr("")]:
|
||||
configured = Settings(_env_file=None, operator_password_hash=value)
|
||||
assert configured.operator_password_hash is None
|
||||
assert not any(
|
||||
p.setting == "MODELFORGE_OPERATOR_PASSWORD_HASH"
|
||||
for p in validate_settings(configured).problems
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_key_with_session_prefix_remains_accepted(settings: Settings):
|
||||
legacy = "mfpw_" + "synthetic-key-fixture" * 3
|
||||
settings.operator_api_key = SecretStr(legacy)
|
||||
assert (
|
||||
authenticate_operator_token(settings, legacy).authentication_method == "legacy_admin_token"
|
||||
)
|
||||
|
||||
|
||||
def test_password_configuration_requires_signing_key_in_production(verifier: str):
|
||||
configured = Settings(_env_file=None, env="production", operator_api_key=None,
|
||||
operator_password_hash=SecretStr(verifier))
|
||||
assert any(p.setting == "MODELFORGE_OPERATOR_API_KEY" and "session signing" in p.message
|
||||
for p in validate_settings(configured).problems)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bootstrap", [None, "", "short", "x" * 31, "x" * 32])
|
||||
def test_named_admin_cannot_replace_password_signing_key(verifier: str, bootstrap: str | None):
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
identities = SecretStr(json.dumps([{
|
||||
"credential_id": "fixture-admin", "subject": "fixture-person", "role": "admin",
|
||||
"token_sha256": hashlib.sha256(("mfop_" + "a" * 43).encode()).hexdigest(),
|
||||
"expires_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(),
|
||||
}]))
|
||||
configured = Settings(_env_file=None, env="production", operator_identities=identities,
|
||||
operator_api_key=None if bootstrap is None else SecretStr(bootstrap),
|
||||
operator_password_hash=SecretStr(verifier))
|
||||
signing_problems = [p for p in validate_settings(configured).problems
|
||||
if p.setting == "MODELFORGE_OPERATOR_API_KEY" and "session signing" in p.message]
|
||||
assert bool(signing_problems) == (bootstrap is None or len(bootstrap) < 32)
|
||||
Reference in new issue
Block a user