Some checks failed
api-ci-deploy / test-build-deploy (push) Has been cancelled
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>
282 lines
11 KiB
Python
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
|