api/tests/test_me_bootstrap.py
CC Worker 93972a62f7
Some checks failed
api-ci-deploy / test-build-deploy (push) Has been cancelled
fix: revert explicit apikey header (caused Kong duplicate-apikey 401)
The previous commit added apikey to _create_base_client headers, but supabase-py
already sets apikey from the key arg → two apikey headers → Kong rejected every
as-user call with 401 'Duplicate API key found' (exam API 502'd on auth). Revert
to Authorization-only; fix the two header unit tests to assert the real contract
(apikey via the key arg; options.headers carries only the user Authorization).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:30:36 +00:00

282 lines
11 KiB
Python

import os
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routers.me.bootstrap_router import router, auth_scheme
from modules.database.services.bootstrap_service import BootstrapService
USER_ID = "00000000-0000-0000-0000-000000000001"
INST_A = "10000000-0000-0000-0000-000000000001"
INST_B = "10000000-0000-0000-0000-000000000002"
class FakeResult:
def __init__(self, data):
self.data = data
class FakeQuery:
def __init__(self, rows):
self.rows = list(rows)
self._single = False
self._limit = None
def select(self, *_args, **_kwargs):
return self
def eq(self, key, value):
self.rows = [row for row in self.rows if row.get(key) == value]
return self
def in_(self, key, values):
values = set(values)
self.rows = [row for row in self.rows if row.get(key) in values]
return self
def order(self, *_args, **_kwargs):
return self
def limit(self, count):
self._limit = count
return self
def single(self):
self._single = True
return self
def execute(self):
rows = self.rows[: self._limit] if self._limit is not None else self.rows
if self._single:
return FakeResult(rows[0] if rows else None)
return FakeResult(rows)
class FakeSupabase:
def __init__(self, tables):
self.tables = tables
def table(self, name):
return FakeQuery(self.tables.get(name, []))
def profile(user_type="teacher", school_id=None):
return {
"id": USER_ID,
"email": "teacher@example.test",
"full_name": "Example Teacher",
"display_name": None,
"user_type": user_type,
"school_id": school_id,
}
def institute(id_=INST_A, name="Example School", status="active"):
return {
"id": id_,
"name": name,
"urn": "123456",
"website": "https://school.example",
"address": {"town": "Testville"},
"metadata": {"internal_note": "should remain institute metadata only"},
"status": status,
"neo4j_uuid_string": "neo-a" if id_ == INST_A else "neo-b",
}
def membership(institute_id=INST_A, role="teacher"):
return {"profile_id": USER_ID, "institute_id": institute_id, "role": role}
def service(tables, graph_probe=None):
return BootstrapService(FakeSupabase(tables), graph_probe=graph_probe or (lambda **_: {"available": True, "projection_state": "ready"}))
def build(credentials=None, tables=None, graph_probe=None):
credentials = credentials or {"sub": USER_ID, "email": "teacher@example.test"}
tables = tables or {"profiles": [profile()]}
return service(tables, graph_probe).build(credentials)
def test_supabase_client_for_user_uses_access_token_authorization(monkeypatch):
from modules.database.supabase.utils import client as client_module
captured = {}
class FakeOptions:
def __init__(self, **kwargs):
captured["options_kwargs"] = kwargs
def fake_create_client(url, key, options=None):
captured["url"] = url
captured["key"] = key
captured["options"] = options
return {"ok": True}
monkeypatch.setenv("SUPABASE_URL", "http://supabase.test")
monkeypatch.setenv("ANON_KEY", "anon-key")
monkeypatch.setattr(client_module, "SyncClientOptions", FakeOptions)
monkeypatch.setattr(client_module, "create_client", fake_create_client)
anon = client_module.SupabaseAnonClient.for_user("user-token")
assert anon.access_token == "user-token"
assert captured["url"] == "http://supabase.test"
# apikey is supplied via the `key` positional arg (supabase-py sets the apikey header from it).
# options.headers must carry ONLY the per-user Authorization override — adding apikey here too
# produces a duplicate apikey header that Kong rejects ("Duplicate API key found").
assert captured["key"] == "anon-key"
assert captured["options_kwargs"]["headers"] == {"Authorization": "Bearer user-token"}
def test_no_school_bootstrap_requires_school_membership_but_allows_canvas():
payload = build(tables={"profiles": [profile()]})
assert payload["school_status"] == "no_school"
assert payload["active_institute"]["source"] == "none"
assert payload["memberships"] == []
assert payload["permissions"]["can_create_school"] is True
assert payload["permissions"]["can_use_canvas"] is True
assert payload["onboarding"]["next_step"] == "create_or_join_school"
assert "school_membership" in payload["onboarding"]["required"]
def test_single_membership_becomes_active_and_uses_supabase_calendar_timetable_status():
payload = build(tables={
"profiles": [profile()],
"institute_memberships": [membership()],
"institutes": [institute()],
"academic_years": [{"id": "ay-1", "institute_id": INST_A, "is_current": True}],
"academic_terms": [{"id": "term-1", "institute_id": INST_A}],
"school_timetables": [{"id": "school-tt-1", "institute_id": INST_A}],
"teacher_timetables": [{"id": "teacher-tt-1", "institute_id": INST_A, "teacher_profile_id": USER_ID}],
"teacher_timetable_slots": [
{"id": "slot-1", "teacher_timetable_id": "teacher-tt-1"},
{"id": "slot-2", "teacher_timetable_id": "teacher-tt-1"},
],
})
assert payload["school_status"] == "member"
assert payload["active_institute"] == {"id": INST_A, "source": "single_membership", "membership_role": "teacher"}
assert payload["calendar_status"] == {
"available": True,
"academic_year_count": 1,
"term_count": 1,
"current_academic_year_id": "ay-1",
"needs_setup": False,
}
assert payload["timetable_status"] == {
"available": True,
"teacher_timetable_id": "teacher-tt-1",
"slot_count": 2,
"needs_setup": False,
}
assert payload["onboarding"]["next_step"] == "ready"
def test_multiple_memberships_require_selection_when_no_profile_school_id_matches():
payload = build(tables={
"profiles": [profile()],
"institute_memberships": [membership(INST_A, "teacher"), membership(INST_B, "department_head")],
"institutes": [institute(INST_A, "Alpha"), institute(INST_B, "Beta")],
})
assert payload["school_status"] == "multi_school_needs_selection"
assert payload["active_institute"] == {"id": None, "source": "none", "membership_role": None}
assert payload["onboarding"]["next_step"] == "select_school"
assert "active_school_selection" in payload["onboarding"]["required"]
def test_school_admin_permissions_and_onboarding_invite_staff_after_calendar_timetable_ready():
payload = build(tables={
"profiles": [profile(user_type="school_admin", school_id=INST_A)],
"institute_memberships": [membership(INST_A, "school_admin")],
"institutes": [institute()],
"academic_years": [{"id": "ay-1", "institute_id": INST_A, "is_current": True}],
"academic_terms": [{"id": "term-1", "institute_id": INST_A}],
"teacher_timetables": [{"id": "teacher-tt-1", "institute_id": INST_A, "teacher_profile_id": USER_ID}],
"teacher_timetable_slots": [{"id": "slot-1", "teacher_timetable_id": "teacher-tt-1"}],
})
assert payload["school_status"] == "school_admin"
perms = payload["permissions"]
assert perms["can_manage_school"] is True
assert perms["can_manage_calendar"] is True
assert perms["can_manage_timetable"] is True
assert perms["can_invite_staff"] is True
assert perms["can_view_student_data"] is True
assert payload["onboarding"]["next_step"] == "invite_staff"
def test_platform_admin_uses_admin_profiles_without_relying_on_profile_super_admin_type():
payload = build(credentials={"sub": USER_ID, "email": "admin@example.test"}, tables={
"profiles": [profile(user_type="teacher")],
"admin_profiles": [{"id": USER_ID, "admin_role": "owner", "is_super_admin": True}],
})
assert payload["profile"]["user_type"] == "platform_admin"
assert payload["school_status"] == "platform_admin"
assert payload["permissions"]["platform_admin"] is True
assert payload["permissions"]["platform_super_admin"] is True
assert payload["permissions"]["can_create_school"] is True
def test_platform_admin_membership_role_does_not_reduce_platform_permissions():
payload = build(credentials={"sub": USER_ID, "email": "admin@example.test"}, tables={
"profiles": [profile(user_type="student")],
"admin_profiles": [{"id": USER_ID, "admin_role": "owner", "is_super_admin": True}],
"institute_memberships": [membership(INST_A, "student")],
"institutes": [institute()],
})
assert payload["school_status"] == "platform_admin"
assert payload["permissions"]["platform_admin"] is True
assert payload["permissions"]["can_manage_school"] is True
assert payload["permissions"]["can_manage_calendar"] is True
assert payload["permissions"]["can_view_student_data"] is True
def test_neo4j_probe_failure_does_not_block_supabase_bootstrap_state():
def failing_probe(**_kwargs):
raise RuntimeError("connection refused with host details that must not leak")
payload = build(tables={
"profiles": [profile()],
"institute_memberships": [membership()],
"institutes": [institute()],
}, graph_probe=failing_probe)
assert payload["school_status"] == "member"
assert payload["active_institute"]["id"] == INST_A
assert payload["graph_status"]["available"] is False
assert payload["graph_status"]["projection_state"] == "error"
assert payload["graph_status"]["needs_rebuild"] is True
assert payload["graph_status"]["error_code"] == "neo4j_unavailable"
assert "connection refused" not in str(payload)
def test_route_is_authenticated_and_does_not_return_raw_auth_metadata(monkeypatch):
app = FastAPI()
app.include_router(router, prefix="/me")
app.dependency_overrides[auth_scheme] = lambda: {"sub": USER_ID, "email": "teacher@example.test", "app_metadata": {"secret": "nope"}}
monkeypatch.setattr(
"routers.me.bootstrap_router.build_bootstrap_response",
lambda credentials: {
"profile": {"id": USER_ID, "email": credentials["email"], "display_name": "Example", "user_type": "teacher", "school_id": None},
"memberships": [],
"active_institute": {"id": None, "source": "none", "membership_role": None},
"permissions": {"platform_admin": False},
"school_status": "no_school",
"onboarding": {"next_step": "create_or_join_school", "required": [], "optional": [], "message": "Create or join a school."},
"calendar_status": {"available": False, "academic_year_count": 0, "term_count": 0, "current_academic_year_id": None, "needs_setup": True},
"timetable_status": {"available": False, "teacher_timetable_id": None, "slot_count": 0, "needs_setup": True},
"graph_status": {"available": False, "user_db": None, "institute_db": None, "projection_state": "unknown", "needs_rebuild": True, "last_checked_at": "2026-05-28T00:00:00Z", "error_code": None},
},
)
response = TestClient(app).get("/me/bootstrap")
assert response.status_code == 200
assert "app_metadata" not in response.text
assert "secret" not in response.text