Files
渡口浪人 1494904b12
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 8m9s
fix: close library upload flow and harden image storage
2026-10-24 00:38:00 +08:00

300 lines
9.1 KiB
Python

import hashlib
import time
from pigway_frame_flow.core.accounts import password_hash
def account(app):
app.state.service.db.execute(
"INSERT INTO users VALUES(?,?,1)",
("test-user", password_hash("fixture-password")),
)
def login(client, app, remember=True):
account(app)
client.headers.pop("Authorization", None)
return client.post(
"/api/v1/auth/login",
json={
"username": "test-user",
"password": "fixture-password",
"remember": remember,
},
)
def test_session_login_and_csrf(client, app):
response = login(client, app)
assert response.status_code == 200
assert "HttpOnly" in response.headers["set-cookie"]
assert "SameSite=strict" in response.headers["set-cookie"]
assert client.get("/api/v1/devices").status_code == 200
assert client.post("/api/v1/jobs", json={}).status_code == 403
assert client.get("/api/v1/auth/me").json()["username"] == "test-user"
client.headers["X-CSRF-Token"] = response.json()["csrf_token"]
assert client.post("/api/v1/auth/logout").status_code == 204
assert client.get("/api/v1/devices").status_code == 401
def test_password_change_revokes_sessions(client, app):
response = login(client, app)
client.headers["X-CSRF-Token"] = response.json()["csrf_token"]
assert (
client.put(
"/api/v1/auth/password",
json={"current_password": "wrong", "new_password": "replacement-password"},
).status_code
== 401
)
assert (
client.put(
"/api/v1/auth/password",
json={
"current_password": "fixture-password",
"new_password": "replacement-password",
},
).status_code
== 204
)
assert client.get("/api/v1/auth/me").status_code == 401
assert (
client.post(
"/api/v1/auth/login",
json={"username": "test-user", "password": "replacement-password"},
).status_code
== 200
)
def test_login_rate_limit(client, app):
account(app)
for _ in range(5):
assert (
client.post(
"/api/v1/auth/login",
json={"username": "test-user", "password": "wrong"},
).status_code
== 401
)
assert (
client.post(
"/api/v1/auth/login",
json={"username": "test-user", "password": "fixture-password"},
).status_code
== 429
)
def test_session_expiry_and_no_plaintext(client, app):
response = login(client, app, False)
assert "Max-Age" not in response.headers["set-cookie"]
raw = client.cookies.get("frameflow_session")
row = app.state.service.db.one("SELECT * FROM sessions")
assert row["token_hash"] == hashlib.sha256(raw.encode()).hexdigest()
app.state.service.db.execute("UPDATE sessions SET expires_at=?", (time.time() - 1,))
assert client.get("/api/v1/auth/me").status_code == 401
assert "fixture-password" not in str(
app.state.service.db.one("SELECT * FROM users")
)
def test_reset_template(client):
assert (
client.put(
"/api/v1/templates/quote/config", json={"data": {"title": "Saved title"}}
).status_code
== 200
)
assert (
client.get("/api/v1/templates/quote").json()["defaults"]["title"]
== "Saved title"
)
assert client.delete("/api/v1/templates/quote/config").status_code == 204
assert (
client.get("/api/v1/templates/quote").json()["defaults"]["title"]
!= "Saved title"
)
def test_openapi_describes_cookie_auth_and_docs_csrf(client):
schema = client.get("/openapi.json").json()
cookie = schema["components"]["securitySchemes"]["AccountSession"]
assert cookie["in"] == "cookie" and cookie["name"] == "frameflow_session"
assert {"AccountSession": []} in schema["paths"]["/api/v1/devices"]["get"][
"security"
]
assert "X-CSRF-Token" in client.get("/docs").text
def test_browser_admin_can_read_other_clients_operation(client, app, service):
operation_id = service.submit(
"render",
{"template": "quote"},
"living-room",
"scheduler",
"fixture-request",
None,
)
assert login(client, app).status_code == 200
assert client.get("/api/v1/operations/" + operation_id).status_code == 200
assert operation_id in [
op["operation_id"] for op in client.get("/api/v1/operations").json()
]
def test_password_minimum_and_https_cookie(client, app):
response = login(client, app)
client.headers["X-CSRF-Token"] = response.json()["csrf_token"]
assert (
client.put(
"/api/v1/auth/password",
json={"current_password": "fixture-password", "new_password": "1234567"},
).status_code
== 422
)
assert (
client.put(
"/api/v1/auth/password",
json={"current_password": "fixture-password", "new_password": "12345678"},
).status_code
== 204
)
response = client.post(
"https://testserver/api/v1/auth/login",
json={"username": "test-user", "password": "12345678"},
)
assert response.status_code == 200
assert "Secure" in response.headers["set-cookie"]
assert response.headers["cache-control"] == "no-store"
def test_login_rejects_cross_origin(client, app):
account(app)
assert (
client.post(
"/api/v1/auth/login",
headers={"Origin": "https://evil.example"},
json={"username": "test-user", "password": "fixture-password"},
).status_code
== 403
)
def test_account_throttle_across_source_addresses(client, app):
account(app)
for _ in range(5):
assert (
client.post(
"/api/v1/auth/login",
json={"username": "test-user", "password": "wrong"},
).status_code
== 401
)
app.state.service.db.execute(
"DELETE FROM login_attempts WHERE identity NOT LIKE 'account:%'"
)
assert (
client.post(
"/api/v1/auth/login",
json={"username": "test-user", "password": "fixture-password"},
).status_code
== 429
)
def test_legacy_hash_upgrade(client, app):
import hmac
from pigway_frame_flow.core.accounts import verify_password
salt = "12" * 16
digest = hashlib.scrypt(
b"fixture-password", salt=bytes.fromhex(salt), n=16384, r=8, p=1
).hex()
legacy = f"scrypt${salt}${digest}"
app.state.service.db.execute(
"INSERT INTO users VALUES(?,?,1)", ("test-user", legacy)
)
assert verify_password("fixture-password", legacy)
assert (
client.post(
"/api/v1/auth/login",
json={"username": "test-user", "password": "fixture-password"},
).status_code
== 200
)
stored = app.state.service.db.one("SELECT password_hash FROM users")[
"password_hash"
]
assert stored.startswith("scrypt-v2$") and not hmac.compare_digest(stored, legacy)
assert verify_password("fixture-password", stored)
def test_only_one_browser_account(app):
import sqlite3
import pytest
account(app)
with pytest.raises(sqlite3.IntegrityError):
app.state.service.db.execute(
"INSERT INTO users VALUES(?,?,1)",
("other", password_hash("another-password")),
)
def test_password_hash_does_not_hold_database_lock(client, app, monkeypatch):
import threading
from concurrent.futures import ThreadPoolExecutor
from pigway_frame_flow.core import accounts
account(app)
started, release = threading.Event(), threading.Event()
verify = accounts.verify_password
def delayed(*args):
started.set()
assert release.wait(5)
return verify(*args)
monkeypatch.setattr(accounts, "verify_password", delayed)
with ThreadPoolExecutor(max_workers=2) as executor:
request = executor.submit(
client.post,
"/api/v1/auth/login",
json={"username": "test-user", "password": "fixture-password"},
)
try:
assert started.wait(5)
query = executor.submit(app.state.service.db.one, "SELECT 1 AS ok")
assert query.result(timeout=2)["ok"] == 1
finally:
release.set()
assert request.result(timeout=5).status_code == 200
def test_password_changed_during_login_does_not_create_session(
client, app, monkeypatch
):
from pigway_frame_flow.core import accounts
account(app)
verify = accounts.verify_password
def changed(*args):
result = verify(*args)
app.state.service.db.execute(
"UPDATE users SET password_hash=?", (password_hash("replacement-password"),)
)
return result
monkeypatch.setattr(accounts, "verify_password", changed)
response = client.post(
"/api/v1/auth/login",
json={"username": "test-user", "password": "fixture-password"},
)
assert response.status_code == 401
assert app.state.service.db.all("SELECT * FROM sessions") == []