File size: 4,699 Bytes
66fd359
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a7ffb22
 
 
 
66fd359
a7ffb22
 
 
 
66fd359
 
 
a7ffb22
 
 
 
 
 
 
 
 
66fd359
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a7ffb22
66fd359
 
a7ffb22
 
66fd359
 
a7ffb22
 
66fd359
 
 
 
 
 
 
 
a7ffb22
66fd359
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
import io
import json
import urllib.error

import pytest
from fastapi.testclient import TestClient

from mathly2 import auth
from mathly2.auth import AuthUnavailable, DevAuthenticator, SupabaseAuthenticator
from mathly2.config import Settings
from mathly2.main import create_app

TEACHER = "teacher@school.test"


@pytest.fixture
def client():
    return TestClient(create_app(Settings(teachers=frozenset({TEACHER}))))


def sign(email):
    return {"Authorization": f"Bearer dev:{email}"}


def test_no_token_is_refused(client):
    assert client.get("/api/me").status_code == 401
    assert client.get("/api/me", headers={"Authorization": "Bearer nonsense"}).status_code == 401
    assert client.get("/api/me", headers={"Authorization": "Bearer dev:not-an-email"}).status_code == 401


def me(client, email):
    return client.get("/api/me", headers=sign(email)).json()


def test_a_listed_teacher_is_a_teacher(client):
    body = me(client, TEACHER)
    assert body["email"] == TEACHER and body["roles"] == ["teacher"] and body["classes"] == [] and body["teaching"] == []
    assert "class.teach" in body["permissions"] and "classes.manage" not in body["permissions"]
    assert me(client, "Teacher@School.test")["roles"] == ["teacher"]            # case does not matter


def test_everyone_else_is_a_student(client):
    body = me(client, "kid@school.test")
    assert body["roles"] == ["student"] and body["permissions"] == ["path.use"] and body["classes"] == []


def test_developers_and_first_admins_come_from_the_settings():
    app = create_app(Settings(developers=frozenset({"dev@school.test"}), admins=frozenset({"head@school.test"})))
    client = TestClient(app)
    assert me(client, "dev@school.test")["roles"] == ["developer"] and me(client, "dev@school.test")["permissions"] == ["*"]
    assert me(client, "head@school.test")["roles"] == ["admin"] and me(client, "head@school.test")["permissions"] == ["roles.manage"]


def test_page_carries_only_public_settings():
    page = TestClient(create_app(Settings())).get("/").text
    assert '"devSignIn": true' in page and "__CONFIG__" not in page
    page = TestClient(create_app(Settings(supabase_url="https://p.test", supabase_key="pub", supabase_secret="SECRET-VALUE"))).get("/").text
    assert '"supabaseKey": "pub"' in page and '"devSignIn": false' in page and "SECRET-VALUE" not in page


def test_half_configured_supabase_does_not_fall_back_to_dev_sign_in():
    with pytest.raises(ValueError, match="all of SUPABASE_URL"):
        create_app(Settings(supabase_url="https://p.test"))


def test_dev_sign_in_is_off_when_supabase_is_on():
    app = create_app(Settings(supabase_url="https://p.test", supabase_key="pub", supabase_secret="sec"))
    assert isinstance(app.state.authenticator, SupabaseAuthenticator)
    assert TestClient(app).get("/api/me", headers=sign("kid@school.test")).status_code in (401, 503)


def supabase_returns(monkeypatch, payload=None, error=None):
    """Stand in for the HTTP call to Supabase's /auth/v1/user: the calls made are returned as (url, headers)."""
    calls = []

    def fake(method, url, headers=None, body=None, timeout=0):
        calls.append((url, headers or {}))
        if error:
            raise error
        return 200, json.dumps(payload).encode()
    monkeypatch.setattr(auth.pooled, "request", fake)
    return calls


def test_supabase_user_is_checked_with_the_token_and_cached(monkeypatch):
    calls = supabase_returns(monkeypatch, {"id": "u1", "email": "Kid@School.test", "email_confirmed_at": "2026-01-01"})
    a = SupabaseAuthenticator("https://p.test", "pub")
    assert a.user("tok") == {"id": "u1", "email": "kid@school.test"}
    assert a.user("tok")["id"] == "u1"
    assert len(calls) == 1 and calls[0][1]["Authorization"] == "Bearer tok" and calls[0][0].endswith("/auth/v1/user")


def test_an_unconfirmed_email_is_not_trusted(monkeypatch):
    supabase_returns(monkeypatch, {"id": "u1", "email": TEACHER})
    assert SupabaseAuthenticator("https://p.test", "pub").user("tok") is None


def test_rejected_token_and_outage(monkeypatch):
    supabase_returns(monkeypatch, error=urllib.error.HTTPError("u", 401, "no", {}, None))
    assert SupabaseAuthenticator("https://p.test", "pub").user("bad") is None
    supabase_returns(monkeypatch, error=urllib.error.URLError("down"))
    with pytest.raises(AuthUnavailable):
        SupabaseAuthenticator("https://p.test", "pub").user("tok")


def test_outage_gives_503(monkeypatch):
    class Down:
        def user(self, token): raise AuthUnavailable("down")
    app = create_app(Settings(), authenticator=Down())
    assert TestClient(app).get("/api/me", headers={"Authorization": "Bearer x"}).status_code == 503