FluxIP/tests/test_auth_integrations.py

379 lines
13 KiB
Python

from __future__ import annotations
import json
import logging
import pytest
from fastapi.testclient import TestClient
from pydantic import SecretStr
from app.auth.service import LoginRateLimiter
from app.core.errors import AppError
from app.core.logging import JsonFormatter, configure_logging
from app.main import create_app
from tests.conftest import csrf_headers
def test_first_setup_login_and_csrf_flow(client: TestClient) -> None:
bootstrap = client.get("/api/v1/auth/bootstrap")
assert bootstrap.status_code == 200
assert bootstrap.json()["data"] == {
"requires_setup": True,
"bootstrap_token_required": False,
"authenticated": False,
"username": None,
}
assert client.get("/api/v1/auth/me").status_code == 401
setup = client.post(
"/api/v1/auth/setup",
json={"username": " admin ", "password": "correct-horse-battery-staple"},
)
assert setup.status_code == 201
assert setup.json()["data"] == {"username": "admin"}
set_cookie = setup.headers.get_list("set-cookie")
assert any("fluxip_session=" in value and "HttpOnly" in value for value in set_cookie)
assert any("fluxip_csrf=" in value and "HttpOnly" not in value for value in set_cookie)
assert all("SameSite=strict" in value for value in set_cookie)
authenticated = client.get("/api/v1/auth/bootstrap")
assert authenticated.json()["data"] == {
"requires_setup": False,
"bootstrap_token_required": False,
"authenticated": True,
"username": "admin",
}
assert client.get("/api/v1/auth/me").json()["data"] == {"username": "admin"}
duplicate = client.post(
"/api/v1/auth/setup",
json={"username": "other", "password": "another-secure-password"},
)
assert duplicate.status_code == 409
assert duplicate.json()["type"] == "about:blank#setup_already_completed"
missing_csrf = client.post("/api/v1/auth/logout")
assert missing_csrf.status_code == 403
assert missing_csrf.json()["type"] == "about:blank#csrf_validation_failed"
assert (
client.post("/api/v1/auth/logout", headers={"X-CSRF-Token": "incorrect-token"}).status_code
== 403
)
logout = client.post("/api/v1/auth/logout", headers=csrf_headers(client))
assert logout.status_code == 204
assert client.get("/api/v1/auth/me").status_code == 401
failed_login = client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "wrong-password"},
)
assert failed_login.status_code == 401
assert failed_login.json()["type"] == "about:blank#authentication_required"
login = client.post(
"/api/v1/auth/login",
json={"username": " admin ", "password": "correct-horse-battery-staple"},
)
assert login.status_code == 200
assert client.get("/api/v1/auth/me").json()["data"]["username"] == "admin"
def test_bootstrap_token_is_required_for_remote_setup(app_settings) -> None:
app_settings.bootstrap_token = SecretStr("one-time-bootstrap-token")
application = create_app(app_settings)
with TestClient(
application,
base_url="http://testserver",
client=("203.0.113.10", 50000),
) as client:
state = client.get("/api/v1/auth/bootstrap").json()["data"]
assert state["requires_setup"] is True
assert state["bootstrap_token_required"] is True
missing = client.post(
"/api/v1/auth/setup",
json={"username": "admin", "password": "correct-horse-battery-staple"},
)
assert missing.status_code == 403
assert missing.json()["type"] == "about:blank#bootstrap_token_invalid"
wrong = client.post(
"/api/v1/auth/setup",
json={
"username": "admin",
"password": "correct-horse-battery-staple",
"bootstrap_token": "错误令牌",
},
)
assert wrong.status_code == 403
setup = client.post(
"/api/v1/auth/setup",
json={
"username": "admin",
"password": "correct-horse-battery-staple",
"bootstrap_token": "one-time-bootstrap-token",
},
)
assert setup.status_code == 201
def test_setup_without_token_is_loopback_only_and_ignores_untrusted_forwarding(
app_settings,
) -> None:
application = create_app(app_settings)
with TestClient(
application,
base_url="http://testserver",
client=("203.0.113.10", 50000),
) as client:
response = client.post(
"/api/v1/auth/setup",
headers={"X-Forwarded-For": "127.0.0.1"},
json={"username": "admin", "password": "correct-horse-battery-staple"},
)
assert response.status_code == 403
assert response.json()["type"] == "about:blank#bootstrap_local_only"
def test_setup_uses_forwarded_client_only_from_trusted_proxy(app_settings) -> None:
application = create_app(app_settings)
with TestClient(
application,
base_url="http://testserver",
client=("127.0.0.1", 50000),
) as client:
response = client.post(
"/api/v1/auth/setup",
headers={"X-Forwarded-For": "203.0.113.25"},
json={"username": "admin", "password": "correct-horse-battery-staple"},
)
assert response.status_code == 403
assert response.json()["type"] == "about:blank#bootstrap_local_only"
def test_secure_cookie_honors_https_and_trusted_proxy_boundary(app_settings) -> None:
app_settings.bootstrap_token = SecretStr("one-time-bootstrap-token")
application = create_app(app_settings)
with TestClient(
application,
base_url="http://testserver",
client=("203.0.113.10", 50000),
) as client:
response = client.post(
"/api/v1/auth/setup",
headers={"X-Forwarded-Proto": "https"},
json={
"username": "admin",
"password": "correct-horse-battery-staple",
"bootstrap_token": "one-time-bootstrap-token",
},
)
assert response.status_code == 201
assert all("Secure" not in value for value in response.headers.get_list("set-cookie"))
proxy_settings = app_settings.model_copy(
update={
"database_path": app_settings.database_path.with_name("proxy.db"),
"master_key_file": app_settings.master_key_file.with_name("proxy.key"),
}
)
application = create_app(proxy_settings)
with TestClient(
application,
base_url="http://testserver",
client=("127.0.0.1", 50000),
) as client:
response = client.post(
"/api/v1/auth/setup",
headers={"X-Forwarded-Proto": "https", "X-Forwarded-For": "203.0.113.10"},
json={
"username": "admin",
"password": "correct-horse-battery-staple",
"bootstrap_token": "one-time-bootstrap-token",
},
)
assert response.status_code == 201
assert all("Secure" in value for value in response.headers.get_list("set-cookie"))
assert response.headers["strict-transport-security"].startswith("max-age=")
def test_https_origin_forces_secure_cookie(app_settings) -> None:
app_settings.bootstrap_token = SecretStr("one-time-bootstrap-token")
app_settings.allowed_origins = "http://testserver,https://testserver"
application = create_app(app_settings)
with TestClient(
application,
base_url="http://testserver",
client=("203.0.113.10", 50000),
) as client:
response = client.post(
"/api/v1/auth/setup",
headers={"Origin": "https://testserver"},
json={
"username": "admin",
"password": "correct-horse-battery-staple",
"bootstrap_token": "one-time-bootstrap-token",
},
)
assert response.status_code == 201
assert all("Secure" in value for value in response.headers.get_list("set-cookie"))
def test_login_rate_limiter_is_bounded_and_cleans_expired_keys() -> None:
now = [0.0]
limiter = LoginRateLimiter(
attempts=1,
window_seconds=10,
max_keys=2,
cleanup_interval_seconds=1,
clock=lambda: now[0],
)
limiter.check("read-only-check-does-not-allocate")
assert not limiter._events
limiter.record_failure("first")
limiter.record_failure("second")
limiter.record_failure("third")
assert list(limiter._events) == ["second", "third"]
limiter.check("first")
with pytest.raises(AppError) as raised:
limiter.check("second")
assert raised.value.code == "LOGIN_RATE_LIMITED"
now[0] = 10.0
limiter.check("second")
assert not limiter._events
def test_json_logging_redacts_nested_credentials_and_dependency_logs() -> None:
class OpaqueExtra:
def __str__(self) -> str:
return "OpaqueExtra(token=object-secret)"
formatter = JsonFormatter()
record = logging.LogRecord(
name="test",
level=logging.INFO,
pathname=__file__,
lineno=1,
msg=(
"Authorization: Bearer bearer-value token=query-value "
"url=https://example.test/?api_key=url-value AKIAABCDEFGHIJKLMNOP"
),
args=(),
exc_info=None,
)
record.api_token = "structured-token"
record.context = {
"password": "nested-password",
"master-key": "master-key-value",
"safe": "visible-value",
"items": [{"aws_access_key_id": "AKIAABCDEFGHIJKLMNOP"}],
}
record.opaque = OpaqueExtra()
serialized = formatter.format(record)
payload = json.loads(serialized)
for secret in (
"bearer-value",
"query-value",
"url-value",
"AKIAABCDEFGHIJKLMNOP",
"structured-token",
"nested-password",
"master-key-value",
"object-secret",
):
assert secret not in serialized
assert payload["api_token"] == "[REDACTED]"
assert payload["context"]["safe"] == "visible-value"
try:
configure_logging("DEBUG")
for logger_name in ("botocore", "boto3", "httpx", "httpcore"):
assert logging.getLogger(logger_name).level == logging.WARNING
finally:
configure_logging("WARNING")
def test_integration_secrets_are_encrypted_and_masked(
authenticated_client: TestClient,
integration_payload: dict[str, object],
) -> None:
client = authenticated_client
response = client.put(
"/api/v1/integrations",
json=integration_payload,
headers=csrf_headers(client),
)
assert response.status_code == 200
view = response.json()["data"]
assert view["config_version"] == 2
assert view["secrets_configured"] == {
"aws_access_key_id": True,
"aws_secret_access_key": True,
"aws_session_token": True,
"cloudflare_api_token": True,
}
serialized_response = response.text
for secret in (
"AKIA_TEST_ACCESS_KEY",
"aws-secret-value",
"aws-session-value",
"cloudflare-secret-value",
):
assert secret not in serialized_response
stored_view = client.get("/api/v1/integrations")
assert stored_view.status_code == 200
assert stored_view.json()["data"] == view
container = client.app.state.container
with container.database.connect() as connection:
rows = connection.execute(
"SELECT name, nonce, ciphertext FROM secrets ORDER BY name"
).fetchall()
assert {row["name"] for row in rows} == {
"aws_access_key_id",
"aws_secret_access_key",
"aws_session_token",
"cloudflare_api_token",
}
stored_blob = " ".join(f"{row['nonce']} {row['ciphertext']}" for row in rows)
assert all(
secret not in stored_blob
for secret in (
"AKIA_TEST_ACCESS_KEY",
"aws-secret-value",
"aws-session-value",
"cloudflare-secret-value",
)
)
assert container.integration_repository.get_secrets() == {
"aws_access_key_id": "AKIA_TEST_ACCESS_KEY",
"aws_secret_access_key": "aws-secret-value",
"aws_session_token": "aws-session-value",
"cloudflare_api_token": "cloudflare-secret-value",
}
preserve_payload = {
**integration_payload,
"aws_access_key_id": None,
"aws_secret_access_key": None,
"aws_session_token": None,
"cloudflare_api_token": None,
}
preserved = client.put(
"/api/v1/integrations",
json=preserve_payload,
headers=csrf_headers(client),
)
assert preserved.status_code == 200
assert preserved.json()["data"]["config_version"] == 3
assert all(preserved.json()["data"]["secrets_configured"].values())
assert container.integration_repository.get_secret("cloudflare_api_token") == (
"cloudflare-secret-value"
)