Compare commits
No commits in common. "24cadf88f88bf5293733ef7d96fb1c048a4daf91" and "a4498fdbbc96efd435b7558ccea50c46e9470158" have entirely different histories.
24cadf88f8
...
a4498fdbbc
12
README.md
12
README.md
|
|
@ -254,12 +254,10 @@ each envelope to its operation key and field purpose so rows and fields cannot b
|
||||||
Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing
|
Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing
|
||||||
freshness, revisions, ordering, replay, or conflict semantics. Synchronized unfiled Draft collections
|
freshness, revisions, ordering, replay, or conflict semantics. Synchronized unfiled Draft collections
|
||||||
use a separate AES-256-GCM key and authenticate the account and revision; existing plaintext rows
|
use a separate AES-256-GCM key and authenticate the account and revision; existing plaintext rows
|
||||||
likewise migrate on first read. Synchronized Saved Search collections and completed Filed review
|
likewise migrate on first read. Synchronized Saved Search collections use the private-state key and
|
||||||
receipts use the private-state key and authenticate each envelope to its normalized account, preventing
|
authenticate each envelope to its normalized account, preventing rows from being substituted between
|
||||||
rows from being substituted between operators. Existing plaintext Saved Searches migrate atomically on
|
operators. Existing plaintext Saved Searches migrate atomically on first read without advancing their
|
||||||
first read without advancing their revision; existing completed Filed receipts migrate transactionally at
|
revision; missing, wrong, or modified key material returns no saved-view content. Other private stores
|
||||||
startup without changing order or acknowledgement state. Missing, wrong, or modified key material
|
|
||||||
returns no saved-view or receipt content. Other private stores
|
|
||||||
are not encrypted at the application layer. Web
|
are not encrypted at the application layer. Web
|
||||||
Push subscriptions use a third, independent
|
Push subscriptions use a third, independent
|
||||||
AES-256-GCM key authenticated to the device session, with keyed endpoint indexes preserving
|
AES-256-GCM key authenticated to the device session, with keyed endpoint indexes preserving
|
||||||
|
|
@ -332,7 +330,7 @@ export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
|
||||||
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
|
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
|
||||||
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
|
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
|
||||||
# Required for worker-shared live/Find Work snapshots, synchronized Today/Later
|
# Required for worker-shared live/Find Work snapshots, synchronized Today/Later
|
||||||
# planning, Saved Search state, completed Filed review receipts, and the Security activity journal. Keep this key independent
|
# planning and Saved Search state, and the Security activity journal. Keep this key independent
|
||||||
# from the Draft key and inject the
|
# from the Draft key and inject the
|
||||||
# base64 encoding of exactly 32 random bytes from a secret manager. Never commit
|
# base64 encoding of exactly 32 random bytes from a secret manager. Never commit
|
||||||
# it. Missing, malformed, wrong-key, or modified state fails closed without
|
# it. Missing, malformed, wrong-key, or modified state fails closed without
|
||||||
|
|
|
||||||
|
|
@ -78,19 +78,6 @@ STORES = (
|
||||||
Store("saved-searches", "saved-searches", "STACKCHAIN_SAVED_SEARCH_DB", "saved-searches.sqlite3", (
|
Store("saved-searches", "saved-searches", "STACKCHAIN_SAVED_SEARCH_DB", "saved-searches.sqlite3", (
|
||||||
Table("saved_searches", ("login",), (Field("views", "views:{login}"),)),
|
Table("saved_searches", ("login",), (Field("views", "views:{login}"),)),
|
||||||
)),
|
)),
|
||||||
Store(
|
|
||||||
"completed-filed-reviews",
|
|
||||||
"completed-filed-reviews",
|
|
||||||
"STACKCHAIN_COMPLETED_FILED_REVIEW_DB",
|
|
||||||
"completed-filed-reviews.sqlite3",
|
|
||||||
(
|
|
||||||
Table(
|
|
||||||
"completed_filed_review_collections",
|
|
||||||
("login",),
|
|
||||||
(Field("receipts", "receipts:{login}"),),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Store("security-events", "security-events", "STACKCHAIN_SECURITY_EVENT_DB", "security-events.sqlite3", (
|
Store("security-events", "security-events", "STACKCHAIN_SECURITY_EVENT_DB", "security-events.sqlite3", (
|
||||||
Table("security_events", ("id",), (Field("payload", "event:{id}"),)),
|
Table("security_events", ("id",), (Field("payload", "event:{id}"),)),
|
||||||
)),
|
)),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Durable, encrypted, account-scoped completed Filed review receipts."""
|
"""Durable, account-scoped completed Filed review receipts."""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
@ -6,68 +6,33 @@ from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from src.private_state import connect_private_sqlite
|
from src.private_state import connect_private_sqlite
|
||||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
|
|
||||||
|
|
||||||
|
|
||||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||||
|
|
||||||
|
|
||||||
class CompletedFiledReviewStore:
|
class CompletedFiledReviewStore:
|
||||||
def __init__(
|
def __init__(self, path: str | Path, *, limit: int = 200, timeout: float = 1.0):
|
||||||
self,
|
|
||||||
path: str | Path,
|
|
||||||
*,
|
|
||||||
limit: int = 200,
|
|
||||||
timeout: float = 1.0,
|
|
||||||
encryption_key: bytes | None = None,
|
|
||||||
):
|
|
||||||
self.path = Path(path)
|
self.path = Path(path)
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self._cipher = PrivateStateCipher(
|
|
||||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
|
||||||
store="completed-filed-reviews",
|
|
||||||
)
|
|
||||||
self._initialize()
|
self._initialize()
|
||||||
|
|
||||||
def _initialize(self) -> None:
|
def _initialize(self) -> None:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
connection.execute("PRAGMA journal_mode=WAL")
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
connection.execute("PRAGMA secure_delete=ON")
|
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS completed_filed_review_collections (
|
CREATE TABLE IF NOT EXISTS completed_filed_reviews (
|
||||||
login TEXT PRIMARY KEY,
|
login TEXT NOT NULL,
|
||||||
receipts TEXT NOT NULL
|
repository TEXT NOT NULL,
|
||||||
|
issue_number INTEGER NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
touched_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (login, repository, issue_number)
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
legacy = connection.execute(
|
|
||||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'completed_filed_reviews'"
|
|
||||||
).fetchone()
|
|
||||||
if legacy:
|
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
|
||||||
rows = connection.execute(
|
|
||||||
"SELECT login, repository, issue_number, updated_at "
|
|
||||||
"FROM completed_filed_reviews ORDER BY login, touched_at"
|
|
||||||
).fetchall()
|
|
||||||
grouped: dict[str, list[dict]] = {}
|
|
||||||
for login, repository, number, updated_at in rows:
|
|
||||||
grouped.setdefault(login, []).append({
|
|
||||||
"repository": repository,
|
|
||||||
"number": int(number),
|
|
||||||
"updated_at": updated_at,
|
|
||||||
})
|
|
||||||
for login, receipts in grouped.items():
|
|
||||||
normalized = [self._receipt(item) for item in receipts][-self.limit:]
|
|
||||||
connection.execute(
|
|
||||||
"INSERT INTO completed_filed_review_collections(login, receipts) VALUES (?, ?) "
|
|
||||||
"ON CONFLICT(login) DO UPDATE SET receipts=excluded.receipts",
|
|
||||||
(login, self._seal(login, normalized)),
|
|
||||||
)
|
|
||||||
connection.execute("DROP TABLE completed_filed_reviews")
|
|
||||||
with self._connect() as connection:
|
|
||||||
connection.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
def _connect(self) -> sqlite3.Connection:
|
||||||
return connect_private_sqlite(self.path, timeout=self.timeout)
|
return connect_private_sqlite(self.path, timeout=self.timeout)
|
||||||
|
|
@ -80,7 +45,7 @@ class CompletedFiledReviewStore:
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _receipt(raw: dict) -> dict:
|
def _receipt(raw: dict) -> tuple[str, int, str]:
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
raise ValueError("receipt must be an object")
|
raise ValueError("receipt must be an object")
|
||||||
repository = raw.get("repository")
|
repository = raw.get("repository")
|
||||||
|
|
@ -98,41 +63,23 @@ class CompletedFiledReviewStore:
|
||||||
raise ValueError("updated_at is invalid") from error
|
raise ValueError("updated_at is invalid") from error
|
||||||
if parsed.tzinfo is None:
|
if parsed.tzinfo is None:
|
||||||
raise ValueError("updated_at is invalid")
|
raise ValueError("updated_at is invalid")
|
||||||
return {"repository": repository, "number": number, "updated_at": updated_at}
|
return repository, number, updated_at
|
||||||
|
|
||||||
def _open(self, login: str, payload: str | None) -> tuple[list[dict], bool]:
|
|
||||||
if payload is None:
|
|
||||||
return [], False
|
|
||||||
value, stale = self._cipher.open(payload, binding=f"receipts:{login}")
|
|
||||||
if not isinstance(value, list):
|
|
||||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
|
||||||
try:
|
|
||||||
return [self._receipt(item) for item in value], stale
|
|
||||||
except ValueError as error:
|
|
||||||
raise PrivateStateEncryptionError("private state could not be decrypted") from error
|
|
||||||
|
|
||||||
def _seal(self, login: str, receipts: list[dict]) -> str:
|
|
||||||
return self._cipher.seal(receipts, binding=f"receipts:{login}")
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _snapshot(receipts: list[dict]) -> dict:
|
def _snapshot(rows) -> dict:
|
||||||
return {"receipts": receipts}
|
return {"receipts": [
|
||||||
|
{"repository": row[0], "number": int(row[1]), "updated_at": row[2]}
|
||||||
|
for row in rows
|
||||||
|
]}
|
||||||
|
|
||||||
def get(self, login: str) -> dict:
|
def get(self, login: str) -> dict:
|
||||||
login = self._login(login)
|
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
row = connection.execute(
|
rows = connection.execute(
|
||||||
"SELECT receipts FROM completed_filed_review_collections WHERE login = ?",
|
"SELECT repository, issue_number, updated_at FROM completed_filed_reviews "
|
||||||
(login,),
|
"WHERE login = ? ORDER BY touched_at",
|
||||||
).fetchone()
|
(self._login(login),),
|
||||||
receipts, stale = self._open(login, row[0] if row else None)
|
).fetchall()
|
||||||
if row is not None and stale:
|
return self._snapshot(rows)
|
||||||
connection.execute(
|
|
||||||
"UPDATE completed_filed_review_collections SET receipts = ? "
|
|
||||||
"WHERE login = ? AND receipts = ?",
|
|
||||||
(self._seal(login, receipts), login, row[0]),
|
|
||||||
)
|
|
||||||
return self._snapshot(receipts)
|
|
||||||
|
|
||||||
def merge(self, login: str, receipts: list[dict]) -> dict:
|
def merge(self, login: str, receipts: list[dict]) -> dict:
|
||||||
login = self._login(login)
|
login = self._login(login)
|
||||||
|
|
@ -141,25 +88,35 @@ class CompletedFiledReviewStore:
|
||||||
normalized = [self._receipt(receipt) for receipt in receipts]
|
normalized = [self._receipt(receipt) for receipt in receipts]
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
row = connection.execute(
|
touched = int(connection.execute(
|
||||||
"SELECT receipts FROM completed_filed_review_collections WHERE login = ?",
|
"SELECT COALESCE(MAX(touched_at), 0) FROM completed_filed_reviews WHERE login = ?",
|
||||||
(login,),
|
(login,),
|
||||||
).fetchone()
|
).fetchone()[0])
|
||||||
current, _stale = self._open(login, row[0] if row else None)
|
for repository, number, updated_at in normalized:
|
||||||
for incoming in normalized:
|
current = connection.execute(
|
||||||
match = next((
|
"SELECT updated_at FROM completed_filed_reviews "
|
||||||
item for item in current
|
"WHERE login = ? AND repository = ? AND issue_number = ?",
|
||||||
if item["repository"] == incoming["repository"] and item["number"] == incoming["number"]
|
(login, repository, number),
|
||||||
), None)
|
).fetchone()
|
||||||
if match is not None and match["updated_at"] >= incoming["updated_at"]:
|
if current is not None and current[0] >= updated_at:
|
||||||
continue
|
continue
|
||||||
if match is not None:
|
touched += 1
|
||||||
current.remove(match)
|
connection.execute(
|
||||||
current.append(incoming)
|
"INSERT INTO completed_filed_reviews "
|
||||||
current = current[-self.limit:]
|
"(login, repository, issue_number, updated_at, touched_at) VALUES (?, ?, ?, ?, ?) "
|
||||||
|
"ON CONFLICT(login, repository, issue_number) DO UPDATE SET "
|
||||||
|
"updated_at=excluded.updated_at, touched_at=excluded.touched_at",
|
||||||
|
(login, repository, number, updated_at, touched),
|
||||||
|
)
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"INSERT INTO completed_filed_review_collections(login, receipts) VALUES (?, ?) "
|
"DELETE FROM completed_filed_reviews WHERE login = ? AND rowid NOT IN ("
|
||||||
"ON CONFLICT(login) DO UPDATE SET receipts=excluded.receipts",
|
"SELECT rowid FROM completed_filed_reviews WHERE login = ? "
|
||||||
(login, self._seal(login, current)),
|
"ORDER BY touched_at DESC LIMIT ?)",
|
||||||
|
(login, login, self.limit),
|
||||||
)
|
)
|
||||||
return self._snapshot(current)
|
rows = connection.execute(
|
||||||
|
"SELECT repository, issue_number, updated_at FROM completed_filed_reviews "
|
||||||
|
"WHERE login = ? ORDER BY touched_at",
|
||||||
|
(login,),
|
||||||
|
).fetchall()
|
||||||
|
return self._snapshot(rows)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -23,30 +21,6 @@ def test_receipts_merge_without_lost_updates_and_remain_account_scoped(tmp_path)
|
||||||
assert store.get("alexander") == {"receipts": []}
|
assert store.get("alexander") == {"receipts": []}
|
||||||
|
|
||||||
|
|
||||||
def test_receipt_history_is_encrypted_and_bound_to_its_account(tmp_path):
|
|
||||||
database = tmp_path / "completed-filed.sqlite3"
|
|
||||||
store = CompletedFiledReviewStore(database, encryption_key=b"f" * 32)
|
|
||||||
timmy = receipt("private-timmy/repository", 912)
|
|
||||||
alexander = receipt("private-alexander/repository", 427)
|
|
||||||
|
|
||||||
store.merge("timmy-private-login", [timmy])
|
|
||||||
store.merge("alexander-private-login", [alexander])
|
|
||||||
|
|
||||||
raw = database.read_bytes()
|
|
||||||
assert b"private-timmy/repository" not in raw
|
|
||||||
assert b"private-alexander/repository" not in raw
|
|
||||||
with sqlite3.connect(database) as connection:
|
|
||||||
rows = connection.execute(
|
|
||||||
"SELECT login, receipts FROM completed_filed_review_collections ORDER BY login"
|
|
||||||
).fetchall()
|
|
||||||
connection.execute(
|
|
||||||
"UPDATE completed_filed_review_collections SET receipts = ? WHERE login = ?",
|
|
||||||
(rows[1][1], rows[0][0]),
|
|
||||||
)
|
|
||||||
with pytest.raises(RuntimeError, match="private state could not be decrypted"):
|
|
||||||
store.get("alexander-private-login")
|
|
||||||
|
|
||||||
|
|
||||||
def test_receipts_keep_newest_revision_and_bound_each_account(tmp_path):
|
def test_receipts_keep_newest_revision_and_bound_each_account(tmp_path):
|
||||||
store = CompletedFiledReviewStore(tmp_path / "completed-filed.sqlite3", limit=2)
|
store = CompletedFiledReviewStore(tmp_path / "completed-filed.sqlite3", limit=2)
|
||||||
newer = receipt(updated_at="2026-08-16T12:00:00Z")
|
newer = receipt(updated_at="2026-08-16T12:00:00Z")
|
||||||
|
|
@ -58,42 +32,6 @@ def test_receipts_keep_newest_revision_and_bound_each_account(tmp_path):
|
||||||
assert bounded == {"receipts": [receipt("stackchain/web", 2), receipt("stackchain/docs", 3)]}
|
assert bounded == {"receipts": [receipt("stackchain/web", 2), receipt("stackchain/docs", 3)]}
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_plaintext_receipts_migrate_for_every_account_without_loss(tmp_path):
|
|
||||||
database = tmp_path / "completed-filed.sqlite3"
|
|
||||||
with sqlite3.connect(database) as connection:
|
|
||||||
connection.execute(
|
|
||||||
"CREATE TABLE completed_filed_reviews ("
|
|
||||||
"login TEXT NOT NULL, repository TEXT NOT NULL, issue_number INTEGER NOT NULL, "
|
|
||||||
"updated_at TEXT NOT NULL, touched_at INTEGER NOT NULL, "
|
|
||||||
"PRIMARY KEY (login, repository, issue_number))"
|
|
||||||
)
|
|
||||||
connection.executemany(
|
|
||||||
"INSERT INTO completed_filed_reviews VALUES (?, ?, ?, ?, ?)",
|
|
||||||
[
|
|
||||||
("timmy", "legacy-private/first", 1, "2026-08-14T12:00:00Z", 1),
|
|
||||||
("timmy", "legacy-private/second", 2, "2026-08-15T12:00:00Z", 2),
|
|
||||||
("alexander", "legacy-private/other", 3, "2026-08-16T12:00:00Z", 1),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
store = CompletedFiledReviewStore(database, encryption_key=b"m" * 32)
|
|
||||||
|
|
||||||
assert store.get("timmy") == {"receipts": [
|
|
||||||
receipt("legacy-private/first", 1, "2026-08-14T12:00:00Z"),
|
|
||||||
receipt("legacy-private/second", 2, "2026-08-15T12:00:00Z"),
|
|
||||||
]}
|
|
||||||
assert store.get("alexander") == {"receipts": [
|
|
||||||
receipt("legacy-private/other", 3, "2026-08-16T12:00:00Z")
|
|
||||||
]}
|
|
||||||
raw = database.read_bytes()
|
|
||||||
assert b"legacy-private/first" not in raw
|
|
||||||
assert b"legacy-private/other" not in raw
|
|
||||||
with sqlite3.connect(database) as connection:
|
|
||||||
assert connection.execute(
|
|
||||||
"SELECT count(*) FROM sqlite_master WHERE name = 'completed_filed_reviews'"
|
|
||||||
).fetchone()[0] == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("candidate", [
|
@pytest.mark.parametrize("candidate", [
|
||||||
{"repository": "bad", "number": 1, "updated_at": "2026-08-15T12:00:00Z"},
|
{"repository": "bad", "number": 1, "updated_at": "2026-08-15T12:00:00Z"},
|
||||||
{"repository": "stackchain/api", "number": 0, "updated_at": "2026-08-15T12:00:00Z"},
|
{"repository": "stackchain/api", "number": 0, "updated_at": "2026-08-15T12:00:00Z"},
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from src.completed_filed_review_store import CompletedFiledReviewStore
|
|
||||||
from src.saved_search_store import SavedSearchStore
|
from src.saved_search_store import SavedSearchStore
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -82,32 +81,3 @@ def test_rotation_command_fails_closed_and_never_prints_private_content(tmp_path
|
||||||
assert secret not in completed.stdout
|
assert secret not in completed.stdout
|
||||||
assert "timmy" not in completed.stdout
|
assert "timmy" not in completed.stdout
|
||||||
assert completed.stderr == ""
|
assert completed.stderr == ""
|
||||||
|
|
||||||
|
|
||||||
def test_rotation_command_rewraps_completed_filed_history_without_printing_it(tmp_path):
|
|
||||||
state = tmp_path / "state"
|
|
||||||
path = state / "completed-filed-reviews.sqlite3"
|
|
||||||
private_receipt = {
|
|
||||||
"repository": "private-rotation/canary",
|
|
||||||
"number": 1252,
|
|
||||||
"updated_at": "2026-08-22T12:00:00Z",
|
|
||||||
}
|
|
||||||
CompletedFiledReviewStore(path, encryption_key=b"o" * 32).merge("timmy", [private_receipt])
|
|
||||||
|
|
||||||
completed = run_rotation(state)
|
|
||||||
|
|
||||||
assert completed.returncode == 0, completed.stderr
|
|
||||||
report = json.loads(completed.stdout)
|
|
||||||
assert report["completed-filed-reviews"] == {
|
|
||||||
"current": 0, "failed": 0, "migrated": 1, "total": 1
|
|
||||||
}
|
|
||||||
assert "private-rotation" not in completed.stdout
|
|
||||||
assert "timmy" not in completed.stdout
|
|
||||||
with sqlite3.connect(path) as connection:
|
|
||||||
payload = connection.execute(
|
|
||||||
"SELECT receipts FROM completed_filed_review_collections WHERE login = 'timmy'"
|
|
||||||
).fetchone()[0]
|
|
||||||
assert payload.startswith("v2:next:")
|
|
||||||
assert CompletedFiledReviewStore(
|
|
||||||
path, encryption_key=({"next": b"n" * 32}, "next")
|
|
||||||
).get("timmy") == {"receipts": [private_receipt]}
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user