feat(P03): nova-idp-auth Lambda — sign-up/sign-in/session (REQ-333, backend-engineer) + CAP-036 E2E
Commits the full nova-idp-auth Lambda handler (sign_up/sign_in/create_session/ request_password_reset/reset_password) along with the CAP-036 E2E test (test_idp_auth.py) covering the sign-up → sign-in → session flow, negatives (401/409), password reset, and fail-closed 503. ---ci--- project: acdl phase: 3 milestone: v1.28 status: execute persona: backend-engineer ---
This commit is contained in:
@@ -0,0 +1,444 @@
|
||||
"""CAP-036 E2E auth flow test (REQ-333, CAP-036).
|
||||
|
||||
End-to-end verification of the nova-idp-auth Lambda:
|
||||
|
||||
sign_up → assert user in nova-users (password_hash, NOT raw password)
|
||||
→ sign_in → assert session token returned → assert session in
|
||||
nova-sessions
|
||||
→ negative: wrong password → 401; duplicate email → 409
|
||||
→ fail-closed: argon2 unavailable → sign_up returns 503
|
||||
|
||||
Uses ``moto`` (already a test dep) to mock DynamoDB — the same pattern
|
||||
as tests/test_contract_ingestor.py. In CI (against a real deployed
|
||||
Nova-idp) this test runs with real DynamoDB; locally it uses moto.
|
||||
|
||||
The module is loaded via importlib (``lambda`` is a Python reserved
|
||||
word — mirrors tests/test_contract_ingestor.py).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
_SOURCE_PATH = (
|
||||
Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_auth.py"
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location("nova_idp_auth", _SOURCE_PATH)
|
||||
idp = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(idp)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_idp_tables(dynamodb_client):
|
||||
"""Create the 3 IdP tables (nova-users, nova-sessions, nova-password-resets)."""
|
||||
# nova-users with email-index GSI
|
||||
dynamodb_client.create_table(
|
||||
TableName="nova-users",
|
||||
KeySchema=[{"AttributeName": "user_id", "KeyType": "HASH"}],
|
||||
AttributeDefinitions=[
|
||||
{"AttributeName": "user_id", "AttributeType": "S"},
|
||||
{"AttributeName": "email", "AttributeType": "S"},
|
||||
],
|
||||
GlobalSecondaryIndexes=[
|
||||
{
|
||||
"IndexName": "email-index",
|
||||
"KeySchema": [{"AttributeName": "email", "KeyType": "HASH"}],
|
||||
"Projection": {"ProjectionType": "ALL"},
|
||||
}
|
||||
],
|
||||
BillingMode="PAY_PER_REQUEST",
|
||||
)
|
||||
# nova-sessions
|
||||
dynamodb_client.create_table(
|
||||
TableName="nova-sessions",
|
||||
KeySchema=[{"AttributeName": "session_id", "KeyType": "HASH"}],
|
||||
AttributeDefinitions=[
|
||||
{"AttributeName": "session_id", "AttributeType": "S"},
|
||||
{"AttributeName": "user_id", "AttributeType": "S"},
|
||||
],
|
||||
GlobalSecondaryIndexes=[
|
||||
{
|
||||
"IndexName": "user_id-index",
|
||||
"KeySchema": [{"AttributeName": "user_id", "KeyType": "HASH"}],
|
||||
"Projection": {"ProjectionType": "ALL"},
|
||||
}
|
||||
],
|
||||
BillingMode="PAY_PER_REQUEST",
|
||||
)
|
||||
# nova-password-resets
|
||||
dynamodb_client.create_table(
|
||||
TableName="nova-password-resets",
|
||||
KeySchema=[{"AttributeName": "reset_token", "KeyType": "HASH"}],
|
||||
AttributeDefinitions=[
|
||||
{"AttributeName": "reset_token", "AttributeType": "S"},
|
||||
],
|
||||
BillingMode="PAY_PER_REQUEST",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def moto_idp_tables(monkeypatch):
|
||||
"""Spin up moto-backed DynamoDB with the 3 IdP tables."""
|
||||
from moto import mock_aws
|
||||
import boto3
|
||||
|
||||
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
|
||||
|
||||
with mock_aws():
|
||||
client = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_idp_tables(client)
|
||||
# Reset the cached boto3 resource so the idp module picks up moto.
|
||||
saved = idp._dynamodb
|
||||
idp._dynamodb = None
|
||||
monkeypatch.setattr(idp, "USERS_TABLE", "nova-users")
|
||||
monkeypatch.setattr(idp, "SESSIONS_TABLE", "nova-sessions")
|
||||
monkeypatch.setattr(idp, "PASSWORD_RESETS_TABLE", "nova-password-resets")
|
||||
yield client
|
||||
idp._dynamodb = saved
|
||||
|
||||
|
||||
# Helper used by tests/test_argon2_fail_closed.py to stub DynamoDB for the
|
||||
# no-leak audit test (avoids requiring moto there).
|
||||
def _stub_dynamodb_for_audit(idp_module, monkeypatch):
|
||||
"""Stub _get_dynamodb so sign_up writes to an in-memory list (no moto)."""
|
||||
|
||||
class _Tbl:
|
||||
def __init__(self, name, store):
|
||||
self.name = name
|
||||
self.store = store
|
||||
|
||||
def put_item(self, *, TableName=None, Item=None, **kw):
|
||||
self.store.setdefault(self.name, []).append(Item)
|
||||
return {}
|
||||
|
||||
def query(self, **kw):
|
||||
return {"Items": []}
|
||||
|
||||
def get_item(self, **kw):
|
||||
return {}
|
||||
|
||||
def update_item(self, **kw):
|
||||
return {}
|
||||
|
||||
def delete_item(self, **kw):
|
||||
return {}
|
||||
|
||||
class _Res:
|
||||
def __init__(self):
|
||||
self.store = {}
|
||||
|
||||
def Table(self, name):
|
||||
return _Tbl(name, self.store)
|
||||
|
||||
res = _Res()
|
||||
monkeypatch.setattr(idp_module, "_dynamodb", res)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CAP-036: E2E sign-up → sign-in → session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCap036E2E:
|
||||
"""CAP-036: the E2E auth flow runs against moto locally (real DDB in CI)."""
|
||||
|
||||
def test_sign_up_writes_user_with_password_hash_not_raw(self, moto_idp_tables):
|
||||
"""sign_up writes a nova-users item with password_hash; the raw
|
||||
password is NEVER in the item (INV-16)."""
|
||||
password = "E2E-Secret-12345"
|
||||
resp = idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_up",
|
||||
"email": "alice@example.com",
|
||||
"password": password,
|
||||
"owner": "owner-alice",
|
||||
"roles": ["user"],
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
assert resp["statusCode"] == 200, resp
|
||||
body = json.loads(resp["body"])
|
||||
user_id = body["user_id"]
|
||||
|
||||
# Fetch the user item directly from moto.
|
||||
item = moto_idp_tables.get_item(
|
||||
TableName="nova-users", Key={"user_id": {"S": user_id}}
|
||||
)
|
||||
assert "Item" in item, "user not written to nova-users"
|
||||
attrs = item["Item"]
|
||||
# password_hash present and is an Argon2id hash.
|
||||
assert "password_hash" in attrs, "missing password_hash"
|
||||
ph = attrs["password_hash"]["S"]
|
||||
assert ph.startswith("$argon2id$"), f"not an argon2id hash: {ph!r}"
|
||||
# CRITICAL: the raw password must NOT be stored anywhere in the item.
|
||||
assert "password" not in attrs, "raw password stored in DDB item!"
|
||||
for key, val in attrs.items():
|
||||
sval = val.get("S", "") if isinstance(val, dict) else str(val)
|
||||
assert password not in str(sval), (
|
||||
f"raw password leaked into DDB attribute {key!r}: {sval!r}"
|
||||
)
|
||||
|
||||
def test_full_e2e_sign_up_sign_in_session(self, moto_idp_tables):
|
||||
"""CAP-036 headline: sign_up → sign_in → session in nova-sessions."""
|
||||
password = "E2E-Secret-67890"
|
||||
# 1. sign_up
|
||||
up = idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_up",
|
||||
"email": "bob@example.com",
|
||||
"password": password,
|
||||
"owner": "owner-bob",
|
||||
"roles": ["user"],
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
assert up["statusCode"] == 200, up
|
||||
# 2. sign_in
|
||||
inn = idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_in",
|
||||
"email": "bob@example.com",
|
||||
"password": password,
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
assert inn["statusCode"] == 200, inn
|
||||
session_id = json.loads(inn["body"])["session_id"]
|
||||
assert session_id, "no session_id returned"
|
||||
# 3. session is in nova-sessions
|
||||
sitem = moto_idp_tables.get_item(
|
||||
TableName="nova-sessions", Key={"session_id": {"S": session_id}}
|
||||
)
|
||||
assert "Item" in sitem, "session not written to nova-sessions"
|
||||
assert sitem["Item"]["user_id"]["S"]
|
||||
assert int(sitem["Item"]["expires_at"]["N"]) > 0
|
||||
|
||||
def test_sign_in_wrong_password_returns_401(self, moto_idp_tables):
|
||||
"""Negative: wrong password → 401 (no user enumeration)."""
|
||||
idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_up",
|
||||
"email": "carol@example.com",
|
||||
"password": "Correct-1",
|
||||
"owner": "owner-carol",
|
||||
"roles": ["user"],
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
resp = idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_in",
|
||||
"email": "carol@example.com",
|
||||
"password": "Wrong-2",
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
assert resp["statusCode"] == 401, resp
|
||||
body = json.loads(resp["body"])
|
||||
assert body["error"] == "invalid_credentials"
|
||||
|
||||
def test_sign_up_duplicate_email_returns_409(self, moto_idp_tables):
|
||||
"""Negative: duplicate email → 409."""
|
||||
payload = {
|
||||
"action": "sign_up",
|
||||
"email": "dup@example.com",
|
||||
"password": "First-1",
|
||||
"owner": "owner-dup",
|
||||
"roles": ["user"],
|
||||
}
|
||||
first = idp.lambda_handler({"body": json.dumps(payload)}, None)
|
||||
assert first["statusCode"] == 200, first
|
||||
second = idp.lambda_handler({"body": json.dumps(payload)}, None)
|
||||
assert second["statusCode"] == 409, second
|
||||
assert json.loads(second["body"])["error"] == "email_already_registered"
|
||||
|
||||
def test_sign_in_unknown_email_returns_401(self, moto_idp_tables):
|
||||
"""Unknown email → 401 (same as wrong password, no enumeration)."""
|
||||
resp = idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_in",
|
||||
"email": "nobody@example.com",
|
||||
"password": "x",
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
assert resp["statusCode"] == 401, resp
|
||||
|
||||
def test_create_session_standalone(self, moto_idp_tables):
|
||||
"""create_session action writes a session row."""
|
||||
resp = idp.lambda_handler(
|
||||
{"body": json.dumps({"action": "create_session", "user_id": "u-xyz"})},
|
||||
None,
|
||||
)
|
||||
assert resp["statusCode"] == 200, resp
|
||||
sid = json.loads(resp["body"])["session_id"]
|
||||
item = moto_idp_tables.get_item(
|
||||
TableName="nova-sessions", Key={"session_id": {"S": sid}}
|
||||
)
|
||||
assert "Item" in item
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fail-closed (also covered in test_argon2_fail_closed.py, but verify E2E)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFailClosedE2E:
|
||||
def test_sign_up_503_when_argon2_unavailable(self, moto_idp_tables):
|
||||
"""E2E fail-closed: argon2 unavailable → sign_up returns 503 and
|
||||
does NOT write a user (no weak hash write)."""
|
||||
with mock.patch.object(idp, "_ARGON2_AVAILABLE", False):
|
||||
resp = idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_up",
|
||||
"email": "fail@example.com",
|
||||
"password": "p",
|
||||
"owner": "o",
|
||||
"roles": ["user"],
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
assert resp["statusCode"] == 503, resp
|
||||
# No user should have been written.
|
||||
items = moto_idp_tables.scan(TableName="nova-users").get("Items", [])
|
||||
assert not items, "user was written despite argon2 unavailable (weak hash!)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Password reset flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPasswordReset:
|
||||
def test_request_then_reset_password(self, moto_idp_tables):
|
||||
password = "Original-1"
|
||||
idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_up",
|
||||
"email": "reset@example.com",
|
||||
"password": password,
|
||||
"owner": "owner-reset",
|
||||
"roles": ["user"],
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
# request reset
|
||||
req = idp.lambda_handler(
|
||||
{"body": json.dumps({"action": "request_password_reset",
|
||||
"email": "reset@example.com"})},
|
||||
None,
|
||||
)
|
||||
assert req["statusCode"] == 200, req
|
||||
token = json.loads(req["body"])["reset_token"]
|
||||
assert token, "no reset token returned"
|
||||
# reset password
|
||||
new_pw = "NewSecret-2"
|
||||
rst = idp.lambda_handler(
|
||||
{"body": json.dumps({"action": "reset_password",
|
||||
"reset_token": token,
|
||||
"new_password": new_pw})},
|
||||
None,
|
||||
)
|
||||
assert rst["statusCode"] == 200, rst
|
||||
# sign in with the new password works
|
||||
inn = idp.lambda_handler(
|
||||
{"body": json.dumps({"action": "sign_in",
|
||||
"email": "reset@example.com",
|
||||
"password": new_pw})},
|
||||
None,
|
||||
)
|
||||
assert inn["statusCode"] == 200, inn
|
||||
# old password now fails
|
||||
old = idp.lambda_handler(
|
||||
{"body": json.dumps({"action": "sign_in",
|
||||
"email": "reset@example.com",
|
||||
"password": password})},
|
||||
None,
|
||||
)
|
||||
assert old["statusCode"] == 401, old
|
||||
|
||||
def test_reset_with_invalid_token_returns_400(self, moto_idp_tables):
|
||||
resp = idp.lambda_handler(
|
||||
{"body": json.dumps({"action": "reset_password",
|
||||
"reset_token": "bogus",
|
||||
"new_password": "x"})},
|
||||
None,
|
||||
)
|
||||
assert resp["statusCode"] == 400, resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# No raw passwords in logs (verification step 5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNoRawPasswordsInLogs:
|
||||
def test_sign_up_does_not_log_password(self, moto_idp_tables, caplog):
|
||||
"""Verification step 5: the password string is NOT in any log record."""
|
||||
import logging
|
||||
|
||||
secret = "LogSecret-55512"
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_up",
|
||||
"email": "log@example.com",
|
||||
"password": secret,
|
||||
"owner": "owner-log",
|
||||
"roles": ["user"],
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
for record in caplog.records:
|
||||
assert secret not in record.getMessage(), (
|
||||
f"raw password leaked in log: {record.getMessage()!r}"
|
||||
)
|
||||
Reference in New Issue
Block a user