add astra draft
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["src"]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q"
|
||||
@@ -0,0 +1,9 @@
|
||||
-r requirements.txt
|
||||
certifi==2026.5.20
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
iniconfig==2.3.0
|
||||
packaging==25.0
|
||||
pluggy==1.6.0
|
||||
pygments==2.20.0
|
||||
pytest==9.0.2
|
||||
@@ -0,0 +1,15 @@
|
||||
# Exact versions used by the local test run; update and retest as a set.
|
||||
annotated-doc==0.0.4
|
||||
annotated-types==0.7.0
|
||||
anyio==4.13.0
|
||||
click==8.1.8
|
||||
fastapi==0.128.2
|
||||
h11==0.16.0
|
||||
idna==3.17
|
||||
pydantic==2.13.4
|
||||
pydantic-core==2.46.4
|
||||
starlette==0.50.0
|
||||
typing-extensions==4.16.0
|
||||
typing-inspection==0.4.2
|
||||
tzdata==2026.2
|
||||
uvicorn==0.48.0
|
||||
@@ -0,0 +1,364 @@
|
||||
"""Small single-household API. Device credentials never grant parent privileges."""
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
import uuid
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import ValidationError
|
||||
|
||||
from .models import (ChildCreate, DeviceStatus, Login, OverrideUpdate, PairInput, Policy,
|
||||
PolicyUpdate, SecretInput, Submission)
|
||||
from .store import Store, earned, effective_policy
|
||||
|
||||
|
||||
def digest(value: str) -> str:
|
||||
return hashlib.sha256(value.encode()).hexdigest()
|
||||
|
||||
|
||||
def new_id() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
admin_token: str
|
||||
database: str = "data/apc.sqlite3"
|
||||
origin: str = "http://localhost:8000"
|
||||
|
||||
def __post_init__(self):
|
||||
if not 32 <= len(self.admin_token) <= 256 or self.admin_token.startswith("CHANGE_ME"):
|
||||
raise ValueError("APC_ADMIN_TOKEN must be a randomly generated 32+ character secret")
|
||||
parsed = urlsplit(self.origin)
|
||||
if (not parsed.hostname or parsed.username or parsed.password or parsed.query
|
||||
or parsed.fragment or parsed.path not in ("", "/")):
|
||||
raise ValueError("APC_ORIGIN must be an origin, without credentials or a path")
|
||||
if parsed.scheme != "https" and not (
|
||||
parsed.scheme == "http" and parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
):
|
||||
raise ValueError("HTTPS is required except for loopback development")
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None, clock=time.time) -> FastAPI:
|
||||
settings = settings or Settings(
|
||||
admin_token=os.environ.get("APC_ADMIN_TOKEN", ""),
|
||||
database=os.environ.get("APC_DATABASE", "data/apc.sqlite3"),
|
||||
origin=os.environ.get("APC_ORIGIN", "http://localhost:8000"),
|
||||
)
|
||||
store = Store(settings.database)
|
||||
app = FastAPI(title="Advanced Parental Controls", version="0.1.0", docs_url=None,
|
||||
redoc_url=None)
|
||||
app.state.store = store
|
||||
static = Path(__file__).parent / "static"
|
||||
secure_cookie = settings.origin.startswith("https:")
|
||||
session_name = "apc_session"
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_headers(request: Request, call_next):
|
||||
# Limits normal accidental payloads. The reverse proxy must also cap streaming bodies.
|
||||
try:
|
||||
length = int(request.headers.get("content-length", "0") or "0")
|
||||
except ValueError:
|
||||
return JSONResponse({"detail": "Invalid content length"}, status_code=400)
|
||||
if length < 0:
|
||||
return JSONResponse({"detail": "Invalid content length"}, status_code=400)
|
||||
if length > 32768:
|
||||
return JSONResponse({"detail": "Request too large"}, status_code=413)
|
||||
response = await call_next(request)
|
||||
response.headers.update({
|
||||
"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "no-referrer", "X-Frame-Options": "DENY",
|
||||
"Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self'; "
|
||||
"frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
|
||||
})
|
||||
return response
|
||||
|
||||
def session_digest(value: str) -> str:
|
||||
# Rotating the admin secret also invalidates existing browser sessions.
|
||||
return hmac.new(settings.admin_token.encode(), value.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
def check_origin(request: Request, required=False):
|
||||
origin = request.headers.get("origin")
|
||||
if (required or origin is not None) and origin != settings.origin.rstrip("/"):
|
||||
raise HTTPException(403, "Origin rejected")
|
||||
|
||||
def bearer(request: Request) -> str:
|
||||
scheme, _, value = request.headers.get("authorization", "").partition(" ")
|
||||
return value if scheme.lower() == "bearer" else ""
|
||||
|
||||
def parent(request: Request):
|
||||
token = bearer(request)
|
||||
if token and hmac.compare_digest(digest(token), digest(settings.admin_token)):
|
||||
check_origin(request)
|
||||
return
|
||||
with store.transaction() as db:
|
||||
valid = db.execute("SELECT 1 FROM sessions WHERE hash=? AND expires>?",
|
||||
(session_digest(request.cookies.get(session_name, "")), clock())).fetchone()
|
||||
if not valid:
|
||||
raise HTTPException(401, "Parent authentication required")
|
||||
if request.method not in ("GET", "HEAD"):
|
||||
check_origin(request, required=True)
|
||||
|
||||
def device(request: Request):
|
||||
token = bearer(request)
|
||||
with store.transaction() as db:
|
||||
row = db.execute("SELECT * FROM devices WHERE token_hash=? AND revoked=0",
|
||||
(digest(token),)).fetchone() if token else None
|
||||
if row is None:
|
||||
raise HTTPException(401, "Device not paired or revoked")
|
||||
return dict(row)
|
||||
|
||||
def day_for(policy: Policy):
|
||||
return datetime.fromtimestamp(clock(), ZoneInfo(policy.timezone)).date().isoformat()
|
||||
|
||||
def revision_matches(db, expected):
|
||||
current = db.execute("SELECT revision FROM family WHERE id=1").fetchone()[0]
|
||||
if current != expected:
|
||||
raise HTTPException(409, "Policy changed; refresh before saving")
|
||||
|
||||
def child_exists(db, child_id):
|
||||
if db.execute("SELECT 1 FROM children WHERE id=?", (child_id,)).fetchone() is None:
|
||||
raise HTTPException(404, "Child not found")
|
||||
|
||||
def issue_code(db, kind, subject):
|
||||
code = secrets.token_urlsafe(18)
|
||||
expiry = clock() + 600
|
||||
db.execute("DELETE FROM codes WHERE expires<=? OR (kind=? AND subject=?)",
|
||||
(clock(), kind, subject))
|
||||
db.execute("INSERT INTO codes VALUES (?,?,?,?)", (digest(code), kind, subject, expiry))
|
||||
return {"code": code, "expires_at": expiry}
|
||||
|
||||
def consume_code(db, code, kind, subject=None):
|
||||
row = db.execute("SELECT * FROM codes WHERE hash=? AND kind=? AND expires>?",
|
||||
(digest(code), kind, clock())).fetchone()
|
||||
if row is None or (subject is not None and row["subject"] != subject):
|
||||
raise HTTPException(403, "Invalid or expired code")
|
||||
db.execute("DELETE FROM codes WHERE hash=?", (digest(code),))
|
||||
return row["subject"]
|
||||
|
||||
@app.get("/healthz")
|
||||
def health():
|
||||
return {"status": "ok", "version": "0.1.0"}
|
||||
|
||||
@app.post("/v1/session")
|
||||
def login(body: Login, request: Request, response: Response):
|
||||
check_origin(request)
|
||||
if not hmac.compare_digest(digest(body.token), digest(settings.admin_token)):
|
||||
raise HTTPException(401, "Invalid parent token")
|
||||
token = secrets.token_urlsafe(32)
|
||||
with store.transaction() as db:
|
||||
db.execute("DELETE FROM sessions WHERE expires<=?", (clock(),))
|
||||
db.execute("INSERT INTO sessions VALUES (?,?)", (session_digest(token), clock() + 43200))
|
||||
response.set_cookie(session_name, token, max_age=43200, httponly=True,
|
||||
secure=secure_cookie, samesite="strict")
|
||||
return {"authenticated": True}
|
||||
|
||||
@app.delete("/v1/session", dependencies=[Depends(parent)])
|
||||
def logout(request: Request, response: Response):
|
||||
with store.transaction() as db:
|
||||
db.execute("DELETE FROM sessions WHERE hash=?",
|
||||
(session_digest(request.cookies.get(session_name, "")),))
|
||||
response.delete_cookie(session_name, secure=secure_cookie, httponly=True, samesite="strict")
|
||||
return {"authenticated": False}
|
||||
|
||||
@app.get("/v1/parent/family", dependencies=[Depends(parent)])
|
||||
def family():
|
||||
with store.transaction() as db:
|
||||
f = db.execute("SELECT * FROM family WHERE id=1").fetchone()
|
||||
children = []
|
||||
for c in db.execute("SELECT * FROM children ORDER BY name, id").fetchall():
|
||||
_, policy = effective_policy(db, c["id"])
|
||||
d = db.execute("SELECT id,name,last_seen,status FROM devices "
|
||||
"WHERE child_id=? AND revoked=0", (c["id"],)).fetchone()
|
||||
info = dict(d) if d else None
|
||||
if info:
|
||||
info["status"] = json.loads(info["status"]) if info["status"] else None
|
||||
children.append({"id": c["id"], "name": c["name"],
|
||||
"overrides": json.loads(c["overrides"]),
|
||||
"effective_policy": policy.model_dump(), "device": info,
|
||||
"earned_minutes": min(earned(db, c["id"], day_for(policy)),
|
||||
policy.max_bonus_minutes)})
|
||||
return {"revision": f["revision"], "policy": json.loads(f["policy"]),
|
||||
"children": children}
|
||||
|
||||
@app.put("/v1/parent/policy", dependencies=[Depends(parent)])
|
||||
def update_policy(body: PolicyUpdate):
|
||||
with store.transaction() as db:
|
||||
revision_matches(db, body.expected_revision)
|
||||
try:
|
||||
for c in db.execute("SELECT overrides FROM children").fetchall():
|
||||
Policy.model_validate(body.policy.model_dump() | json.loads(c[0]))
|
||||
except ValidationError:
|
||||
raise HTTPException(422, "New policy conflicts with a child's overrides") from None
|
||||
# Timezone changes could mint a second 'today'. Keep it fixed once rewards exist.
|
||||
old = Policy.model_validate_json(db.execute("SELECT policy FROM family").fetchone()[0])
|
||||
if body.policy.timezone != old.timezone and db.execute("SELECT 1 FROM rewards LIMIT 1").fetchone():
|
||||
raise HTTPException(409, "Timezone changes after rewards exist are deferred in v0")
|
||||
db.execute("UPDATE family SET policy=?, revision=revision+1 WHERE id=1",
|
||||
(body.policy.model_dump_json(),))
|
||||
return {"revision": body.expected_revision + 1}
|
||||
|
||||
@app.post("/v1/parent/children", dependencies=[Depends(parent)], status_code=201)
|
||||
def add_child(body: ChildCreate):
|
||||
child_id = new_id()
|
||||
with store.transaction() as db:
|
||||
db.execute("INSERT INTO children(id,name) VALUES (?,?)", (child_id, body.name))
|
||||
return {"id": child_id, "name": body.name}
|
||||
|
||||
@app.put("/v1/parent/children/{child_id}/overrides", dependencies=[Depends(parent)])
|
||||
def overrides(child_id: str, body: OverrideUpdate):
|
||||
with store.transaction() as db:
|
||||
child_exists(db, child_id)
|
||||
revision_matches(db, body.expected_revision)
|
||||
if "timezone" in body.overrides:
|
||||
raise HTTPException(422, "Timezone belongs to the family, not child overrides")
|
||||
base = json.loads(db.execute("SELECT policy FROM family WHERE id=1").fetchone()[0])
|
||||
try:
|
||||
Policy.model_validate(base | body.overrides)
|
||||
except ValidationError:
|
||||
raise HTTPException(422, "Invalid override fields or values") from None
|
||||
db.execute("UPDATE children SET overrides=? WHERE id=?",
|
||||
(json.dumps(body.overrides), child_id))
|
||||
db.execute("UPDATE family SET revision=revision+1 WHERE id=1")
|
||||
return {"revision": body.expected_revision + 1}
|
||||
|
||||
@app.post("/v1/parent/children/{child_id}/pairing-code", dependencies=[Depends(parent)])
|
||||
def pairing_code(child_id: str):
|
||||
with store.transaction() as db:
|
||||
child_exists(db, child_id)
|
||||
if db.execute("SELECT 1 FROM devices WHERE child_id=? AND revoked=0", (child_id,)).fetchone():
|
||||
raise HTTPException(409, "One active iPhone per child in v0; revoke the old one first")
|
||||
return issue_code(db, "pair", child_id)
|
||||
|
||||
@app.post("/v1/parent/devices/{device_id}/configuration-code", dependencies=[Depends(parent)])
|
||||
def configuration_code(device_id: str):
|
||||
with store.transaction() as db:
|
||||
if not db.execute("SELECT 1 FROM devices WHERE id=? AND revoked=0", (device_id,)).fetchone():
|
||||
raise HTTPException(404, "Active device not found")
|
||||
return issue_code(db, "configure", device_id)
|
||||
|
||||
@app.delete("/v1/parent/devices/{device_id}", dependencies=[Depends(parent)])
|
||||
def revoke(device_id: str):
|
||||
with store.transaction() as db:
|
||||
db.execute("UPDATE devices SET revoked=1 WHERE id=?", (device_id,))
|
||||
db.execute("DELETE FROM codes WHERE subject=?", (device_id,))
|
||||
return {"revoked": True}
|
||||
|
||||
@app.post("/v1/pair", status_code=201)
|
||||
def pair(body: PairInput):
|
||||
with store.transaction() as db:
|
||||
child_id = consume_code(db, body.code, "pair")
|
||||
if db.execute("SELECT 1 FROM devices WHERE child_id=? AND revoked=0", (child_id,)).fetchone():
|
||||
raise HTTPException(409, "Child already has an active device")
|
||||
device_id, token = new_id(), secrets.token_urlsafe(32)
|
||||
db.execute("INSERT INTO devices(id,child_id,name,token_hash) VALUES (?,?,?,?)",
|
||||
(device_id, child_id, body.name, digest(token)))
|
||||
return {"device_id": device_id, "child_id": child_id, "token": token}
|
||||
|
||||
@app.post("/v1/device/configuration-unlock")
|
||||
def unlock(body: SecretInput, d=Depends(device)):
|
||||
with store.transaction() as db:
|
||||
consume_code(db, body.code, "configure", d["id"])
|
||||
return {"authorized": True}
|
||||
|
||||
@app.get("/v1/device/snapshot")
|
||||
def snapshot(d=Depends(device)):
|
||||
with store.transaction() as db:
|
||||
revision, policy = effective_policy(db, d["child_id"])
|
||||
day = day_for(policy)
|
||||
name = db.execute("SELECT name FROM children WHERE id=?", (d["child_id"],)).fetchone()[0]
|
||||
return {"schema_version": 1, "device_id": d["id"], "child_id": d["child_id"],
|
||||
"child_name": name, "policy_revision": revision, "policy": policy.model_dump(),
|
||||
"day": day, "earned_minutes": min(earned(db, d["child_id"], day),
|
||||
policy.max_bonus_minutes), "server_time": clock()}
|
||||
|
||||
@app.post("/v1/device/status")
|
||||
def status(body: DeviceStatus, d=Depends(device)):
|
||||
with store.transaction() as db:
|
||||
db.execute("UPDATE devices SET status=?,last_seen=? WHERE id=?",
|
||||
(body.model_dump_json(), clock(), d["id"]))
|
||||
return {"received": True}
|
||||
|
||||
def public_challenge(row):
|
||||
return {"id": row["id"], "expires_at": row["expires"],
|
||||
"questions": [{"id": q["id"], "prompt": q["prompt"]}
|
||||
for q in json.loads(row["questions"])]}
|
||||
|
||||
@app.post("/v1/device/challenges", status_code=201)
|
||||
def challenge(d=Depends(device)):
|
||||
with store.transaction() as db:
|
||||
revision, policy = effective_policy(db, d["child_id"])
|
||||
day = day_for(policy)
|
||||
if earned(db, d["child_id"], day) >= policy.max_bonus_minutes:
|
||||
raise HTTPException(409, "Today's bonus cap has been reached")
|
||||
pending = db.execute("SELECT * FROM challenges WHERE device_id=? AND day=? "
|
||||
"AND revision=? AND expires>? AND result IS NULL ORDER BY expires DESC LIMIT 1",
|
||||
(d["id"], day, revision, clock())).fetchone()
|
||||
if pending:
|
||||
return public_challenge(pending)
|
||||
count = db.execute("SELECT COUNT(*) FROM challenges WHERE device_id=? AND day=?",
|
||||
(d["id"], day)).fetchone()[0]
|
||||
if count >= 100:
|
||||
raise HTTPException(429, "Daily challenge attempt limit reached")
|
||||
questions = []
|
||||
for _ in range(policy.correct_answers):
|
||||
a, b = secrets.randbelow(policy.max_operand + 1), secrets.randbelow(policy.max_operand + 1)
|
||||
op = secrets.choice(("+", "-"))
|
||||
if op == "-":
|
||||
a, b = max(a, b), min(a, b)
|
||||
questions.append({"id": new_id(), "prompt": f"{a} {op} {b}",
|
||||
"answer": a + b if op == "+" else a - b})
|
||||
challenge_id = new_id()
|
||||
db.execute("INSERT INTO challenges VALUES (?,?,?,?,?,?,?,NULL)",
|
||||
(challenge_id, d["id"], d["child_id"], day, revision, clock() + 600,
|
||||
json.dumps(questions)))
|
||||
row = db.execute("SELECT * FROM challenges WHERE id=?", (challenge_id,)).fetchone()
|
||||
return public_challenge(row)
|
||||
|
||||
@app.post("/v1/device/challenges/{challenge_id}/submit")
|
||||
def submit(challenge_id: str, body: Submission, d=Depends(device)):
|
||||
with store.transaction() as db:
|
||||
row = db.execute("SELECT * FROM challenges WHERE id=? AND device_id=?",
|
||||
(challenge_id, d["id"])).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Challenge not found")
|
||||
if row["result"] is not None:
|
||||
return json.loads(row["result"]) # Durable idempotency, including failed attempts.
|
||||
revision, policy = effective_policy(db, d["child_id"])
|
||||
day = day_for(policy)
|
||||
if row["expires"] <= clock() or row["day"] != day or row["revision"] != revision:
|
||||
raise HTTPException(409, "Challenge expired or policy changed; start a new challenge")
|
||||
questions = json.loads(row["questions"])
|
||||
answers = {a.question_id: a.answer for a in body.answers}
|
||||
if len(answers) != len(body.answers) or set(answers) != {q["id"] for q in questions}:
|
||||
raise HTTPException(422, "Answer each question exactly once")
|
||||
correct = sum(answers[q["id"]] == q["answer"] for q in questions)
|
||||
passed = correct == len(questions)
|
||||
minutes = max(0, min(policy.bonus_minutes,
|
||||
policy.max_bonus_minutes - earned(db, d["child_id"], day))) if passed else 0
|
||||
reward_id = new_id() if minutes else None
|
||||
if reward_id:
|
||||
db.execute("INSERT INTO rewards VALUES (?,?,?,?,?,?)",
|
||||
(reward_id, challenge_id, d["child_id"], day, minutes, clock()))
|
||||
result = {"challenge_id": challenge_id, "passed": passed, "correct_count": correct,
|
||||
"required_count": len(questions), "awarded_minutes": minutes,
|
||||
"reward_id": reward_id, "day": day}
|
||||
db.execute("UPDATE challenges SET result=? WHERE id=?", (json.dumps(result), challenge_id))
|
||||
return result
|
||||
|
||||
@app.get("/")
|
||||
def dashboard():
|
||||
return FileResponse(static / "index.html")
|
||||
|
||||
app.mount("/static", StaticFiles(directory=static), name="static")
|
||||
return app
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Version-one wire contract. Examples are product defaults, not clinical guidance."""
|
||||
from typing import Any, Literal
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
|
||||
class Model(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", strict=True)
|
||||
|
||||
|
||||
class Policy(Model):
|
||||
timezone: str = "Europe/Berlin"
|
||||
daily_minutes: int = Field(default=30, ge=0, le=240)
|
||||
bonus_minutes: int = Field(default=10, ge=5, le=30)
|
||||
max_bonus_minutes: int = Field(default=30, ge=0, le=60)
|
||||
correct_answers: int = Field(default=5, ge=1, le=20)
|
||||
max_operand: int = Field(default=20, ge=2, le=100)
|
||||
allowed_start: int = Field(default=420, ge=0, le=1439)
|
||||
allowed_end: int = Field(default=1200, ge=1, le=1439)
|
||||
|
||||
@field_validator("timezone")
|
||||
@classmethod
|
||||
def valid_timezone(cls, value: str) -> str:
|
||||
try:
|
||||
ZoneInfo(value)
|
||||
except (ValueError, ZoneInfoNotFoundError) as exc:
|
||||
raise ValueError("Use an IANA timezone, e.g. Europe/Berlin") from exc
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_window(self):
|
||||
if self.allowed_end - self.allowed_start < 60:
|
||||
raise ValueError("v0 requires one same-day allowed window of at least 60 minutes")
|
||||
return self
|
||||
|
||||
|
||||
class PolicyUpdate(Model):
|
||||
expected_revision: int = Field(ge=1)
|
||||
policy: Policy
|
||||
|
||||
|
||||
class ChildCreate(Model):
|
||||
name: str = Field(min_length=1, max_length=60)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def clean_name(cls, value: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError("Name must not be blank")
|
||||
return value.strip()
|
||||
|
||||
|
||||
class OverrideUpdate(Model):
|
||||
expected_revision: int = Field(ge=1)
|
||||
overrides: dict[str, Any]
|
||||
|
||||
|
||||
class SecretInput(Model):
|
||||
code: str = Field(min_length=16, max_length=256)
|
||||
|
||||
|
||||
class PairInput(SecretInput):
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
|
||||
|
||||
class Login(Model):
|
||||
token: str = Field(min_length=32, max_length=256)
|
||||
|
||||
|
||||
class Answer(Model):
|
||||
question_id: str = Field(max_length=64)
|
||||
answer: int = Field(ge=-10000, le=10000)
|
||||
|
||||
|
||||
class Submission(Model):
|
||||
answers: list[Answer] = Field(min_length=1, max_length=20)
|
||||
|
||||
|
||||
class DeviceStatus(Model):
|
||||
policy_revision: int = Field(ge=0)
|
||||
state: Literal["monitoring", "not_authorized", "not_configured", "error"]
|
||||
shielded: bool
|
||||
detail: str = Field(max_length=500)
|
||||
consumed_lower_bound: int = Field(ge=0, le=10000)
|
||||
usage_day: str = Field(max_length=10)
|
||||
@@ -0,0 +1,172 @@
|
||||
"use strict";
|
||||
const $ = (s) => document.querySelector(s);
|
||||
let family;
|
||||
const fields = [
|
||||
["daily_minutes", "Daily entertainment minutes", 0, 240],
|
||||
["bonus_minutes", "Minutes per completed challenge", 5, 30],
|
||||
["max_bonus_minutes", "Daily bonus cap", 0, 60],
|
||||
["correct_answers", "Correct answers per challenge", 1, 20],
|
||||
["max_operand", "Largest number in a question", 2, 100],
|
||||
["allowed_start", "Allowed from", "time"],
|
||||
["allowed_end", "Allowed until", "time"],
|
||||
["timezone", "Family timezone", "text"],
|
||||
];
|
||||
function node(tag, text, cls) {
|
||||
const el = document.createElement(tag);
|
||||
if (text !== undefined) el.textContent = text;
|
||||
if (cls) el.className = cls;
|
||||
return el;
|
||||
}
|
||||
function message(text, error = false) {
|
||||
$("#message").textContent = text;
|
||||
$("#message").className = error ? "error" : "";
|
||||
}
|
||||
async function api(path, method = "GET", body) {
|
||||
const response = await fetch(path, {
|
||||
method, credentials: "same-origin", headers: {"Content-Type": "application/json"},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) setSignedIn(false);
|
||||
throw new Error(typeof result.detail === "string" ? result.detail : "Check the form values and try again.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function setSignedIn(value) {
|
||||
$("#login-panel").hidden = value;
|
||||
$("#dashboard").hidden = !value;
|
||||
$("#actions").hidden = !value;
|
||||
if (!value) { family = undefined; $("#children").replaceChildren(); }
|
||||
}
|
||||
function asTime(value) {
|
||||
return `${String(Math.floor(value / 60)).padStart(2, "0")}:${String(value % 60).padStart(2, "0")}`;
|
||||
}
|
||||
function buildFields(target, values, inherited) {
|
||||
target.replaceChildren();
|
||||
for (const [key, title, min, max] of fields) {
|
||||
if (inherited && key === "timezone") continue;
|
||||
const label = node("label", title);
|
||||
const input = node("input");
|
||||
input.name = key;
|
||||
input.type = typeof min === "string" ? min : "number";
|
||||
if (typeof min === "number") { input.min = min; input.max = max; input.step = 1; }
|
||||
input.required = !inherited;
|
||||
const present = Object.hasOwn(values, key);
|
||||
input.value = present ? (min === "time" ? asTime(values[key]) : values[key]) : "";
|
||||
label.append(input);
|
||||
if (inherited) label.append(node("small", `Blank inherits: ${min === "time" ? asTime(inherited[key]) : inherited[key]}`));
|
||||
target.append(label);
|
||||
}
|
||||
}
|
||||
function readFields(form) {
|
||||
const values = {};
|
||||
for (const [key, , type] of fields) {
|
||||
const input = form.elements.namedItem(key);
|
||||
if (!input || input.value === "") continue;
|
||||
values[key] = type === "time"
|
||||
? input.value.split(":").reduce((h, m) => Number(h) * 60 + Number(m))
|
||||
: type === "text" ? input.value.trim() : Number(input.value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
async function run(button, operation) {
|
||||
button.disabled = true;
|
||||
try { await operation(); } catch (error) { message(error.message, true); }
|
||||
finally { button.disabled = false; }
|
||||
}
|
||||
async function showCode(path, configuring) {
|
||||
const result = await api(path, "POST");
|
||||
$("#code-title").textContent = configuring ? "Allow app-selection changes" : "Pair a child's iPhone";
|
||||
$("#issued-code").textContent = result.code;
|
||||
$("#code-help").textContent = configuring
|
||||
? "Enter this in Parent setup on the child's app. It allows one app-selection session."
|
||||
: "On the child's phone, enter this server's HTTPS address and this code. A guardian must also approve Apple's Family Controls prompt.";
|
||||
$("#code-dialog").showModal();
|
||||
}
|
||||
function renderChild(child) {
|
||||
const card = node("article", undefined, "child");
|
||||
card.append(node("h3", child.name));
|
||||
const p = child.effective_policy;
|
||||
card.append(node("p", `${p.daily_minutes} daily minutes + ${child.earned_minutes} earned today · bonus cap ${p.max_bonus_minutes}`));
|
||||
const device = child.device;
|
||||
const status = device?.status;
|
||||
const applied = status?.policy_revision === family.revision && status.state === "monitoring";
|
||||
card.append(node("p", !device ? "No phone paired" : applied
|
||||
? `Last reported: monitoring revision ${status.policy_revision}${status.shielded ? " · shielded" : ""}`
|
||||
: `Pending / needs attention: ${status?.detail || "phone has not reported configuration"}`, "status"));
|
||||
if (device?.last_seen) card.append(node("small", `Last report: ${new Date(device.last_seen * 1000).toLocaleString()}`));
|
||||
const actions = node("div", undefined, "buttons");
|
||||
const code = node("button", device ? "Authorize app selection" : "Pair phone");
|
||||
code.type = "button";
|
||||
code.addEventListener("click", () => run(code, () => showCode(device
|
||||
? `/v1/parent/devices/${device.id}/configuration-code`
|
||||
: `/v1/parent/children/${child.id}/pairing-code`, !!device)));
|
||||
actions.append(code);
|
||||
if (device) {
|
||||
const revoke = node("button", "Revoke pairing", "secondary");
|
||||
revoke.type = "button";
|
||||
revoke.addEventListener("click", () => run(revoke, async () => {
|
||||
if (!confirm("Revoke API access? This does not remotely remove shields from an offline phone. Guardian removal may be needed.")) return;
|
||||
await api(`/v1/parent/devices/${device.id}`, "DELETE"); await refresh();
|
||||
}));
|
||||
actions.append(revoke);
|
||||
}
|
||||
card.append(actions);
|
||||
const details = node("details");
|
||||
details.append(node("summary", "Individual exceptions"));
|
||||
const form = node("form");
|
||||
const controls = node("div", undefined, "fields");
|
||||
buildFields(controls, child.overrides, family.policy);
|
||||
const save = node("button", "Save exceptions");
|
||||
form.append(controls, save);
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
run(save, async () => {
|
||||
await api(`/v1/parent/children/${child.id}/overrides`, "PUT", {
|
||||
expected_revision: family.revision, overrides: readFields(form),
|
||||
});
|
||||
await refresh(); message("Exceptions saved. The phone will apply them at its next sync.");
|
||||
});
|
||||
});
|
||||
details.append(form); card.append(details); return card;
|
||||
}
|
||||
async function refresh() {
|
||||
family = await api("/v1/parent/family");
|
||||
setSignedIn(true);
|
||||
$("#revision").textContent = `Revision ${family.revision}`;
|
||||
buildFields($("#policy-fields"), family.policy);
|
||||
$("#children").replaceChildren(...family.children.map(renderChild));
|
||||
}
|
||||
$("#login").addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
run(form.querySelector("button"), async () => {
|
||||
const token = form.elements.token.value;
|
||||
form.reset();
|
||||
await api("/v1/session", "POST", {token}); await refresh(); message("");
|
||||
});
|
||||
});
|
||||
$("#policy").addEventListener("submit", (event) => {
|
||||
event.preventDefault(); const form = event.currentTarget;
|
||||
run(form.querySelector("button"), async () => {
|
||||
await api("/v1/parent/policy", "PUT", {expected_revision: family.revision, policy: readFields(form)});
|
||||
await refresh(); message("Shared policy saved. Devices apply it at their next sync.");
|
||||
});
|
||||
});
|
||||
$("#add-child").addEventListener("submit", (event) => {
|
||||
event.preventDefault(); const form = event.currentTarget;
|
||||
run(form.querySelector("button"), async () => {
|
||||
await api("/v1/parent/children", "POST", {name: form.elements.name.value}); form.reset(); await refresh();
|
||||
});
|
||||
});
|
||||
$("#refresh").addEventListener("click", (event) => run(event.currentTarget, refresh));
|
||||
$("#logout").addEventListener("click", (event) => run(event.currentTarget, async () => {
|
||||
await api("/v1/session", "DELETE"); setSignedIn(false); message("");
|
||||
}));
|
||||
$("#copy-code").addEventListener("click", (event) => run(event.currentTarget, async () => {
|
||||
await navigator.clipboard.writeText($("#issued-code").textContent);
|
||||
}));
|
||||
$("#close-code").addEventListener("click", () => $("#code-dialog").close());
|
||||
$("#code-dialog").addEventListener("close", () => { $("#issued-code").textContent = ""; });
|
||||
refresh().catch(() => setSignedIn(false));
|
||||
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Family controls</title><link rel="stylesheet" href="/static/style.css">
|
||||
<script defer src="/static/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header><div><span class="eyebrow">ADVANCED PARENTAL CONTROLS · DEVELOPMENT MVP</span>
|
||||
<h1>One family. Shared rules.</h1><p>Change a rule once. Give each child only the exceptions they need.</p></div>
|
||||
<div id="actions" hidden><button id="refresh" type="button">Refresh status</button>
|
||||
<button id="logout" type="button" class="secondary">Sign out</button></div></header>
|
||||
<main>
|
||||
<p id="message" role="status" aria-live="polite"></p>
|
||||
<section id="login-panel"><h2>Parent sign-in</h2>
|
||||
<p>Use the administration token configured on your server. Never put it on a child's phone.</p>
|
||||
<form id="login"><label>Administration token<input name="token" type="password" required minlength="32" autocomplete="current-password"></label>
|
||||
<button>Sign in</button></form></section>
|
||||
<div id="dashboard" hidden>
|
||||
<aside>Experimental enforcement: keep Apple's safety protections enabled. Phone updates apply when the child app opens or syncs; status is not live telemetry.</aside>
|
||||
<section><div class="section-heading"><h2>Shared family policy</h2><span id="revision"></span></div>
|
||||
<p>These values are inherited unless a child has an explicit override. The example limits are not age-based recommendations.</p>
|
||||
<form id="policy"><div id="policy-fields" class="fields"></div><button>Save shared policy</button></form></section>
|
||||
<section><h2>Children</h2><form id="add-child" class="inline"><label>Display name<input name="name" required maxlength="60" autocomplete="off"></label><button>Add child</button></form>
|
||||
<div id="children" class="children"></div></section>
|
||||
</div>
|
||||
</main>
|
||||
<dialog id="code-dialog"><h2 id="code-title">Pair phone</h2><p>This single-use code expires in ten minutes. Keep it private.</p>
|
||||
<p><code id="issued-code"></code></p><p id="code-help"></p><button id="copy-code" type="button">Copy code</button>
|
||||
<button id="close-code" type="button" class="secondary">Close</button></dialog>
|
||||
<footer>v0.1 · One household · One active iPhone per child · No app names or browsing history leave the phone</footer>
|
||||
</body></html>
|
||||
@@ -0,0 +1,24 @@
|
||||
:root { font-family: system-ui, sans-serif; color: #1c2938; background: #f3f6f8; line-height: 1.5; }
|
||||
* { box-sizing: border-box; } [hidden] { display: none !important; }
|
||||
body { margin: 0; } header, main, footer { max-width: 1120px; margin: auto; padding: 24px; }
|
||||
header { display: flex; justify-content: space-between; gap: 24px; align-items: center; padding-top: 42px; }
|
||||
h1 { font-size: clamp(1.8rem,4vw,2.6rem); line-height: 1.15; margin: 12px 0; letter-spacing: -.03em; }
|
||||
h2 { margin-top: 0; } h3 { margin-bottom: 8px; } p { margin-top: 8px; }
|
||||
.eyebrow { font-size: .73rem; letter-spacing: .11em; font-weight: 700; }
|
||||
section, .child { border: 1px solid #d8e1e8; padding: 24px; border-radius: 12px; background: white; margin-bottom: 22px; }
|
||||
.child { background: #fafcfd; margin-bottom: 0; } .children { display: grid; gap: 18px; }
|
||||
.fields { display: grid; grid-template-columns: repeat(auto-fit,minmax(210px,1fr)); gap: 16px; margin: 20px 0; }
|
||||
label { display: flex; flex-direction: column; font-weight: 600; font-size: .9rem; gap: 5px; }
|
||||
input { min-height: 44px; padding: 10px; border: 1px solid #aab8c4; border-radius: 6px; font: inherit; width: 100%; background: white; }
|
||||
button { border: 1px solid #183f53; border-radius: 6px; background: #183f53; color: white; min-height: 42px; padding: 9px 17px; font: inherit; cursor: pointer; }
|
||||
button.secondary { background: white; color: #183f53; } button:disabled { opacity: .55; cursor: progress; }
|
||||
button:focus-visible, input:focus-visible, summary:focus-visible { outline: 3px solid #6b9abd; outline-offset: 3px; }
|
||||
.inline, .buttons, .section-heading { display: flex; gap: 12px; align-items: end; flex-wrap: wrap; }
|
||||
.inline { margin-bottom: 24px; } .inline label { flex: 1; } .buttons { margin: 18px 0; } .section-heading { justify-content: space-between; align-items: center; }
|
||||
small, footer { color: #526477; } small { font-size: .8rem; font-weight: normal; } .status { font-weight: 600; }
|
||||
aside { background: #fff5dd; padding: 16px; border-left: 4px solid #ab7424; margin-bottom: 22px; }
|
||||
#message:empty { display: none; } #message { padding: 12px; border: 1px solid #a8bbc4; border-radius: 6px; }
|
||||
#message.error { color: #9c2433; border-color: #9c2433; } details { border-top: 1px solid #d8e1e8; padding-top: 16px; } summary { cursor: pointer; }
|
||||
dialog { width: min(560px,calc(100% - 32px)); border: 1px solid #aab8c4; border-radius: 12px; padding: 24px; } dialog::backdrop { background: #182f4599; }
|
||||
code { overflow-wrap: anywhere; font-size: 1.1rem; } #login { max-width: 480px; } #login button { margin-top: 16px; }
|
||||
@media(max-width:650px) { header { display: block; } main, header, footer { padding: 18px; } section, .child { padding: 18px; } }
|
||||
@@ -0,0 +1,80 @@
|
||||
"""SQLite transactions serialize reward issuance; no in-memory allowance ledger."""
|
||||
from contextlib import contextmanager
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
|
||||
from .models import Policy
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS family (
|
||||
id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL, policy TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS children (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, overrides TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id TEXT PRIMARY KEY, child_id TEXT NOT NULL REFERENCES children(id),
|
||||
name TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE,
|
||||
revoked INTEGER NOT NULL DEFAULT 0, last_seen REAL, status TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS one_active_device ON devices(child_id) WHERE revoked=0;
|
||||
CREATE TABLE IF NOT EXISTS codes (
|
||||
hash TEXT PRIMARY KEY, kind TEXT NOT NULL, subject TEXT NOT NULL, expires REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (hash TEXT PRIMARY KEY, expires REAL NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS challenges (
|
||||
id TEXT PRIMARY KEY, device_id TEXT NOT NULL REFERENCES devices(id),
|
||||
child_id TEXT NOT NULL REFERENCES children(id), day TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL, expires REAL NOT NULL, questions TEXT NOT NULL, result TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS rewards (
|
||||
id TEXT PRIMARY KEY, challenge_id TEXT NOT NULL UNIQUE REFERENCES challenges(id),
|
||||
child_id TEXT NOT NULL REFERENCES children(id), day TEXT NOT NULL,
|
||||
minutes INTEGER NOT NULL CHECK(minutes>0), created REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS reward_day ON rewards(child_id, day);
|
||||
CREATE INDEX IF NOT EXISTS challenge_device ON challenges(device_id, day);
|
||||
PRAGMA user_version=1;
|
||||
"""
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, path: str):
|
||||
self.path = path
|
||||
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with self.transaction() as db:
|
||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (0, 1):
|
||||
raise RuntimeError(f"Unsupported database version {version}; do not downgrade")
|
||||
db.executescript(SCHEMA)
|
||||
db.execute("INSERT OR IGNORE INTO family VALUES (1, 1, ?)",
|
||||
(Policy().model_dump_json(),))
|
||||
|
||||
@contextmanager
|
||||
def transaction(self):
|
||||
db = sqlite3.connect(self.path, timeout=10)
|
||||
db.row_factory = sqlite3.Row
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
db.execute("PRAGMA journal_mode=WAL")
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
yield db
|
||||
db.commit()
|
||||
except BaseException:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def effective_policy(db, child_id: str) -> tuple[int, Policy]:
|
||||
family = db.execute("SELECT * FROM family WHERE id=1").fetchone()
|
||||
child = db.execute("SELECT * FROM children WHERE id=?", (child_id,)).fetchone()
|
||||
values = json.loads(family["policy"]) | json.loads(child["overrides"])
|
||||
return family["revision"], Policy.model_validate(values)
|
||||
|
||||
|
||||
def earned(db, child_id: str, day: str) -> int:
|
||||
return db.execute("SELECT COALESCE(SUM(minutes),0) FROM rewards WHERE child_id=? AND day=?",
|
||||
(child_id, day)).fetchone()[0]
|
||||
@@ -0,0 +1,222 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
|
||||
from apc.api import Settings, create_app
|
||||
|
||||
TOKEN = "test-only-parent-token-with-more-than-32-characters"
|
||||
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path):
|
||||
clock = [datetime(2026, 9, 17, 10, tzinfo=timezone.utc).timestamp()]
|
||||
app = create_app(Settings(TOKEN, str(tmp_path / "test.db")), clock=lambda: clock[0])
|
||||
with TestClient(app) as client:
|
||||
yield client, app.state.store, clock
|
||||
|
||||
|
||||
def enroll(client, name="Example child"):
|
||||
child = client.post("/v1/parent/children", json={"name": name}, headers=HEADERS).json()
|
||||
code = client.post(f"/v1/parent/children/{child['id']}/pairing-code", headers=HEADERS).json()["code"]
|
||||
device = client.post("/v1/pair", json={"code": code, "name": "Test phone"}).json()
|
||||
return child, device, {"Authorization": f"Bearer {device['token']}"}
|
||||
|
||||
|
||||
def solve(challenge):
|
||||
result = []
|
||||
for question in challenge["questions"]:
|
||||
a, op, b = question["prompt"].split()
|
||||
result.append({"question_id": question["id"],
|
||||
"answer": int(a) + int(b) if op == "+" else int(a) - int(b)})
|
||||
return {"answers": result}
|
||||
|
||||
|
||||
def policy_update(client, **updates):
|
||||
f = client.get("/v1/parent/family", headers=HEADERS).json()
|
||||
return client.put("/v1/parent/policy", headers=HEADERS,
|
||||
json={"expected_revision": f["revision"], "policy": f["policy"] | updates})
|
||||
|
||||
|
||||
def test_parent_auth_and_device_scope(env):
|
||||
client, _, _ = env
|
||||
assert client.get("/v1/parent/family").status_code == 401
|
||||
child, _, headers = enroll(client)
|
||||
assert client.get("/v1/parent/family", headers=headers).status_code == 401
|
||||
assert client.get("/v1/device/snapshot", headers=HEADERS).status_code == 401
|
||||
assert client.get("/v1/device/snapshot", headers=headers).json()["child_id"] == child["id"]
|
||||
|
||||
|
||||
def test_inheritance_overrides_and_optimistic_concurrency(env):
|
||||
client, _, _ = env
|
||||
a, _, ha = enroll(client, "A")
|
||||
_, _, hb = enroll(client, "B")
|
||||
f = client.get("/v1/parent/family", headers=HEADERS).json()
|
||||
body = {"expected_revision": f["revision"], "overrides": {"daily_minutes": 45}}
|
||||
assert client.put(f"/v1/parent/children/{a['id']}/overrides", json=body, headers=HEADERS).status_code == 200
|
||||
assert client.put(f"/v1/parent/children/{a['id']}/overrides", json=body, headers=HEADERS).status_code == 409
|
||||
assert policy_update(client, daily_minutes=60, correct_answers=3).status_code == 200
|
||||
pa = client.get("/v1/device/snapshot", headers=ha).json()["policy"]
|
||||
pb = client.get("/v1/device/snapshot", headers=hb).json()["policy"]
|
||||
assert (pa["daily_minutes"], pb["daily_minutes"]) == (45, 60)
|
||||
assert pa["correct_answers"] == pb["correct_answers"] == 3
|
||||
revision = client.get("/v1/parent/family", headers=HEADERS).json()["revision"]
|
||||
assert client.put(f"/v1/parent/children/{a['id']}/overrides", headers=HEADERS,
|
||||
json={"expected_revision": revision, "overrides": {}}).status_code == 200
|
||||
assert client.get("/v1/device/snapshot", headers=ha).json()["policy"]["daily_minutes"] == 60
|
||||
|
||||
|
||||
def test_reward_is_server_graded_capped_and_idempotent(env):
|
||||
client, store, _ = env
|
||||
_, _, headers = enroll(client)
|
||||
assert policy_update(client, max_bonus_minutes=15).status_code == 200
|
||||
for expected in (10, 5):
|
||||
challenge = client.post("/v1/device/challenges", headers=headers).json()
|
||||
assert all("answer" not in q for q in challenge["questions"])
|
||||
same = client.post("/v1/device/challenges", headers=headers).json()
|
||||
assert same == challenge
|
||||
path = f"/v1/device/challenges/{challenge['id']}/submit"
|
||||
result = client.post(path, json=solve(challenge), headers=headers)
|
||||
assert result.json()["awarded_minutes"] == expected
|
||||
assert client.post(path, json=solve(challenge), headers=headers).json() == result.json()
|
||||
assert client.post("/v1/device/challenges", headers=headers).status_code == 409
|
||||
with store.transaction() as db:
|
||||
assert db.execute("SELECT COUNT(*), SUM(minutes) FROM rewards").fetchone()[:] == (2, 15)
|
||||
|
||||
|
||||
def test_concurrent_submission_issues_one_credit(env):
|
||||
client, store, _ = env
|
||||
_, _, headers = enroll(client)
|
||||
challenge = client.post("/v1/device/challenges", headers=headers).json()
|
||||
def submit(_):
|
||||
return client.post(f"/v1/device/challenges/{challenge['id']}/submit",
|
||||
headers=headers, json=solve(challenge)).json()
|
||||
with ThreadPoolExecutor(max_workers=6) as pool:
|
||||
results = list(pool.map(submit, range(12)))
|
||||
assert all(result == results[0] for result in results)
|
||||
with store.transaction() as db:
|
||||
assert db.execute("SELECT COUNT(*) FROM rewards").fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_bad_answers_fail_and_cannot_be_retried_into_credit(env):
|
||||
client, _, _ = env
|
||||
_, _, headers = enroll(client)
|
||||
ch = client.post("/v1/device/challenges", headers=headers).json()
|
||||
bad = solve(ch)
|
||||
bad["answers"][0]["answer"] += 1
|
||||
path = f"/v1/device/challenges/{ch['id']}/submit"
|
||||
result = client.post(path, headers=headers, json=bad).json()
|
||||
assert not result["passed"] and result["awarded_minutes"] == 0
|
||||
assert client.post(path, headers=headers, json=solve(ch)).json() == result
|
||||
|
||||
|
||||
def test_duplicate_question_and_cross_child_submission_rejected(env):
|
||||
client, _, _ = env
|
||||
_, _, ha = enroll(client, "A")
|
||||
_, _, hb = enroll(client, "B")
|
||||
ch = client.post("/v1/device/challenges", headers=ha).json()
|
||||
path = f"/v1/device/challenges/{ch['id']}/submit"
|
||||
answers = solve(ch)
|
||||
assert client.post(path, headers=hb, json=answers).status_code == 404
|
||||
answers["answers"][1] = answers["answers"][0]
|
||||
assert client.post(path, headers=ha, json=answers).status_code == 422
|
||||
|
||||
|
||||
def test_expiry_policy_change_and_local_midnight(env):
|
||||
client, _, clock = env
|
||||
_, _, h = enroll(client)
|
||||
def start(): return client.post("/v1/device/challenges", headers=h).json()
|
||||
def submit(ch): return client.post(f"/v1/device/challenges/{ch['id']}/submit", headers=h, json=solve(ch))
|
||||
ch = start(); clock[0] += 601
|
||||
assert submit(ch).status_code == 409
|
||||
ch = start(); policy_update(client, daily_minutes=40)
|
||||
assert submit(ch).status_code == 409
|
||||
ch = start(); assert submit(ch).json()["awarded_minutes"] == 10
|
||||
clock[0] = datetime(2026, 9, 17, 21, 59, tzinfo=timezone.utc).timestamp()
|
||||
ch = start(); clock[0] += 120 # Cross Berlin midnight, not UTC midnight.
|
||||
assert submit(ch).status_code == 409
|
||||
snapshot = client.get("/v1/device/snapshot", headers=h).json()
|
||||
assert snapshot["day"] == "2026-09-18" and snapshot["earned_minutes"] == 0
|
||||
|
||||
|
||||
def test_pairing_single_use_expiry_revocation_and_configuration_scope(env):
|
||||
client, _, clock = env
|
||||
a, device, h = enroll(client)
|
||||
assert client.post(f"/v1/parent/children/{a['id']}/pairing-code", headers=HEADERS).status_code == 409
|
||||
code = client.post(f"/v1/parent/devices/{device['device_id']}/configuration-code", headers=HEADERS).json()["code"]
|
||||
_, _, other = enroll(client, "Other")
|
||||
assert client.post("/v1/device/configuration-unlock", json={"code": code}, headers=other).status_code == 403
|
||||
assert client.post("/v1/device/configuration-unlock", json={"code": code}, headers=h).status_code == 200
|
||||
assert client.post("/v1/device/configuration-unlock", json={"code": code}, headers=h).status_code == 403
|
||||
client.delete(f"/v1/parent/devices/{device['device_id']}", headers=HEADERS)
|
||||
assert client.get("/v1/device/snapshot", headers=h).status_code == 401
|
||||
code = client.post(f"/v1/parent/children/{a['id']}/pairing-code", headers=HEADERS).json()["code"]
|
||||
clock[0] += 601
|
||||
assert client.post("/v1/pair", json={"code": code, "name": "New phone"}).status_code == 403
|
||||
|
||||
|
||||
def test_cookie_auth_csrf_headers_and_logout(env):
|
||||
client, _, _ = env
|
||||
result = client.post("/v1/session", json={"token": TOKEN}, headers={"Origin": "http://localhost:8000"})
|
||||
assert "HttpOnly" in result.headers["set-cookie"] and "SameSite=strict" in result.headers["set-cookie"]
|
||||
assert client.get("/v1/parent/family").status_code == 200
|
||||
assert client.post("/v1/parent/children", json={"name": "Test"}).status_code == 403
|
||||
assert client.post("/v1/parent/children", json={"name": "Test"}, headers={"Origin": "https://evil.test"}).status_code == 403
|
||||
assert client.delete("/v1/session", headers={"Origin": "http://localhost:8000"}).status_code == 200
|
||||
assert client.get("/v1/parent/family").status_code == 401
|
||||
assert "frame-ancestors 'none'" in client.get("/").headers["content-security-policy"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("update", [{"daily_minutes": -1}, {"daily_minutes": True},
|
||||
{"timezone": "Not/AZone"}, {"allowed_start": 1300},
|
||||
{"daily_minutes": "30"}, {"unexpected": 123}])
|
||||
def test_invalid_policy_rejected(env, update):
|
||||
assert policy_update(env[0], **update).status_code == 422
|
||||
|
||||
|
||||
def test_status_is_acknowledgement_not_server_usage_claim(env):
|
||||
client, store, _ = env
|
||||
_, device, headers = enroll(client)
|
||||
status = {"policy_revision": 1, "state": "not_configured", "shielded": True,
|
||||
"detail": "Select apps with guardian present", "consumed_lower_bound": 0,
|
||||
"usage_day": "2026-09-17"}
|
||||
assert client.post("/v1/device/status", headers=headers, json=status).status_code == 200
|
||||
family = client.get("/v1/parent/family", headers=HEADERS).json()
|
||||
assert family["children"][0]["device"]["status"] == status
|
||||
with store.transaction() as db:
|
||||
stored = db.execute("SELECT token_hash FROM devices WHERE id=?", (device["device_id"],)).fetchone()[0]
|
||||
assert stored != device["token"] and len(stored) == 64
|
||||
|
||||
|
||||
def test_configuration_validation_and_sqlite_persistence(env):
|
||||
client, store, _ = env
|
||||
_, _, headers = enroll(client)
|
||||
ch = client.post("/v1/device/challenges", headers=headers).json()
|
||||
client.post(f"/v1/device/challenges/{ch['id']}/submit", headers=headers, json=solve(ch))
|
||||
assert policy_update(client, timezone="UTC").status_code == 409
|
||||
with store.transaction() as db:
|
||||
assert json.loads(db.execute("SELECT result FROM challenges").fetchone()[0])["passed"]
|
||||
with pytest.raises(ValueError): Settings("too short")
|
||||
with pytest.raises(ValueError): Settings(TOKEN, origin="http://public.example")
|
||||
|
||||
|
||||
def test_session_rotation_and_restart_preserve_ledger(env):
|
||||
client, store, clock = env
|
||||
_, _, headers = enroll(client)
|
||||
ch = client.post("/v1/device/challenges", headers=headers).json()
|
||||
client.post(f"/v1/device/challenges/{ch['id']}/submit", headers=headers, json=solve(ch))
|
||||
client.post("/v1/session", json={"token": TOKEN})
|
||||
cookies = dict(client.cookies)
|
||||
restarted = create_app(Settings(TOKEN + "-rotated", store.path), clock=lambda: clock[0])
|
||||
with TestClient(restarted) as next_client:
|
||||
next_client.cookies.update(cookies)
|
||||
assert next_client.get("/v1/parent/family").status_code == 401
|
||||
assert next_client.get("/v1/device/snapshot", headers=headers).json()["earned_minutes"] == 10
|
||||
|
||||
|
||||
@pytest.mark.parametrize("length,expected", [("invalid", 400), ("-1", 400), ("40000", 413)])
|
||||
def test_request_length_validation(env, length, expected):
|
||||
assert env[0].post("/v1/pair", headers={"Content-Length": length}, content="{}").status_code == expected
|
||||
Reference in New Issue
Block a user