Harden Stork for public demo and family durability
Read-only demo boards, per-IP rate limits, opaque voter sessions, session leave, upload sniffing, path containment, and retire /v1.
This commit is contained in:
@@ -15,10 +15,15 @@ def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
monkeypatch.setenv("STORK_INVITE_TOKEN", "test-invite-token")
|
||||
monkeypatch.setenv("STORK_ADMIN_TOKEN", "test-admin-token")
|
||||
monkeypatch.setenv("STORK_COOKIE_SECURE", "false")
|
||||
monkeypatch.delenv("STORK_READONLY_BOARD_IDS", raising=False)
|
||||
monkeypatch.delenv("STORK_RL_BOARDS_PER_HOUR", raising=False)
|
||||
|
||||
import stork.app as app_mod
|
||||
import stork.rate_limit as rl_mod
|
||||
|
||||
importlib.reload(rl_mod)
|
||||
importlib.reload(app_mod)
|
||||
app_mod.limiter.reset()
|
||||
with TestClient(app_mod.app) as test_client:
|
||||
yield test_client
|
||||
app_mod.store.close()
|
||||
|
||||
+123
-25
@@ -2,8 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Minimal EBML/WebM header so upload sniffing accepts the fixture.
|
||||
WEBM_BYTES = b"\x1a\x45\xdf\xa3" + b"\x00" * 32
|
||||
|
||||
|
||||
def test_health(client: TestClient) -> None:
|
||||
res = client.get("/api/health")
|
||||
@@ -29,12 +34,19 @@ def test_v2_redirects_home(client: TestClient) -> None:
|
||||
assert res.headers["location"] == "/"
|
||||
|
||||
|
||||
def test_v1_redirects_home(client: TestClient) -> None:
|
||||
res = client.get("/v1", follow_redirects=False)
|
||||
assert res.status_code == 302
|
||||
assert res.headers["location"] == "/"
|
||||
|
||||
|
||||
def test_public_create_board_and_share_url(client: TestClient) -> None:
|
||||
created = client.post("/api/boards", json={"title": "Baby Levkin"})
|
||||
assert created.status_code == 200
|
||||
board_id = created.json()["id"]
|
||||
assert board_id.startswith("b_")
|
||||
assert created.json()["url"].endswith(f"/b/{board_id}")
|
||||
assert created.json()["readonly"] is False
|
||||
page = client.get(f"/b/{board_id}")
|
||||
assert page.status_code == 200
|
||||
assert b"Start a private board" in page.content or b"Your name" in page.content
|
||||
@@ -56,16 +68,10 @@ def test_boards_are_isolated(client: TestClient) -> None:
|
||||
b = client.post("/api/boards", json={"title": "B"}).json()
|
||||
client.post("/api/session", json={"board_id": a["id"], "display_name": "Ilia"})
|
||||
assert client.post("/api/names", json={"kind": "c1", "spelling": "Roze"}).status_code == 200
|
||||
a_names = client.get("/api/names?kind=c1").json()["items"]
|
||||
client.post("/api/session", json={"board_id": b["id"], "display_name": "Dan"})
|
||||
assert client.post("/api/names", json={"kind": "c1", "spelling": "Roze"}).status_code == 200
|
||||
a_names = client.get(
|
||||
"/api/names?kind=c1",
|
||||
headers={"X-Stork-Board": a["id"], "X-Stork-Display-Name": "Ilia"},
|
||||
).json()["items"]
|
||||
b_names = client.get(
|
||||
"/api/names?kind=c1",
|
||||
headers={"X-Stork-Board": b["id"], "X-Stork-Display-Name": "Dan"},
|
||||
).json()["items"]
|
||||
b_names = client.get("/api/names?kind=c1").json()["items"]
|
||||
assert len(a_names) == 1 and len(b_names) == 1
|
||||
assert a_names[0]["id"] != b_names[0]["id"]
|
||||
|
||||
@@ -112,13 +118,20 @@ def test_cannot_delete_last_column(authed: TestClient) -> None:
|
||||
def test_recording_upload_and_play(authed: TestClient) -> None:
|
||||
created = authed.post("/api/names", json={"kind": "c1", "spelling": "Roze"}).json()
|
||||
name_id = created["id"]
|
||||
files = {"file": ("voice.webm", b"fake-webm-bytes", "audio/webm")}
|
||||
files = {"file": ("voice.webm", WEBM_BYTES, "audio/webm")}
|
||||
up = authed.post(f"/api/names/{name_id}/recording/en", files=files)
|
||||
assert up.status_code == 200
|
||||
assert up.json()["recordings"]["en"] is True
|
||||
audio = authed.get(f"/api/names/{name_id}/recording/en")
|
||||
assert audio.status_code == 200
|
||||
assert audio.content == b"fake-webm-bytes"
|
||||
assert audio.content == WEBM_BYTES
|
||||
|
||||
|
||||
def test_recording_rejects_bad_magic(authed: TestClient) -> None:
|
||||
created = authed.post("/api/names", json={"kind": "c1", "spelling": "BadAudio"}).json()
|
||||
files = {"file": ("voice.webm", b"not-audio", "audio/webm")}
|
||||
up = authed.post(f"/api/names/{created['id']}/recording/en", files=files)
|
||||
assert up.status_code == 400
|
||||
|
||||
|
||||
def test_session_rejects_missing_board(client: TestClient) -> None:
|
||||
@@ -131,6 +144,45 @@ def test_names_require_session(client: TestClient) -> None:
|
||||
assert res.status_code in (401, 404)
|
||||
|
||||
|
||||
def test_header_name_alone_rejected(client: TestClient, family_board: dict) -> None:
|
||||
headers = {
|
||||
"X-Stork-Board": family_board["id"],
|
||||
"X-Stork-Display-Name": "Seed Bot",
|
||||
}
|
||||
res = client.post(
|
||||
"/api/names",
|
||||
headers=headers,
|
||||
json={"kind": "c1", "spelling": "HeaderOnly"},
|
||||
)
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
def test_header_auth_with_opaque_voter(client: TestClient, family_board: dict) -> None:
|
||||
headers = {
|
||||
"X-Stork-Board": family_board["id"],
|
||||
"X-Stork-Display-Name": "Seed Bot",
|
||||
"X-Stork-Voter": "opaque-voter-token-1",
|
||||
}
|
||||
res = client.post(
|
||||
"/api/names",
|
||||
headers=headers,
|
||||
json={"kind": "c1", "spelling": "HeaderOnly"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
listed = client.get("/api/names?kind=c1", headers=headers)
|
||||
assert any(n["spelling"] == "HeaderOnly" for n in listed.json()["items"])
|
||||
|
||||
|
||||
def test_clear_session(client: TestClient, family_board: dict) -> None:
|
||||
client.post(
|
||||
"/api/session",
|
||||
json={"board_id": family_board["id"], "display_name": "Ilia"},
|
||||
)
|
||||
assert client.get("/api/session").json()["authenticated"] is True
|
||||
assert client.delete("/api/session").status_code == 200
|
||||
assert client.get("/api/session").json()["authenticated"] is False
|
||||
|
||||
|
||||
def test_add_vote_and_rank(authed: TestClient) -> None:
|
||||
a = authed.post(
|
||||
"/api/names",
|
||||
@@ -180,22 +232,68 @@ def test_admin_delete_name(authed: TestClient) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_header_auth_with_board(client: TestClient, family_board: dict) -> None:
|
||||
headers = {
|
||||
"X-Stork-Board": family_board["id"],
|
||||
"X-Stork-Display-Name": "Seed Bot",
|
||||
}
|
||||
res = client.post(
|
||||
"/api/names",
|
||||
headers=headers,
|
||||
json={"kind": "c1", "spelling": "HeaderOnly"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
listed = client.get("/api/names?kind=c1", headers=headers)
|
||||
assert any(n["spelling"] == "HeaderOnly" for n in listed.json()["items"])
|
||||
|
||||
|
||||
def test_legacy_invite_resolve(client: TestClient, family_board: dict) -> None:
|
||||
res = client.get("/api/resolve", params={"invite": "test-invite-token"})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["id"] == family_board["id"]
|
||||
assert res.json()["readonly"] is False
|
||||
|
||||
|
||||
def test_readonly_board_blocks_mutations(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("STORK_DATA", str(tmp_path / "data"))
|
||||
monkeypatch.setenv("STORK_INVITE_TOKEN", "test-invite-token")
|
||||
monkeypatch.setenv("STORK_ADMIN_TOKEN", "test-admin-token")
|
||||
monkeypatch.setenv("STORK_COOKIE_SECURE", "false")
|
||||
monkeypatch.setenv("STORK_READONLY_BOARD_IDS", "")
|
||||
|
||||
import stork.app as app_mod
|
||||
|
||||
importlib.reload(app_mod)
|
||||
with TestClient(app_mod.app) as client:
|
||||
board = client.post("/api/boards", json={"title": "Demo"}).json()
|
||||
board_id = board["id"]
|
||||
# Reload with this board marked readonly
|
||||
monkeypatch.setenv("STORK_READONLY_BOARD_IDS", board_id)
|
||||
importlib.reload(app_mod)
|
||||
with TestClient(app_mod.app) as client2:
|
||||
info = client2.get(f"/api/boards/{board_id}")
|
||||
assert info.status_code == 200
|
||||
assert info.json()["readonly"] is True
|
||||
client2.post(
|
||||
"/api/session",
|
||||
json={"board_id": board_id, "display_name": "Visitor"},
|
||||
)
|
||||
assert (
|
||||
client2.post("/api/names", json={"kind": "c1", "spelling": "X"}).status_code
|
||||
== 403
|
||||
)
|
||||
assert client2.get("/api/names?kind=c1").status_code == 200
|
||||
app_mod.store.close()
|
||||
# close first store if still open
|
||||
try:
|
||||
app_mod.store.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def test_rate_limit_create_board(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("STORK_DATA", str(tmp_path / "data"))
|
||||
monkeypatch.setenv("STORK_INVITE_TOKEN", "test-invite-token")
|
||||
monkeypatch.setenv("STORK_ADMIN_TOKEN", "test-admin-token")
|
||||
monkeypatch.setenv("STORK_COOKIE_SECURE", "false")
|
||||
monkeypatch.setenv("STORK_RL_BOARDS_PER_HOUR", "2")
|
||||
|
||||
import stork.app as app_mod
|
||||
import stork.rate_limit as rl
|
||||
|
||||
importlib.reload(rl)
|
||||
importlib.reload(app_mod)
|
||||
app_mod.limiter.reset()
|
||||
app_mod.limiter.configure("create_board", 2, 3600)
|
||||
with TestClient(app_mod.app) as client:
|
||||
assert client.post("/api/boards", json={"title": "One"}).status_code == 200
|
||||
assert client.post("/api/boards", json={"title": "Two"}).status_code == 200
|
||||
third = client.post("/api/boards", json={"title": "Three"})
|
||||
assert third.status_code == 429
|
||||
assert "Retry-After" in third.headers
|
||||
app_mod.store.close()
|
||||
|
||||
Reference in New Issue
Block a user