378 lines
12 KiB
Python
378 lines
12 KiB
Python
"""Regression tests for request-level authentication and tenant activity guards."""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from app.api.helpers import ApiError, assert_tenant
|
|
from app.auth.users import AuthError, UserStore
|
|
from app.config import Config
|
|
|
|
|
|
def auth_headers(token: str) -> dict[str, str]:
|
|
return {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
def test_must_setup_user_can_only_use_setup_safe_endpoints(client, user_store, login):
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="pending-user",
|
|
password="pending-password",
|
|
name="Pending User",
|
|
role="user",
|
|
must_setup=True,
|
|
)
|
|
token = login(user["username"], "pending-password")["token"]
|
|
headers = auth_headers(token)
|
|
|
|
assert client.get("/api/auth/me", headers=headers).status_code == 200
|
|
assert client.post(
|
|
"/api/auth/setup",
|
|
headers=headers,
|
|
json={
|
|
"email": "pending@example.com",
|
|
"password": "pending-new-password",
|
|
"accepted_terms": True,
|
|
},
|
|
).status_code == 200
|
|
|
|
|
|
def test_must_setup_user_cannot_use_business_routes_before_setup(
|
|
client, user_store, login
|
|
):
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="pending-business",
|
|
password="pending-password",
|
|
name="Pending Business",
|
|
role="user",
|
|
must_setup=True,
|
|
)
|
|
token = login(user["username"], "pending-password")["token"]
|
|
headers = auth_headers(token)
|
|
|
|
response = client.get("/api/groups", headers=headers)
|
|
|
|
assert response.status_code == 403, response.get_json()
|
|
|
|
|
|
def test_must_setup_user_cannot_use_admin_chat_or_analytics(
|
|
client, user_store, login
|
|
):
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="pending-all-routes",
|
|
password="pending-password",
|
|
name="Pending User",
|
|
role="user",
|
|
must_setup=True,
|
|
)
|
|
token = login(user["username"], "pending-password")["token"]
|
|
headers = auth_headers(token)
|
|
|
|
responses = [
|
|
client.post(
|
|
"/api/admin/users",
|
|
json={"username": "should-not-create", "password": "password"},
|
|
headers=headers,
|
|
),
|
|
client.get("/api/analytics", headers=headers),
|
|
client.post(
|
|
"/api/chat/missing-group/personas/missing-persona/chat/start",
|
|
json={"scenario": "social"},
|
|
headers=headers,
|
|
),
|
|
]
|
|
|
|
assert [response.status_code for response in responses] == [403, 403, 403]
|
|
|
|
|
|
def test_inactive_user_token_is_rejected(client, user_store, login):
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="inactive-user",
|
|
password="inactive-password",
|
|
name="Inactive User",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
token = login(user["username"], "inactive-password")["token"]
|
|
user_store.set_active(user["username"], False)
|
|
|
|
response = client.get("/api/auth/me", headers=auth_headers(token))
|
|
|
|
assert response.status_code == 401, response.get_json()
|
|
|
|
|
|
def test_inactive_org_rejects_existing_token(client, user_store, login):
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="inactive-org-user",
|
|
password="inactive-org-password",
|
|
name="Inactive Org User",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
token = login(user["username"], "inactive-org-password")["token"]
|
|
user_store.orgs.update("org-default", active=False)
|
|
|
|
response = client.get("/api/auth/me", headers=auth_headers(token))
|
|
|
|
assert response.status_code == 401, response.get_json()
|
|
|
|
|
|
def test_missing_org_rejects_token(client, user_store, login):
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="missing-org-user",
|
|
password="missing-org-password",
|
|
name="Missing Org User",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
token = login(user["username"], "missing-org-password")["token"]
|
|
user_store.orgs.delete("org-default")
|
|
|
|
response = client.get("/api/auth/me", headers=auth_headers(token))
|
|
|
|
assert response.status_code == 401, response.get_json()
|
|
|
|
|
|
def test_token_org_claim_mismatch_is_rejected(client, user_store, login):
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="mismatched-org-user",
|
|
password="mismatched-org-password",
|
|
name="Mismatched Org User",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
token = login(user["username"], "mismatched-org-password")["token"]
|
|
payload = user_store.decode_token(token)
|
|
payload["org_id"] = "org-other"
|
|
forged_token = __import__("jwt").encode(
|
|
payload, Config.SECRET_KEY, algorithm=Config.JWT_ALGO
|
|
)
|
|
|
|
response = client.get("/api/auth/me", headers=auth_headers(forged_token))
|
|
|
|
assert response.status_code == 401, response.get_json()
|
|
|
|
|
|
def test_malformed_jwt_claim_types_are_rejected(client, user_store, login):
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="malformed-claim-user",
|
|
password="malformed-claim-password",
|
|
name="Malformed Claim User",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
token = login(user["username"], "malformed-claim-password")["token"]
|
|
payload = user_store.decode_token(token)
|
|
jwt = __import__("jwt")
|
|
|
|
for claim, value in (("sub", 123), ("org_id", 123)):
|
|
malformed = dict(payload)
|
|
malformed[claim] = value
|
|
forged_token = jwt.encode(
|
|
malformed, Config.SECRET_KEY, algorithm=Config.JWT_ALGO
|
|
)
|
|
response = client.get(
|
|
"/api/auth/me", headers=auth_headers(forged_token)
|
|
)
|
|
assert response.status_code == 401, response.get_json()
|
|
|
|
|
|
def test_malformed_jwt_subject_and_org_claims_do_not_500(client, user_store, login):
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="malformed-claim-input-user",
|
|
password="malformed-claim-input-password",
|
|
name="Malformed Claim Input User",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
token = login(user["username"], "malformed-claim-input-password")["token"]
|
|
payload = user_store.decode_token(token)
|
|
jwt = __import__("jwt")
|
|
|
|
for claim, value in (("sub", {"nested": True}), ("org_id", ["org-default"])):
|
|
malformed = dict(payload)
|
|
malformed[claim] = value
|
|
forged_token = jwt.encode(
|
|
malformed, Config.SECRET_KEY, algorithm=Config.JWT_ALGO
|
|
)
|
|
response = client.get(
|
|
"/api/auth/me", headers=auth_headers(forged_token)
|
|
)
|
|
assert response.status_code == 401, response.get_json()
|
|
|
|
|
|
def test_malformed_stored_org_id_is_rejected_without_500(client, user_store, login):
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="malformed-stored-org-user",
|
|
password="malformed-stored-org-password",
|
|
name="Malformed Stored Org User",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
token = login(user["username"], "malformed-stored-org-password")["token"]
|
|
user_store.users.update(user["username"], org_id=123)
|
|
|
|
response = client.get(
|
|
"/api/auth/me", headers=auth_headers(token)
|
|
)
|
|
|
|
assert response.status_code == 401, response.get_json()
|
|
|
|
|
|
def test_login_rejects_user_when_organization_becomes_inactive(
|
|
client, user_store
|
|
):
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="inactive-org-login",
|
|
password="inactive-org-login-password",
|
|
name="Inactive Org Login",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
user_store.orgs.update("org-default", active=False)
|
|
|
|
response = client.post(
|
|
"/api/auth/login",
|
|
json={
|
|
"username": user["username"],
|
|
"password": "inactive-org-login-password",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 401, response.get_json()
|
|
|
|
|
|
def test_non_boolean_active_state_is_fail_closed_for_user_and_org(
|
|
client, user_store, login
|
|
):
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="legacy-active-shape",
|
|
password="legacy-active-password",
|
|
name="Legacy Active Shape",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
token = login(user["username"], "legacy-active-password")["token"]
|
|
|
|
user_store.users.update(user["username"], active="false")
|
|
user_response = client.get("/api/auth/me", headers=auth_headers(token))
|
|
assert user_response.status_code == 401, user_response.get_json()
|
|
|
|
user_store.users.update(user["username"], active=True)
|
|
user_store.orgs.update("org-default", active="false")
|
|
org_response = client.get("/api/auth/me", headers=auth_headers(token))
|
|
assert org_response.status_code == 401, org_response.get_json()
|
|
|
|
|
|
def test_malformed_must_setup_state_cannot_enter_setup_flow(
|
|
client, user_store, login
|
|
):
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="malformed-must-setup",
|
|
password="malformed-must-setup-password",
|
|
name="Malformed Must Setup",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
token = login(user["username"], "malformed-must-setup-password")["token"]
|
|
user_store.users.update(user["username"], must_setup="false")
|
|
|
|
response = client.get(
|
|
"/api/auth/me",
|
|
headers=auth_headers(token),
|
|
)
|
|
|
|
assert response.status_code == 401, response.get_json()
|
|
assert user_store.get_user(user["username"])["must_setup"] == "false"
|
|
|
|
|
|
def test_malformed_must_setup_state_cannot_login(client, user_store):
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="malformed-must-setup-login",
|
|
password="malformed-must-setup-login-password",
|
|
name="Malformed Must Setup Login",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
user_store.users.update(user["username"], must_setup="true")
|
|
|
|
response = client.post(
|
|
"/api/auth/login",
|
|
json={
|
|
"username": user["username"],
|
|
"password": "malformed-must-setup-login-password",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 401, response.get_json()
|
|
|
|
|
|
@pytest.mark.parametrize("bad_version", ["corrupt", True, -1, 1.5])
|
|
def test_malformed_auth_version_cannot_be_repaired_into_token_reuse(
|
|
client, user_store, login, bad_version
|
|
):
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username=f"bad-auth-version-{str(bad_version).replace('.', '-')}",
|
|
password="bad-auth-version-password",
|
|
name="Bad Auth Version",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
token = login(user["username"], "bad-auth-version-password")["token"]
|
|
user_store.users.update(user["username"], auth_version=bad_version)
|
|
|
|
response = client.get("/api/auth/me", headers=auth_headers(token))
|
|
assert response.status_code == 401, response.get_json()
|
|
with pytest.raises(AuthError):
|
|
user_store.set_password(user["username"], "replacement-password")
|
|
assert user_store.users.get(user["username"])["auth_version"] == bad_version
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("field", "value"),
|
|
[("password_hash", "not-a-password-hash"), ("role", "unknown-role"), ("org_id", 123)],
|
|
)
|
|
def test_malformed_login_record_fails_as_invalid_credentials(
|
|
client, user_store, field, value
|
|
):
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username=f"malformed-login-{field}",
|
|
password="malformed-login-password",
|
|
name="Malformed Login",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
user_store.users.update(user["username"], **{field: value})
|
|
|
|
response = client.post(
|
|
"/api/auth/login",
|
|
json={"username": user["username"], "password": "malformed-login-password"},
|
|
)
|
|
|
|
assert response.status_code == 401, response.get_json()
|
|
|
|
|
|
def test_tenant_guard_rejects_missing_object_tenant(client):
|
|
from flask import g
|
|
|
|
with client.application.test_request_context():
|
|
g.user = {"role": "user", "org_id": "org-default"}
|
|
g.org_id = "org-default"
|
|
|
|
with pytest.raises(ApiError, match="permission denied"):
|
|
assert_tenant(None)
|