Files
Lamont/tests/unit/test_security_core.py
Chelsea Lee fbdf33e894
Some checks failed
CI / test (push) Has been cancelled
CI / compose-smoke (push) Has been cancelled
Build reusable bot framework
2026-07-19 21:53:24 -05:00

383 lines
16 KiB
Python

"""Unit tests for user, token, API-key, and provider identity boundaries."""
from contextlib import contextmanager
import datetime
import json
from unittest.mock import MagicMock
import uuid
import pytest
from psycopg2.errors import UniqueViolation
from core import api_keys as apiKeys
from core import auth, identity, users
USER_ID = "00000000-0000-0000-0000-000000000101"
KEY_ID = "00000000-0000-0000-0000-000000000201"
class FakeCursor:
def __init__(self, rows=None):
self.rows = list(rows or [])
self.executed = []
def execute(self, query, params=None):
self.executed.append((" ".join(query.split()), params))
def fetchone(self):
return self.rows.pop(0) if self.rows else None
def _cursorContext(cursor):
@contextmanager
def context():
yield cursor
return context
def test_user_timezone_and_password_validation(monkeypatch):
assert users.isValidTimezone("America/Chicago")
assert not users.isValidTimezone("not/a-zone")
assert users.normalizeTimezone(" UTC ") == "UTC"
with pytest.raises(ValueError, match="IANA"):
users.normalizeTimezone(None)
monkeypatch.setenv("DEFAULT_TIMEZONE", "America/Chicago")
assert users.getDefaultTimezone() == "America/Chicago"
assert users.validatePassword("long-enough")[0]
assert not users.validatePassword("short")[0]
assert not users.validatePassword("é" * 40)[0]
assert users.validatePassword(None) == (False, ["password"])
def test_user_lookup_helpers_and_registration_boundaries(monkeypatch):
select = MagicMock(
side_effect=[
{"id": USER_ID, "username": "alice", "timezone": "UTC"},
{"id": USER_ID},
None,
]
)
monkeypatch.setattr(users.postgres, "select_one", select)
monkeypatch.setattr(users.postgres, "exists", MagicMock(side_effect=[False, True]))
assert users.getUser(USER_ID)["username"] == "alice"
assert users.getUserUUID("alice") == USER_ID
assert users.getUserUUID("missing") is False
assert users.isUsernameAvailable("alice")
assert users.doesUserUUIDExist(USER_ID)
monkeypatch.setattr(users, "isUsernameAvailable", lambda _name: True)
monkeypatch.setattr(users.bcrypt, "gensalt", lambda: b"salt")
monkeypatch.setattr(users.bcrypt, "hashpw", lambda value, _salt: b"hash:" + value)
create = MagicMock()
monkeypatch.setattr(users, "createUser", create)
assert users.registerUser(
" alice ",
"password123",
{"timezone": "UTC", "id": "attacker", "admin": True},
)
created = create.call_args.args[0]
assert created["username"] == "alice"
assert created["password_hashed"] == b"hash:password123"
assert created["timezone"] == "UTC"
assert created["id"] != "attacker" and "admin" not in created
monkeypatch.setattr(users, "isUsernameAvailable", lambda _name: False)
assert users.registerUser("alice", "password123") is False
monkeypatch.setattr(users, "isUsernameAvailable", lambda _name: True)
create.side_effect = UniqueViolation()
assert users.registerUser("alice", "password123") is False
@pytest.mark.parametrize("username", [None, " ", "x" * 256])
def test_registration_rejects_invalid_usernames(username):
with pytest.raises(ValueError, match="username"):
users.registerUser(username, "password123")
def test_user_creation_updates_password_and_delete(monkeypatch):
monkeypatch.setattr(users, "getUser", lambda _user: {"id": USER_ID})
update = MagicMock(return_value=[{"id": USER_ID}])
delete = MagicMock(return_value=[{"id": USER_ID}])
insert = MagicMock(return_value={"id": USER_ID})
monkeypatch.setattr(users.postgres, "update", update)
monkeypatch.setattr(users.postgres, "delete", delete)
monkeypatch.setattr(users.postgres, "insert", insert)
assert users.updateUser(USER_ID, {"timezone": "UTC", "username": "ignored"})
update.assert_called_with("users", {"timezone": "UTC"}, {"id": USER_ID})
assert not users.updateUser(USER_ID, {"username": "ignored"})
monkeypatch.setattr(users.bcrypt, "gensalt", lambda: b"salt")
monkeypatch.setattr(users.bcrypt, "hashpw", lambda _value, _salt: b"new-hash")
assert users.changePassword(USER_ID, "new-password")
assert users.deleteUser(USER_ID)
valid = {
"id": USER_ID,
"username": "alice",
"password_hashed": b"hash",
"timezone": "UTC",
"unexpected": True,
}
assert users.createUser(valid) == {"id": USER_ID}
assert "unexpected" not in insert.call_args.args[1]
assert users.validateUser(valid)[0]
assert not users.validateUser({"id": "bad", "timezone": "bad"})[0]
def test_login_token_creation_decoding_and_protected_claims(monkeypatch):
monkeypatch.setenv("JWT_SECRET", "test-secret")
monkeypatch.setattr(users, "doesUserUUIDExist", lambda value: value == USER_ID)
monkeypatch.setattr(users, "getUserFirstName", lambda _value: "Alice")
encode = MagicMock(return_value="encoded-token")
monkeypatch.setattr(auth.jwt, "encode", encode)
assert auth.createLoginToken(
USER_ID,
expiresIn=60,
extraClaims={"provider": "discord", "sub": "attacker"},
) == "encoded-token"
payload = encode.call_args.args[0]
assert payload["sub"] == USER_ID and payload["provider"] == "discord"
assert payload["exp"] > payload["iat"]
with pytest.raises(ValueError, match="positive"):
auth.createLoginToken(USER_ID, expiresIn=0)
with pytest.raises(ValueError, match="extra claims"):
auth.createLoginToken(USER_ID, extraClaims="bad")
monkeypatch.setattr(auth.jwt, "decode", lambda *_args, **_kwargs: {"sub": USER_ID})
assert auth.decodeLoginToken("token")["sub"] == USER_ID
monkeypatch.setattr(auth.jwt, "decode", lambda *_args, **_kwargs: {"sub": 123})
assert auth.decodeLoginToken("token") is None
monkeypatch.delenv("JWT_SECRET")
assert auth.decodeLoginToken("token") is None
with pytest.raises(RuntimeError, match="JWT_SECRET"):
auth.createLoginToken(USER_ID)
def test_password_login_and_account_removal(monkeypatch):
monkeypatch.setattr(users, "getUserUUID", lambda name: USER_ID if name == "alice" else False)
monkeypatch.setattr(auth, "getUserpasswordHash", lambda _value: b"stored")
monkeypatch.setattr(auth.bcrypt, "checkpw", lambda password, hashed: password == b"password123")
monkeypatch.setattr(auth, "createLoginToken", lambda _value: "login-token")
assert auth.getLoginToken("alice", "password123") == "login-token"
assert auth.getLoginToken("alice", "wrong-password") is False
assert auth.getLoginToken(None, "password123") is False
monkeypatch.setattr(users, "deleteUser", MagicMock(return_value=True))
assert auth.unregisterUser(USER_ID, "password123")
assert not auth.unregisterUser(USER_ID, "wrong-password")
def test_bearer_authentication_and_principal_helpers(monkeypatch):
assert auth.getBearerToken("Bearer token") == "token"
assert auth.getBearerToken("basic token") is None
assert auth.getBearerToken(None) is None
monkeypatch.setattr(auth, "decodeLoginToken", lambda _token: {"sub": USER_ID})
principal = auth.authenticateBearerToken("Bearer a.b.c")
assert principal["authentication"] == "jwt"
assert auth.isUserPrincipal(principal, USER_ID, requireLogin=True)
assert auth.authenticateBearerToken(
"Bearer a.b.c", requiredScopes=["service:scope"]
) is None
service = {
"type": "service",
"scopes": ["jobs:claim"],
"authentication": "api_key",
}
monkeypatch.setattr(apiKeys, "authenticateApiKey", lambda *_args, **_kwargs: service)
assert auth.authenticateBearerToken("Bearer service-key") == service
assert auth.authenticateBearerToken("Bearer service-key", allowService=False) is None
assert auth.hasServiceScope(service, "jobs:claim")
assert not auth.hasServiceScope(principal, "jobs:claim")
def test_api_key_normalization_public_shape_and_ids(monkeypatch):
assert apiKeys._normalizeScopes("one, two one") == ["one", "two"]
assert apiKeys._normalizeScopes(None) == []
with pytest.raises(ValueError, match="scopes"):
apiKeys._normalizeScopes(123)
with pytest.raises(ValueError, match="future"):
apiKeys._normalizeExpiry("2000-01-01T00:00:00Z")
record = {"id": KEY_ID, "key_hash": "secret", "scopes": '["one"]'}
assert apiKeys._publicKey(record, includeSecret="raw") == {
"id": KEY_ID,
"scopes": ["one"],
"key": "raw",
}
assert apiKeys._keyID(KEY_ID) == KEY_ID
assert apiKeys._keyID("bad") is None
monkeypatch.setattr(apiKeys.postgres, "select_one", MagicMock(return_value=record))
assert apiKeys.getApiKey("bad") is None
assert apiKeys.getApiKey(KEY_ID)["id"] == KEY_ID
def test_api_key_creation_and_owner_rules(monkeypatch):
monkeypatch.setattr(users, "doesUserUUIDExist", lambda value: value == USER_ID)
secret = "x" * 32
stored = {
"id": KEY_ID,
"name": "cli",
"key_type": "user",
"user_uuid": USER_ID,
"key_hash": apiKeys._hashToken(secret),
"scopes": [],
}
cursor = FakeCursor([stored])
monkeypatch.setattr(apiKeys.postgres, "get_cursor", _cursorContext(cursor))
created = apiKeys.createApiKey(
"cli", "user", userUUID=USER_ID, secret=secret
)
assert created["key"] == secret and "key_hash" not in created
assert cursor.executed[0][1]["key_prefix"] == secret[:20]
with pytest.raises(ValueError, match="user does not exist"):
apiKeys.createUserApiKey(str(uuid.uuid4()), "missing")
with pytest.raises(ValueError, match="cannot have a service"):
apiKeys.createApiKey(
"bad", "user", userUUID=USER_ID, serviceName="service"
)
with pytest.raises(ValueError, match="service name"):
apiKeys.createApiKey("bad", "service", scopes=[])
with pytest.raises(ValueError, match="cannot have a user"):
apiKeys.createApiKey(
"bad", "service", userUUID=USER_ID, serviceName="service"
)
def test_api_key_authentication_scope_expiry_and_revoke(monkeypatch):
secret = "service-secret-that-is-at-least-32-characters"
record = {
"id": KEY_ID,
"key_type": "service",
"user_uuid": None,
"service_name": "worker",
"key_hash": apiKeys._hashToken(secret),
"scopes": ["jobs:claim"],
"expires_at": None,
}
select = MagicMock(return_value=[record])
update = MagicMock()
monkeypatch.setattr(apiKeys.postgres, "select", select)
monkeypatch.setattr(apiKeys.postgres, "update", update)
principal = apiKeys.authenticateApiKey(secret, ["jobs:claim"])
assert principal["service_name"] == "worker"
assert update.called
assert apiKeys.authenticateApiKey("short") is None
assert apiKeys.authenticateApiKey(secret, ["jobs:deliver"]) is None
expired = dict(record, expires_at=apiKeys._utcNow() - datetime.timedelta(seconds=1))
select.return_value = [expired]
assert apiKeys.authenticateApiKey(secret) is None
select.return_value = []
assert apiKeys.authenticateApiKey(secret) is None
execute = MagicMock(return_value=[{"id": KEY_ID}])
monkeypatch.setattr(apiKeys.postgres, "execute", execute)
assert apiKeys.revokeApiKey(KEY_ID, serviceName="worker")
assert not apiKeys.revokeApiKey("invalid")
def test_service_key_bootstrap_is_idempotent_configuration(monkeypatch):
monkeypatch.delenv("BOT_API_KEY", raising=False)
assert apiKeys.bootstrapServiceApiKey() is None
secret = "configured-service-key-at-least-32-characters"
monkeypatch.setenv("BOT_API_KEY", secret)
monkeypatch.setenv("BOT_API_KEY_SCOPES", "discord:session, outbox:claim")
record = {
"id": KEY_ID,
"key_hash": apiKeys._hashToken(secret),
"scopes": json.dumps(["discord:session", "outbox:claim"]),
}
cursor = FakeCursor([record])
monkeypatch.setattr(apiKeys.postgres, "get_cursor", _cursorContext(cursor))
result = apiKeys.bootstrapServiceApiKey()
assert result["scopes"] == ["discord:session", "outbox:claim"]
assert len(cursor.executed) == 2
def test_identity_normalization_enrollment_and_lookup(monkeypatch):
assert identity._normalizeProvider(" Discord ") == "discord"
assert identity._normalizeProviderUserID(123) == "123"
assert identity._normalizeDisplayName(" Alice ") == "Alice"
with pytest.raises(ValueError, match="provider"):
identity._normalizeProvider("bad provider")
with pytest.raises(ValueError, match="provider user ID"):
identity._normalizeProviderUserID("")
monkeypatch.setenv("DISCORD_ENROLLMENT_MODE", "allowlist")
monkeypatch.setenv("DISCORD_ALLOWLIST", "123, 456\n789")
assert identity.getDiscordAllowlist() == {"123", "456", "789"}
assert identity.isDiscordEnrollmentAllowed("456")
assert not identity.isDiscordEnrollmentAllowed("000")
monkeypatch.setenv("DISCORD_ENROLLMENT_MODE", "open")
assert identity.isDiscordEnrollmentAllowed("000")
monkeypatch.setattr(
identity.postgres,
"select_one",
MagicMock(return_value={"user_uuid": USER_ID}),
)
monkeypatch.setattr(users, "getUser", MagicMock(return_value={"id": USER_ID}))
assert identity.getDiscordUser("123") == {"id": USER_ID}
def test_link_provider_identity_existing_and_new_paths(monkeypatch):
monkeypatch.setattr(users, "doesUserUUIDExist", lambda _value: True)
existing = {
"id": "identity-one",
"user_uuid": USER_ID,
"display_name": "Old",
}
monkeypatch.setattr(identity, "getProviderIdentity", MagicMock(return_value=existing))
update = MagicMock(return_value=[dict(existing, display_name="New")])
monkeypatch.setattr(identity.postgres, "update", update)
assert identity.linkProviderIdentity(
USER_ID, "discord", "123", displayName="New"
)["display_name"] == "New"
identity.getProviderIdentity.return_value = dict(existing, user_uuid=str(uuid.uuid4()))
with pytest.raises(ValueError, match="another user"):
identity.linkProviderIdentity(USER_ID, "discord", "123")
identity.getProviderIdentity.return_value = None
inserted = {"id": "identity-two", "user_uuid": USER_ID}
monkeypatch.setattr(identity.postgres, "insert", MagicMock(return_value=inserted))
assert identity.linkDiscordIdentity(USER_ID, "456") == inserted
monkeypatch.setattr(identity.postgres, "delete", MagicMock(return_value=[inserted]))
assert identity.unlinkProviderIdentity(USER_ID, "discord", "456")
def test_get_or_create_discord_user_existing_denied_and_created(monkeypatch):
monkeypatch.setattr(users, "getDefaultTimezone", lambda: "UTC")
existingIdentity = {
"id": "identity-one",
"user_uuid": USER_ID,
"display_name": "Old",
}
monkeypatch.setattr(
identity, "getProviderIdentity", MagicMock(return_value=existingIdentity)
)
monkeypatch.setattr(users, "getUser", MagicMock(return_value={"id": USER_ID}))
update = MagicMock()
monkeypatch.setattr(identity.postgres, "update", update)
assert identity.getOrCreateDiscordUser("123", "New") == {"id": USER_ID}
assert update.called
identity.getProviderIdentity.return_value = None
monkeypatch.setattr(identity, "isDiscordEnrollmentAllowed", lambda _value: False)
assert identity.getOrCreateDiscordUser("999") is None
monkeypatch.setattr(identity, "isDiscordEnrollmentAllowed", lambda _value: True)
cursor = FakeCursor([None, {"id": USER_ID, "timezone": "UTC"}])
monkeypatch.setattr(identity.postgres, "get_cursor", _cursorContext(cursor))
created = identity.getOrCreateDiscordUser("999", "New")
assert created["id"] == USER_ID
assert len(cursor.executed) == 4