310 lines
12 KiB
Python
310 lines
12 KiB
Python
"""
|
|
Registration stack alignment: frontend-shaped payloads, API, PostgreSQL, and MinIO.
|
|
|
|
Mirrors the JSON body produced by fe0 ``src/lib/auth-service.ts`` (``register()``);
|
|
when changing that client, update this test's payload builder if needed.
|
|
|
|
Flow:
|
|
1. POST /api/v1/auth/register — expect verification flow (no JWT); DB rows match payload.
|
|
2. Login blocked with 403 until verify-otp.
|
|
3. POST verify-otp — DB email_verified, OTP row consumed.
|
|
4. Login — JWT issued.
|
|
5. Minimal draft save + evidence upload — MinIO attachments bucket contains object at ``storageKey`` (``head_object``).
|
|
|
|
Run (same env style as test_backup_e2e):
|
|
|
|
export INITIATIVE_DATABASE_URL="postgresql+asyncpg://initiative:initiative_secret@127.0.0.1:15432/initiatives"
|
|
export S3_ENDPOINT_URL="http://127.0.0.1:19000"
|
|
export S3_PUBLIC_ENDPOINT_URL="http://127.0.0.1:19000"
|
|
export S3_ACCESS_KEY="minio_user"
|
|
export S3_SECRET_KEY="minio_password"
|
|
export S3_BUCKET_ATTACHMENTS="initiative-attachments"
|
|
export S3_BUCKET_EXPORTS="initiative-exports"
|
|
export S3_BUCKET_QUARANTINE="initiative-quarantine"
|
|
export REGISTRATION_STACK_TEST=1
|
|
cd be0 && python -m unittest tests.test_registration_stack_alignment -v
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import io
|
|
import os
|
|
import unittest
|
|
import uuid
|
|
from unittest.mock import patch
|
|
|
|
from sqlalchemy import select
|
|
|
|
from tests.auth_register_staff_fixture import register_staff_fields
|
|
from tests.fixtures.minimal_submit_bundle import minimal_tabs_bundle
|
|
|
|
_RUN_DB = os.getenv("INITIATIVE_DATABASE_URL", "").strip().lower().startswith("postgresql")
|
|
_S3_KEYS = (
|
|
"S3_ENDPOINT_URL",
|
|
"S3_ACCESS_KEY",
|
|
"S3_SECRET_KEY",
|
|
"S3_BUCKET_ATTACHMENTS",
|
|
"S3_BUCKET_EXPORTS",
|
|
"S3_BUCKET_QUARANTINE",
|
|
)
|
|
_HAS_S3 = all(os.getenv(k, "").strip() for k in _S3_KEYS)
|
|
_RUN_ALIGN = os.getenv("REGISTRATION_STACK_TEST", "").strip().lower() in ("1", "true", "yes")
|
|
|
|
_TEST_PASSWORD = "Testpass1!"
|
|
_MIN_PDF = (
|
|
b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n" + b"0" * 120
|
|
)
|
|
|
|
|
|
def _fe0_style_register_json(
|
|
*,
|
|
email: str,
|
|
full_name: str,
|
|
password: str,
|
|
staff: dict[str, str],
|
|
) -> dict:
|
|
"""Same keys as fe0 auth-service.register() JSON body (no ``role``, no camelCase drift)."""
|
|
return {
|
|
"fullName": full_name,
|
|
"email": email,
|
|
"password": password,
|
|
"passwordConfirm": password,
|
|
"employeeId": staff["employeeId"],
|
|
"academicTitleCode": staff["academicTitleCode"],
|
|
"unitNameFreetext": staff["unitNameFreetext"],
|
|
"jobTitle": staff["jobTitle"],
|
|
}
|
|
|
|
|
|
def _token_hash(raw: str) -> str:
|
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _s3_client():
|
|
import boto3
|
|
from botocore.config import Config as BotoConfig
|
|
|
|
return boto3.client(
|
|
"s3",
|
|
endpoint_url=os.environ["S3_ENDPOINT_URL"].strip(),
|
|
aws_access_key_id=os.environ["S3_ACCESS_KEY"].strip(),
|
|
aws_secret_access_key=os.environ["S3_SECRET_KEY"].strip(),
|
|
region_name=os.getenv("S3_REGION", "us-east-1"),
|
|
config=BotoConfig(signature_version="s3v4"),
|
|
)
|
|
|
|
|
|
@unittest.skipUnless(
|
|
_RUN_DB and _HAS_S3 and _RUN_ALIGN,
|
|
"Need INITIATIVE_DATABASE_URL, full S3_* env, REGISTRATION_STACK_TEST=1 (see module docstring).",
|
|
)
|
|
class RegistrationStackAlignmentTests(unittest.IsolatedAsyncioTestCase):
|
|
async def asyncSetUp(self) -> None:
|
|
from src.initiative_db import engine as eng
|
|
|
|
await eng.dispose_engine()
|
|
await eng.init_engine()
|
|
|
|
async def asyncTearDown(self) -> None:
|
|
from src.initiative_db import engine as eng
|
|
|
|
await eng.dispose_engine()
|
|
|
|
async def _delete_user_and_initiatives(self, email: str) -> None:
|
|
from src.initiative_db.engine import get_session
|
|
from src.initiative_db.models import Initiative, User
|
|
|
|
async with get_session() as session:
|
|
user = (
|
|
await session.execute(select(User).where(User.email == email))
|
|
).scalar_one_or_none()
|
|
if user is None:
|
|
return
|
|
inis = (
|
|
await session.execute(select(Initiative).where(Initiative.owner_id == user.id))
|
|
).scalars().all()
|
|
for ini in inis:
|
|
await session.delete(ini)
|
|
await session.flush()
|
|
await session.delete(user)
|
|
await session.commit()
|
|
|
|
async def test_register_api_db_login_verify_minio_alignment(self) -> None:
|
|
from fastapi.testclient import TestClient
|
|
|
|
from main import app
|
|
from src.initiative_db.engine import get_session
|
|
from src.initiative_db.models import (
|
|
RegistrationOtpCode,
|
|
User,
|
|
UserRoleRow,
|
|
UserStaffProfile,
|
|
)
|
|
|
|
email = f"align-reg-{uuid.uuid4().hex[:12]}@ump.edu.vn"
|
|
staff = register_staff_fields()
|
|
full_name = "Alignment Register User"
|
|
body = _fe0_style_register_json(
|
|
email=email,
|
|
full_name=full_name,
|
|
password=_TEST_PASSWORD,
|
|
staff=staff,
|
|
)
|
|
|
|
captured: list[str] = []
|
|
|
|
async def grab_mail(_to: str, raw: str) -> None:
|
|
captured.append(raw)
|
|
|
|
bucket = os.environ["S3_BUCKET_ATTACHMENTS"].strip()
|
|
s3 = _s3_client()
|
|
storage_key: str | None = None
|
|
|
|
try:
|
|
with patch.dict(os.environ, {"AUTH_MAIL_LOG_ONLY": "1"}):
|
|
with patch("src.auth_api.deliver_registration_otp_email", side_effect=grab_mail):
|
|
with TestClient(app) as client:
|
|
# --- Register (mirrors fe0 fetch body) ---
|
|
r = client.post("/api/v1/auth/register", json=body)
|
|
self.assertEqual(r.status_code, 200, r.text)
|
|
payload = r.json()
|
|
self.assertTrue(payload.get("emailVerificationRequired"), payload)
|
|
self.assertNotIn("accessToken", payload)
|
|
self.assertEqual(payload.get("email"), email)
|
|
self.assertIn("message", payload)
|
|
|
|
u_out = payload.get("user") or {}
|
|
self.assertEqual(u_out.get("email"), email)
|
|
self.assertEqual(u_out.get("name"), full_name)
|
|
self.assertIs(u_out.get("emailVerified"), False)
|
|
self.assertIn("viewer", u_out.get("roles") or [])
|
|
sp_out = u_out.get("staffProfile") or {}
|
|
self.assertEqual(sp_out.get("employeeId"), staff["employeeId"])
|
|
self.assertEqual(sp_out.get("academicTitleCode"), staff["academicTitleCode"])
|
|
self.assertEqual(sp_out.get("unitNameFreetext"), staff["unitNameFreetext"])
|
|
self.assertEqual(sp_out.get("jobTitle"), staff["jobTitle"])
|
|
|
|
self.assertTrue(captured, "OTP should be issued")
|
|
raw_otp = captured[0]
|
|
self.assertEqual(len(raw_otp), 6, raw_otp)
|
|
self.assertTrue(raw_otp.isdigit())
|
|
|
|
with TestClient(app) as client:
|
|
blocked = client.post(
|
|
"/api/v1/auth/login",
|
|
json={"email": email, "password": _TEST_PASSWORD},
|
|
)
|
|
self.assertEqual(blocked.status_code, 403, blocked.text)
|
|
|
|
# --- PostgreSQL: user, profile, role, OTP row ---
|
|
async with get_session() as session:
|
|
user = (
|
|
await session.execute(select(User).where(User.email == email))
|
|
).scalar_one()
|
|
self.assertFalse(user.email_verified)
|
|
self.assertEqual(user.full_name, full_name)
|
|
|
|
profile = await session.get(UserStaffProfile, user.id)
|
|
self.assertIsNotNone(profile)
|
|
assert profile is not None
|
|
self.assertEqual(profile.employee_id, staff["employeeId"])
|
|
self.assertEqual(profile.academic_title_code, staff["academicTitleCode"])
|
|
self.assertEqual(profile.job_title, staff["jobTitle"])
|
|
|
|
roles = (
|
|
await session.execute(
|
|
select(UserRoleRow.role).where(UserRoleRow.user_id == user.id)
|
|
)
|
|
).scalars().all()
|
|
self.assertEqual(sorted(roles), ["viewer"])
|
|
|
|
otps = (
|
|
await session.execute(
|
|
select(RegistrationOtpCode).where(
|
|
RegistrationOtpCode.user_id == user.id
|
|
)
|
|
)
|
|
).scalars().all()
|
|
self.assertEqual(len(otps), 1)
|
|
o = otps[0]
|
|
self.assertIsNone(o.used_at)
|
|
self.assertEqual(o.otp_hash, _token_hash(raw_otp))
|
|
|
|
# --- Verify OTP ---
|
|
with TestClient(app) as client:
|
|
vr = client.post("/api/v1/auth/verify-otp", json={"email": email, "otp": raw_otp})
|
|
self.assertEqual(vr.status_code, 200, vr.text)
|
|
|
|
async with get_session() as session:
|
|
user = (
|
|
await session.execute(select(User).where(User.email == email))
|
|
).scalar_one()
|
|
self.assertTrue(user.email_verified)
|
|
otps = (
|
|
await session.execute(
|
|
select(RegistrationOtpCode).where(
|
|
RegistrationOtpCode.user_id == user.id
|
|
)
|
|
)
|
|
).scalars().all()
|
|
self.assertEqual(len(otps), 1)
|
|
self.assertIsNotNone(otps[0].used_at)
|
|
|
|
# --- Login ---
|
|
with TestClient(app) as client:
|
|
ok = client.post(
|
|
"/api/v1/auth/login",
|
|
json={"email": email, "password": _TEST_PASSWORD},
|
|
)
|
|
self.assertEqual(ok.status_code, 200, ok.text)
|
|
token = ok.json()["accessToken"]
|
|
self.assertTrue(token)
|
|
|
|
cr = client.post(
|
|
"/api/applications/new",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
json={"name": "Alignment case"},
|
|
)
|
|
self.assertEqual(cr.status_code, 200, cr.text)
|
|
shell = cr.json().get("application") or {}
|
|
case_id = str(shell.get("draft_case_id") or "").strip()
|
|
self.assertTrue(case_id, shell)
|
|
|
|
bundle = minimal_tabs_bundle(initiative_name="Align Initiative")
|
|
for tab_name in ("report", "application", "contribution"):
|
|
dr = client.post(
|
|
"/api/v1/application-drafts",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
json={"caseId": case_id, "tab": tab_name, "data": bundle[tab_name]},
|
|
)
|
|
self.assertEqual(dr.status_code, 200, dr.text)
|
|
|
|
er = client.post(
|
|
f"/api/v1/application-drafts/{case_id}/evidence",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
data={"kind": "technical"},
|
|
files={"file": ("align.pdf", io.BytesIO(_MIN_PDF), "application/pdf")},
|
|
)
|
|
self.assertEqual(er.status_code, 200, er.text, er.text)
|
|
ev_body = er.json()
|
|
storage_key = str(ev_body.get("storageKey") or "").strip()
|
|
self.assertTrue(storage_key, ev_body)
|
|
|
|
# MinIO must contain bytes at the key the API recorded.
|
|
assert storage_key is not None
|
|
head = s3.head_object(Bucket=bucket, Key=storage_key)
|
|
self.assertGreater(int(head["ContentLength"]), 0)
|
|
|
|
finally:
|
|
await self._delete_user_and_initiatives(email)
|
|
if storage_key:
|
|
try:
|
|
s3.delete_object(Bucket=bucket, Key=storage_key)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|