feat(sync): 实现设备认证与对象版本协议原型

This commit is contained in:
2026-09-07 15:45:37 +08:00
parent f193f699b2
commit 7fffbcd55a
17 changed files with 1444 additions and 0 deletions
+155
View File
@@ -0,0 +1,155 @@
"""两个受控设备的黑盒协议向量;只操作 pytest 临时目录。"""
from concurrent.futures import ThreadPoolExecutor
import hashlib
import uuid
from fastapi.testclient import TestClient
import pytest
from sync_server.app import create_app
from sync_server.database import Database, run
from sync_server.storage import DiskObjects
@pytest.fixture
def env(tmp_path):
db = Database("sqlite:///" + str(tmp_path / "sync.db"))
now = [1000000]
store = DiskObjects(tmp_path / "objects")
app = create_app(db, store, tmp_path / "staging", clock=lambda: now[0])
db.add_user("alice", "controlled-fixture-password")
db.add_user("bob", "controlled-fixture-password")
with TestClient(app) as client:
yield client, db, store, now
db.engine.dispose()
def session(client, user="alice"):
response = client.post("/sync/v1/auth/sessions", json={"username": user, "password": "controlled-fixture-password", "device_name": "测试设备"})
assert response.status_code == 200, response.text
data = response.json()
return {"Authorization": "Bearer " + data["access_token"]}, data
def setup(client):
auth, token = session(client)
vault = client.post("/sync/v1/vaults", json={"name": "隔离笔记"}, headers=auth).json()["vault_id"]
return auth, token, "/sync/v1/vaults/" + vault
def upload(client, base, auth, data=b"controlled note"):
sha = hashlib.sha256(data).hexdigest()
info = client.post(base + "/uploads", headers=auth, json={"content_hash": sha, "size": len(data)}).json()
if not info["complete"]:
path = base + "/uploads/" + info["upload_id"]
assert client.put(path + "?offset=0", headers=auth, content=data).status_code == 200
assert client.post(path + "/complete", headers=auth).status_code == 200
return sha
def change(sha, **overrides):
return {"operation_id": uuid.uuid4().hex, "file_id": uuid.uuid4().hex,
"base_revision": 0, "path": "中文/笔记.md", "operation": "put", "content_hash": sha,
"size": len(b"controlled note"), **overrides}
def test_two_devices_conflict_retry_move_delete_history(env):
client, _, _, _ = env
auth, _, base = setup(client)
second, _ = session(client)
sha = upload(client, base, auth)
body = change(sha)
first = client.post(base + "/revisions", headers=auth, json=body)
assert first.status_code == 200
assert client.post(base + "/revisions", headers=auth, json=body).json() == first.json()
other = {**body, "operation_id": uuid.uuid4().hex}
assert client.post(base + "/revisions", headers=second, json=other).json()["error"]["code"] == "REVISION_CONFLICT"
move = {**other, "base_revision": 1, "path": "中文/移动.md"}
assert client.post(base + "/revisions", headers=second, json=move).json()["sequence"] == 2
deletion = {**move, "operation_id": uuid.uuid4().hex, "base_revision": 2, "operation": "delete", "content_hash": None, "size": 0}
assert client.post(base + "/revisions", headers=second, json=deletion).json()["sequence"] == 3
assert client.post(base + "/revisions", headers=auth, json={**body, "operation_id": uuid.uuid4().hex}).status_code == 409
history = client.get(base + "/history/" + body["file_id"], headers=auth).json()["items"]
assert [x["sequence"] for x in history] == [3, 2, 1]
# 恢复生成新 Revision,旧历史和对象保持不变。
restore = {**body, "operation_id": uuid.uuid4().hex, "base_revision": 3}
assert client.post(base + "/revisions", headers=auth, json=restore).json()["sequence"] == 4
assert client.get(base + "/objects/" + sha, headers=auth).content == b"controlled note"
def test_object_isolation_revocation_refresh_and_expiry(env):
client, _, _, now = env
auth, token, base = setup(client)
second, _ = session(client)
outsider, _ = session(client, "bob")
sha = upload(client, base, auth)
assert client.get(base + "/objects/" + sha, headers=outsider).status_code == 404
refreshed = client.post("/sync/v1/auth/refresh", json={"refresh_token": token["refresh_token"]}).json()
assert client.get(base + "/objects/" + sha, headers=auth).status_code == 401
assert client.post("/sync/v1/auth/refresh", json={"refresh_token": token["refresh_token"]}).status_code == 401
auth = {"Authorization": "Bearer " + refreshed["access_token"]}
assert client.delete("/sync/v1/devices/" + token["device_id"], headers=second).status_code == 204
assert client.get(base + "/objects/" + sha, headers=auth).status_code == 401
assert client.post("/sync/v1/auth/refresh", json={"refresh_token": refreshed["refresh_token"]}).status_code == 401
now[0] += 901
assert client.get(base + "/changes", headers=second).status_code == 401
@pytest.mark.parametrize("path", ["../a", "/a", "a\\b", "CON.md", "a/aux", "a.", "a ", "a//b", ".ainote/db", "e\u0301.md", "x:y", "a\x00b"])
def test_unsafe_paths(env, path):
client, _, _, _ = env
auth, _, base = setup(client)
assert client.post(base + "/revisions", headers=auth, json=change("0" * 64, path=path)).status_code == 422
def test_resume_integrity_quota_and_missing_object(env):
client, db, _, _ = env
auth, _, base = setup(client)
sha = hashlib.sha256(b"abc").hexdigest()
info = client.post(base + "/uploads", headers=auth, json={"content_hash": sha, "size": 3}).json()
path = base + "/uploads/" + info["upload_id"]
assert client.put(path + "?offset=0", headers=auth, content=b"a").json()["offset"] == 1
assert client.put(path + "?offset=0", headers=auth, content=b"a").status_code == 409
assert client.get(path, headers=auth).json()["offset"] == 1
assert client.post(path + "/complete", headers=auth).status_code == 422
assert client.put(path + "?offset=1", headers=auth, content=b"bc").status_code == 200
assert client.post(path + "/complete", headers=auth).status_code == 200
assert client.post(base + "/revisions", headers=auth, json=change("0" * 64)).status_code == 409
with db.transaction() as conn:
run(conn, "UPDATE vaults SET quota=3")
assert client.post(base + "/uploads", headers=auth, json={"content_hash": "0" * 64, "size": 1}).status_code == 413
def test_concurrent_cas_and_fixed_cursor_boundary(env):
client, _, _, _ = env
auth, _, base = setup(client)
sha = upload(client, base, auth)
body = change(sha)
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(lambda _: client.post(base + "/revisions", headers=auth, json={**body, "operation_id": uuid.uuid4().hex}).status_code, range(2)))
assert sorted(results) == [200, 409]
snapshot = client.get(base + "/changes?limit=1", headers=auth).json()
assert snapshot["boundary"] == 1
assert client.post(base + "/revisions", headers=auth, json=change(sha, path="第二篇.md")).status_code == 200
assert client.get(base + "/changes?cursor=1&boundary=1", headers=auth).json()["items"] == []
assert len(client.get(base + "/changes?cursor=1", headers=auth).json()["items"]) == 1
def test_casefold_parent_path_collision_and_idempotency(env):
client, _, _, _ = env
auth, _, base = setup(client)
sha = upload(client, base, auth)
body = change(sha, path="A.md")
assert client.post(base + "/revisions", headers=auth, json=body).status_code == 200
for path in ["a.MD", "a.md/child"]:
assert client.post(base + "/revisions", headers=auth, json=change(sha, path=path)).json()["error"]["code"] == "PATH_CONFLICT"
assert client.post(base + "/revisions", headers=auth, json={**body, "path": "other"}).json()["error"]["code"] == "IDEMPOTENCY_REUSED"
def test_login_limits_and_protocol(env):
client, _, _, _ = env
assert client.get("/sync/v1/handshake?protocol=2").status_code == 426
for _ in range(10):
assert client.post("/sync/v1/auth/sessions", json={"username": "unknown", "password": "controlled-fixture-password", "device_name": "fixture"}).status_code == 401
assert client.post("/sync/v1/auth/sessions", json={"username": "unknown", "password": "controlled-fixture-password", "device_name": "fixture"}).status_code == 429