Build reusable bot framework
This commit is contained in:
382
tests/unit/test_security_core.py
Normal file
382
tests/unit/test_security_core.py
Normal file
@@ -0,0 +1,382 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user