mathly / tests /test_push.py
3v324v23's picture
Add tests for student task list and quiz modes functionality
0ba5932
Raw History Blame Contribute Delete
11.2 kB
import asyncio
import json
import urllib.parse
from datetime import datetime, timedelta, timezone
from pathlib import Path
import httpx
import pytest
from app import push as pushes
from app.answer_scans import scan_code
from app.auth import AuthUnavailable
from app.main import create_app
from scan_support import OTHER, OWNER, ScanAuth, answer_store, postgres_server # noqa: F401
KEY = pushes.new_private_key()
SUB = {"endpoint": "https://push.example/abc", "keys": {"p256dh": "p" * 87, "auth": "a" * 22}}
class PushAuth(ScanAuth):
"""ScanAuth with an in-memory math_push_subs behind the PostgREST calls Push makes."""
def __init__(self):
self.rows: dict[str, dict] = {}
def _admin(self, path, method="GET", body=None, prefer=""):
table, _, query = path.partition("?")
if table != "/rest/v1/math_push_subs":
return super()._admin(path) # no other tables here (a quiz answer looks for a final sitting)
q = dict(urllib.parse.parse_qsl(query))
match = lambda r: all(r[k] == v.removeprefix("eq.") for k, v in q.items() if k in ("endpoint", "user_id"))
if method == "POST":
self.rows[body["endpoint"]] = dict(body)
return None
if method == "DELETE":
for e in [e for e, r in self.rows.items() if match(r)]:
del self.rows[e]
return None
return [r for r in self.rows.values() if match(r)]
def scan(state="waiting", minutes=10, sid="7d9c2f0e-3c1a-4c55-9a1e-2b3c4d5e6f70"):
end = datetime.now(timezone.utc) + timedelta(minutes=minutes)
return {"attempt": {"id": "11111111-1111-1111-1111-111111111111"},
"scan": {"id": sid, "state": state, "expires_at": end.isoformat()}}
@pytest.fixture
def sent(monkeypatch):
calls = []
def webpush(sub, data, **kwargs):
calls.append({"endpoint": sub["endpoint"], "data": json.loads(data), **kwargs})
import pywebpush
monkeypatch.setattr(pywebpush, "webpush", webpush)
return calls
def test_payload_has_code_and_no_question_and_replaces_by_attempt():
result = scan()
msg = pushes.scan_payload(result)
assert scan_code(result["scan"]["id"]) in msg["body"]
assert msg["tag"] == "scan-11111111-1111-1111-1111-111111111111"
assert msg["url"] == "/?from=push-scan"
assert 590 <= pushes.seconds_left(result) <= 600
assert pushes.seconds_left(scan(minutes=-1)) == 0
assert pushes.seconds_left({"scan": {"expires_at": None}}) == 900
def test_keys_round_trip_and_off_reasons():
assert len(pushes.public_key(KEY)) == 87 # 65 bytes, base64url
assert pushes.Push(PushAuth(), "").why == "VAPID_PRIVATE_KEY is not set"
assert pushes.Push(PushAuth(), "not a key!").why == "VAPID_PRIVATE_KEY is not a valid key"
p = pushes.Push(PushAuth(), KEY, "mailto:a@test.invalid")
assert p.on and p.public == pushes.public_key(KEY)
def test_request_goes_once_to_the_accounts_phones_and_gone_ones_are_deleted(sent, monkeypatch):
auth = PushAuth()
p = pushes.Push(auth, KEY, "mailto:a@test.invalid")
p.save(OWNER, SUB)
p.save(OWNER, {**SUB, "endpoint": "https://push.example/gone"})
p.save(OTHER, {**SUB, "endpoint": "https://push.example/other"})
with pytest.raises(ValueError):
p.save(OWNER, {"endpoint": "http://insecure", "keys": SUB["keys"]})
import pywebpush
real = pywebpush.webpush
def webpush(sub, data, **kwargs):
if sub["endpoint"].endswith("/gone"):
raise pywebpush.WebPushException("gone", response=type("R", (), {"status_code": 410})())
return real(sub, data, **kwargs)
monkeypatch.setattr(pywebpush, "webpush", webpush)
assert p.scan_requested(OWNER, scan()) == 1
assert [c["endpoint"] for c in sent] == [SUB["endpoint"]]
assert sent[0]["headers"] == {"Urgency": "high"} and 0 < sent[0]["ttl"] <= 600
assert "https://push.example/gone" not in auth.rows and "https://push.example/other" in auth.rows
assert p.scan_requested(OWNER, scan()) == 0 # the same request again: no second ring
assert p.scan_requested(OWNER, scan("received", sid="22222222-2222-2222-2222-222222222222")) == 0
assert p.scan_requested(OWNER, scan(minutes=-1, sid="33333333-3333-3333-3333-333333333333")) == 0
assert len(sent) == 1
def test_missing_table_does_not_break_the_request(sent):
class NoTable(PushAuth):
def _admin(self, *a, **k):
raise AuthUnavailable("supabase answered 404: relation math_push_subs does not exist")
assert pushes.Push(NoTable(), KEY, "mailto:a@test.invalid").scan_requested(OWNER, scan()) == 0
def test_subscribe_api_moves_a_phone_between_accounts(monkeypatch):
monkeypatch.setenv("VAPID_PRIVATE_KEY", KEY)
auth = PushAuth()
app = create_app(auth=auth)
async def check():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
assert (await c.get("/api/push")).status_code == 401
owner, other = ({"Authorization": f"Bearer {u}"} for u in (OWNER, OTHER))
assert (await c.get("/api/push", headers=owner)).json()["key"] == pushes.public_key(KEY)
assert (await c.post("/api/push/subscribe", json={"endpoint": "x"}, headers=owner)).status_code == 400
assert (await c.post("/api/push/subscribe", json=SUB, headers=owner)).json() == {"ok": True}
assert auth.rows[SUB["endpoint"]]["user_id"] == OWNER
assert (await c.post("/api/push/subscribe", json=SUB, headers=other)).status_code == 200
assert auth.rows[SUB["endpoint"]]["user_id"] == OTHER # one row per phone: the account signed in now
await c.post("/api/push/unsubscribe", json={"endpoint": SUB["endpoint"]}, headers=owner)
assert SUB["endpoint"] in auth.rows # another account cannot remove it
await c.post("/api/push/unsubscribe", json={"endpoint": SUB["endpoint"]}, headers=other)
assert not auth.rows
sw = await c.get("/service-worker.js")
assert "showNotification" in sw.text and 'addEventListener("fetch"' not in sw.text and sw.headers["cache-control"] == "no-store"
asyncio.run(check())
def test_push_off_without_key(monkeypatch):
monkeypatch.delenv("VAPID_PRIVATE_KEY", raising=False)
app = create_app(auth=PushAuth())
async def check():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test",
headers={"Authorization": f"Bearer {OWNER}"}) as c:
assert (await c.get("/api/push")).json() == {"key": ""}
assert (await c.post("/api/push/subscribe", json=SUB)).status_code == 503
asyncio.run(check())
def test_computer_request_pushes_to_the_phone(answer_store, sent, monkeypatch):
monkeypatch.setenv("VAPID_PRIVATE_KEY", KEY)
auth = PushAuth()
app = create_app(auth=auth)
app.state.answers = answer_store
app.state.push.save(OWNER, SUB)
aid = answer_store.action(OWNER, "resume", question_id="q", question_version="v1",
snapshot={"question": "Prove it", "type": "open-ended"})["attempt"]["id"]
async def check():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test",
headers={"Authorization": f"Bearer {OWNER}"}) as c:
first = (await c.post(f"/api/math/answer-attempts/{aid}/scans", json={})).json()
again = (await c.post(f"/api/math/answer-attempts/{aid}/scans", json={})).json()
assert again["scan"]["id"] == first["scan"]["id"]
renewed = (await c.post(f"/api/math/answer-attempts/{aid}/scans", json={"renew": True})).json()
return first, renewed
first, renewed = asyncio.run(check())
assert len(sent) == 2 # the repeated request did not ring again; the renewed one did
assert first["scan"]["code"] in sent[0]["data"]["body"] and renewed["scan"]["code"] in sent[1]["data"]["body"]
assert sent[0]["data"]["tag"] == sent[1]["data"]["tag"] == f"scan-{aid}"
assert "Prove it" not in json.dumps(sent)
def test_migration_keeps_subscriptions_from_browsers(postgres_server):
import uuid
import psycopg
database = "push_" + uuid.uuid4().hex
with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
c.execute("create database " + database)
try:
with psycopg.connect(postgres_server.get_uri(database=database), autocommit=True) as c:
c.execute("create schema auth; create table auth.users(id uuid primary key);"
"grant usage on schema public to anon, authenticated, service_role;")
c.execute(Path("supabase/migrations/20261103180000_math_push_subs.sql").read_text())
c.execute("insert into auth.users values(%s)", (OWNER,))
c.execute("set role service_role")
c.execute("insert into math_push_subs(endpoint,user_id,p256dh,auth) values(%s,%s,'p','a')", (SUB["endpoint"], OWNER))
c.execute("reset role; set role authenticated")
with pytest.raises(psycopg.errors.InsufficientPrivilege):
c.execute("select * from math_push_subs")
c.execute("reset role")
with pytest.raises(psycopg.errors.CheckViolation):
c.execute("insert into math_push_subs(endpoint,user_id,p256dh,auth) values('http://x',%s,'p','a')", (OWNER,))
finally:
with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
c.execute("drop database " + database + " with (force)")
def test_a_devices_own_upload_rings_no_phone_and_is_offered_to_none(answer_store, sent, monkeypatch):
monkeypatch.setenv("VAPID_PRIVATE_KEY", KEY)
app = create_app(auth=PushAuth())
app.state.answers = answer_store
app.state.push.save(OWNER, SUB)
own = answer_store.action(OWNER, "resume", question_id="quiz:t:0", question_version="v1",
snapshot={"question": "Solve", "type": "open-ended"})["attempt"]["id"]
asked = answer_store.action(OWNER, "resume", question_id="quiz:t:1", question_version="v1",
snapshot={"question": "Prove", "type": "open-ended"})["attempt"]["id"]
async def check():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test",
headers={"Authorization": f"Bearer {OWNER}"}) as c:
direct = (await c.post(f"/api/math/answer-attempts/{own}/scans", json={"renew": True, "direct": True})).json()
phone = (await c.post(f"/api/math/answer-attempts/{asked}/scans", json={"renew": True})).json()
listed = (await c.get("/api/math/answer-scans")).json()
return direct, phone, listed
direct, phone, listed = asyncio.run(check())
assert len(sent) == 1 and phone["scan"]["code"] in sent[0]["data"]["body"] # only the request for the phone rang
assert [r["scan"]["id"] for r in listed] == [phone["scan"]["id"]] # and only it waits for a phone
assert direct["scan"]["state"] == "waiting" # the device's own request works as before