Let the server decide whose memory a caller may read
A holder is now somebody who can prove it. Schema v11 stores a sha256 of a per-holder token beside the directory row, so a copied database cannot replay it and the plaintext is shown exactly once. The operator token mints and revokes those tokens; a holder token reads that holder's own memories and nothing else; naming somebody else without the operator token is refused with both ids in the message. The visibility rule was leaking before this: browse had none at all, and the search rules short-circuited to the whole library whenever no holder was named. The rule is now written out in five copies with one parameter carried four times, and a test compares the copies so they cannot drift — semgrep rejects an interpolated constant, so the copies are not a choice. Eyes carry three states on purpose. NULL means the whole library, which is what a process that owns the store is entitled to; a holder id means that holder; the empty string means nobody, which is what a tokenless HTTP caller is. Making NULL mean nobody instead would have forced every internal caller — CLI, proofs, the compiler, the bench harnesses — to name eyes, contradicting the rule that a local process reads as its own trusted principal. The capability card answers "can this store answer my kind of question" before anything is sent. It is derived from what the store holds, so it cannot promise a modality the store lacks, and it carries no memory text, so it is safe to read before trusting the store. HTTP, CLI and one MCP tool all serve it without a token.
This commit is contained in:
1 parent
209f2d0a1c
commit
83dfc14daa
29 files changed
+1335
-77
No files matched your search
@@ -51,7 +51,8 @@ class Store:
|
||||
return self.cartridges.materialize(name, atoms, sources).cartridge_id
|
||||
|
||||
def texts(self, query: str = "钥匙", **filters: Any) -> list[str]:
|
||||
result = self.search.search(query, filters=SearchFilters(**filters) if filters else None)
|
||||
eyes: dict[str, Any] = {"as_holder": "wife", **filters}
|
||||
result = self.search.search(query, filters=SearchFilters(**eyes))
|
||||
return [hit.atom.text for hit in result.hits]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
"""The sharing rule over HTTP: a token says who is asking, and the store answers to that person."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import ely_eye.api as api_module
|
||||
from ely_eye.compiler import ContextCompiler
|
||||
from ely_eye.config import Settings
|
||||
from ely_eye.db import Database
|
||||
from ely_eye.holder_store import HolderStore
|
||||
from ely_eye.memory import MemoryService
|
||||
from ely_eye.schemas import (
|
||||
EvidenceAtom,
|
||||
MemoryOrigin,
|
||||
Modality,
|
||||
RuntimeProfile,
|
||||
SourceRecord,
|
||||
TrustScores,
|
||||
Visibility,
|
||||
)
|
||||
from ely_eye.storage import ObjectStore
|
||||
|
||||
OPERATOR = "the-operator-token"
|
||||
AS_OPERATOR = {"Authorization": f"Bearer {OPERATOR}"}
|
||||
PNG = b"\x89PNG\r\n\x1a\n"
|
||||
KEYS = "我们的钥匙放在玄关抽屉里"
|
||||
THERMOSTAT = "恒温器设在二十二度"
|
||||
OUTAGE = "上周三的停电持续了四十分钟"
|
||||
|
||||
|
||||
def _settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Settings:
|
||||
resolved = Settings(home=tmp_path, default_holder="wife", auth_token=OPERATOR)
|
||||
monkeypatch.setattr(api_module, "get_settings", lambda: resolved)
|
||||
Database(resolved).init()
|
||||
return resolved
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
return TestClient(api_module.create_app())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Settings:
|
||||
return _settings(tmp_path, monkeypatch)
|
||||
|
||||
|
||||
def _token_for(client: TestClient, holder_id: str) -> dict[str, Any]:
|
||||
answer = client.post(f"/api/holders/{holder_id}/token", headers=AS_OPERATOR)
|
||||
assert answer.status_code == 200, answer.json()
|
||||
return dict(answer.json())
|
||||
|
||||
|
||||
def _remember(settings: Settings, text: str, holder_id: str, **kwargs: Any) -> EvidenceAtom:
|
||||
return MemoryService(settings, Database(settings)).remember(
|
||||
text, holder_id=holder_id, origin=MemoryOrigin.self_, **kwargs
|
||||
)
|
||||
|
||||
|
||||
def _hits(client: TestClient, query: str, headers: dict[str, str] | None = None, **body: Any) -> list[str]:
|
||||
answer = client.post("/api/search", json={"query": query, **body}, headers=headers or {})
|
||||
assert answer.status_code == 200, answer.json()
|
||||
return [str(hit["atom"]["atom_id"]) for hit in answer.json()["hits"]]
|
||||
|
||||
|
||||
def test_only_the_operator_mints_a_token(client: TestClient, settings: Settings) -> None:
|
||||
_remember(settings, KEYS, "wife")
|
||||
wife = _token_for(client, "wife")
|
||||
assert client.post("/api/holders/husband/token").status_code == 401, "no token cannot mint one"
|
||||
refused = client.post("/api/holders/husband/token", headers={"Authorization": f"Bearer {wife['token']}"})
|
||||
assert refused.status_code == 401, "a holder token is not an operator token"
|
||||
assert "Bearer" in refused.json()["detail"]
|
||||
|
||||
|
||||
def test_the_token_is_shown_once_and_stored_as_a_hash(client: TestClient, settings: Settings) -> None:
|
||||
_remember(settings, KEYS, "wife")
|
||||
issued = _token_for(client, "wife")
|
||||
token = str(issued["token"])
|
||||
assert issued["holder_id"] == "wife" and issued["issued_at"]
|
||||
|
||||
with Database(settings).connect() as connection:
|
||||
row = connection.execute("SELECT token_hash FROM holders WHERE holder_id = 'wife'").fetchone()
|
||||
stored = str(row["token_hash"])
|
||||
assert stored == hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
assert token not in stored and len(stored) == 64, "a copied database must not replay the token"
|
||||
|
||||
listed = client.get("/api/holders/wife", headers=AS_OPERATOR).json()
|
||||
assert "token" not in listed and listed["token_issued_at"] == issued["issued_at"]
|
||||
|
||||
|
||||
def test_a_holder_token_reads_as_that_holder_and_nobody_else(client: TestClient, settings: Settings) -> None:
|
||||
mine = _remember(settings, KEYS, "wife")
|
||||
wife = _token_for(client, "wife")
|
||||
_token_for(client, "husband")
|
||||
|
||||
as_wife = _hits(client, KEYS, headers={"Authorization": f"Bearer {wife['token']}"})
|
||||
as_husband = _hits(client, KEYS, headers={"Authorization": f"Bearer {wife['token']}"})
|
||||
assert mine.atom_id in as_wife
|
||||
assert as_husband == as_wife, "the same token reads the same way whatever the query says"
|
||||
|
||||
anonymous = _hits(client, KEYS)
|
||||
assert mine.atom_id not in anonymous, "a caller with no token is nobody"
|
||||
|
||||
|
||||
def test_an_unknown_token_is_refused_rather_than_treated_as_nobody(client: TestClient, settings: Settings) -> None:
|
||||
_remember(settings, KEYS, "wife")
|
||||
refused = client.post("/api/search", json={"query": KEYS}, headers={"Authorization": "Bearer not-a-token"})
|
||||
assert refused.status_code == 401
|
||||
detail = str(refused.json()["detail"])
|
||||
assert "operator" in detail and "holder" in detail, "the refusal has to say which two things failed"
|
||||
|
||||
|
||||
def test_a_public_memory_is_findable_by_anybody(client: TestClient, settings: Settings) -> None:
|
||||
shared = _remember(settings, OUTAGE, "wife", visibility=Visibility(scope="everyone"))
|
||||
assert shared.atom_id in _hits(client, OUTAGE), "no token, still visible"
|
||||
|
||||
|
||||
def test_a_shared_memory_is_readable_only_by_the_holders_it_names(client: TestClient, settings: Settings) -> None:
|
||||
private = _remember(settings, THERMOSTAT, "wife", visibility=Visibility(scope="visible_to", holders=["husband"]))
|
||||
_remember(settings, KEYS, "husband")
|
||||
wife = _token_for(client, "wife")
|
||||
husband = _token_for(client, "husband")
|
||||
|
||||
assert private.atom_id in _hits(client, THERMOSTAT, headers={"Authorization": f"Bearer {husband['token']}"})
|
||||
assert private.atom_id in _hits(client, THERMOSTAT, headers={"Authorization": f"Bearer {wife['token']}"})
|
||||
assert private.atom_id not in _hits(client, THERMOSTAT)
|
||||
|
||||
outsider = _remember(settings, OUTAGE, "neighbour")
|
||||
_token_for(client, "neighbour")
|
||||
neighbour = _token_for(client, "neighbour")
|
||||
assert outsider.atom_id
|
||||
assert private.atom_id not in _hits(client, THERMOSTAT, headers={"Authorization": f"Bearer {neighbour['token']}"})
|
||||
|
||||
|
||||
def test_naming_another_holder_is_a_claim_the_token_has_to_back(client: TestClient, settings: Settings) -> None:
|
||||
mine = _remember(settings, KEYS, "wife")
|
||||
wife = _token_for(client, "wife")
|
||||
_token_for(client, "husband")
|
||||
as_wife = {"Authorization": f"Bearer {wife['token']}"}
|
||||
|
||||
over = client.post("/api/search", json={"query": KEYS, "filters": {"as_holder": "husband"}}, headers=as_wife)
|
||||
assert over.status_code == 403
|
||||
detail = str(over.json()["detail"])
|
||||
assert "wife" in detail and "husband" in detail, "a refusal that names neither side cannot be acted on"
|
||||
|
||||
anonymous = client.post("/api/search", json={"query": KEYS, "filters": {"as_holder": "wife"}})
|
||||
assert anonymous.status_code == 403, "typing a name is not a token"
|
||||
|
||||
operator = client.post("/api/search", json={"query": KEYS, "filters": {"as_holder": "wife"}}, headers=AS_OPERATOR)
|
||||
assert operator.status_code == 200
|
||||
assert mine.atom_id in [str(hit["atom"]["atom_id"]) for hit in operator.json()["hits"]]
|
||||
|
||||
|
||||
def test_revoking_a_token_stops_it_working(client: TestClient, settings: Settings) -> None:
|
||||
_remember(settings, KEYS, "wife")
|
||||
token = _token_for(client, "wife")["token"]
|
||||
assert HolderStore(Database(settings)).verify_token(str(token)) == "wife"
|
||||
|
||||
revoked = client.delete("/api/holders/wife/token", headers=AS_OPERATOR)
|
||||
assert revoked.status_code == 200 and revoked.json()["holder_id"] == "wife"
|
||||
assert client.post("/api/search", json={"query": KEYS}, headers={"Authorization": f"Bearer {token}"}).status_code == 401
|
||||
assert client.get("/api/holders/wife", headers=AS_OPERATOR).json()["token_issued_at"] is None
|
||||
|
||||
|
||||
def test_a_working_set_refuses_to_belong_to_nobody(client: TestClient, settings: Settings) -> None:
|
||||
_remember(settings, KEYS, "wife", session_id="kitchen-2026")
|
||||
_remember(settings, THERMOSTAT, "husband", session_id="kitchen-2026")
|
||||
|
||||
anonymous = client.get("/api/memory/working", params={"session": "kitchen-2026"})
|
||||
assert anonymous.status_code == 422
|
||||
assert "somebody" in str(anonymous.json()["detail"])
|
||||
|
||||
wife = _token_for(client, "wife")
|
||||
hers = client.get("/api/memory/working", params={"session": "kitchen-2026"}, headers={"Authorization": f"Bearer {wife['token']}"})
|
||||
assert [atom["text"] for atom in hers.json()] == [KEYS], "a session sees its own lane, not the room's"
|
||||
|
||||
both = client.get("/api/memory/working", params={"session": "kitchen-2026", "holder": "husband"}, headers=AS_OPERATOR)
|
||||
assert [atom["text"] for atom in both.json()] == [THERMOSTAT]
|
||||
|
||||
|
||||
def test_a_conflict_list_refuses_to_belong_to_nobody(client: TestClient, settings: Settings) -> None:
|
||||
_remember(settings, "恒温器放在书房,设定 22 度", "wife")
|
||||
_remember(settings, "恒温器调到 24 度", "wife")
|
||||
assert client.get("/api/memory/conflicts").status_code == 422
|
||||
wife = _token_for(client, "wife")
|
||||
hers = client.get("/api/memory/conflicts", headers={"Authorization": f"Bearer {wife['token']}"})
|
||||
assert hers.status_code == 200 and len(hers.json()) == 1
|
||||
|
||||
|
||||
def test_the_card_is_public_names_holders_and_carries_no_memory_text(client: TestClient, settings: Settings) -> None:
|
||||
_remember(settings, KEYS, "wife", scenario="home")
|
||||
_remember(settings, THERMOSTAT, "husband")
|
||||
_token_for(client, "wife")
|
||||
|
||||
answer = client.get("/api/card")
|
||||
assert answer.status_code == 200, "a capability card exists to be read by a stranger"
|
||||
card = answer.json()
|
||||
assert card["schema_version"] >= 11 and card["has_audio"] is False
|
||||
named = {holder["holder_id"]: holder for holder in card["holders"]}
|
||||
assert set(named) == {"wife", "husband"}
|
||||
assert named["wife"]["memories"] == 1 and named["wife"]["token_issued"] is True
|
||||
assert named["husband"]["token_issued"] is False
|
||||
assert named["wife"]["scenarios"] == ["home"] and named["wife"]["modalities"] == {"text": 1}
|
||||
assert named["wife"]["newest_memory"], "a stranger needs to know how stale the memory is"
|
||||
assert KEYS not in answer.text and THERMOSTAT not in answer.text
|
||||
|
||||
one = client.get("/api/card/wife")
|
||||
assert one.status_code == 200 and one.json()["holder_id"] == "wife"
|
||||
assert client.get("/api/card/nobody").status_code == 404
|
||||
|
||||
|
||||
def test_a_picture_is_served_only_to_eyes_that_may_see_it(client: TestClient, settings: Settings) -> None:
|
||||
database = Database(settings)
|
||||
stored = ObjectStore(settings).put_bytes(PNG, ".png", "image")
|
||||
database.upsert_sources(
|
||||
[
|
||||
SourceRecord(
|
||||
source_id=source,
|
||||
path=f"file:///{source}.png",
|
||||
kind="image",
|
||||
mime="image/png",
|
||||
sha256=source * 8,
|
||||
size_bytes=len(PNG),
|
||||
)
|
||||
for source in ("hers", "ours")
|
||||
]
|
||||
)
|
||||
database.upsert_atoms(
|
||||
[
|
||||
EvidenceAtom(
|
||||
atom_id=f"{source}:frame",
|
||||
modality=Modality.image,
|
||||
source_id=source,
|
||||
source=f"file:///{source}.png",
|
||||
image_ref=stored,
|
||||
trust=TrustScores(parser=1.0, ocr=0.0, model=0.0),
|
||||
holder_id="wife" if source == "hers" else "",
|
||||
)
|
||||
for source in ("hers", "ours")
|
||||
]
|
||||
)
|
||||
|
||||
assert client.get("/api/atoms/ours:frame/image").status_code == 200, "nobody owns it, so anybody may look"
|
||||
assert client.get("/api/atoms/hers:frame/image").status_code == 404, "the bytes are behind the same rule as the words"
|
||||
assert client.get("/api/atoms", params={"modality": "image"}).json()["total"] == 1
|
||||
wife = _token_for(client, "wife")
|
||||
page = client.get("/api/atoms", params={"modality": "image"}, headers={"Authorization": f"Bearer {wife['token']}"})
|
||||
assert page.json()["total"] == 2
|
||||
|
||||
|
||||
def test_an_operator_and_a_stranger_do_not_share_one_compiled_context(settings: Settings) -> None:
|
||||
_remember(settings, KEYS, "wife")
|
||||
compiler = ContextCompiler(settings, Database(settings))
|
||||
# The stranger compiles first: if the cache key ignored whose eyes were used, the operator would be served this.
|
||||
stranger = compiler.compile(KEYS, RuntimeProfile.live_demo, as_holder="")
|
||||
assert stranger.hits == [], "a memory nobody owns and nobody marked public is invisible to a stranger"
|
||||
|
||||
owner = compiler.compile(KEYS, RuntimeProfile.live_demo, as_holder="wife")
|
||||
assert KEYS in owner.packed_text and owner.cache_trace_id != stranger.cache_trace_id
|
||||
|
||||
whole = compiler.compile(KEYS, RuntimeProfile.live_demo)
|
||||
assert KEYS in whole.packed_text, "a process that owns the store still reads the whole library"
|
||||
@@ -18,6 +18,8 @@ from ely_eye.storage import ObjectStore
|
||||
|
||||
PNG = b"\x89PNG\r\n\x1a\n"
|
||||
BASE = datetime(2026, 5, 1, 12, 0, tzinfo=UTC)
|
||||
OPERATOR = "the-shelf-owner"
|
||||
AS_OWNER = {"Authorization": f"Bearer {OPERATOR}"}
|
||||
|
||||
HTTP_SURFACE = {
|
||||
"/api/adapters",
|
||||
@@ -32,11 +34,14 @@ HTTP_SURFACE = {
|
||||
"/api/cartridges/{cartridge_id}/refresh",
|
||||
"/api/cartridges/{cartridge_id}/switch",
|
||||
"/api/cartridges/{cartridge_id}/unmount",
|
||||
"/api/card",
|
||||
"/api/card/{holder_id}",
|
||||
"/api/chat",
|
||||
"/api/hashhop",
|
||||
"/api/holders",
|
||||
"/api/holders/{holder_id}",
|
||||
"/api/holders/{holder_id}/knows",
|
||||
"/api/holders/{holder_id}/token",
|
||||
"/api/ingest/path",
|
||||
"/api/ingest/upload",
|
||||
"/api/memory",
|
||||
@@ -57,7 +62,7 @@ HTTP_SURFACE = {
|
||||
|
||||
@pytest.fixture
|
||||
def settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Settings:
|
||||
resolved = Settings(home=tmp_path, default_holder="wife")
|
||||
resolved = Settings(home=tmp_path, default_holder="wife", auth_token=OPERATOR)
|
||||
monkeypatch.setattr(api_module, "get_settings", lambda: resolved)
|
||||
Database(resolved).init()
|
||||
return resolved
|
||||
@@ -106,14 +111,15 @@ def _seed(settings: Settings, pictures: int = 3) -> None:
|
||||
|
||||
def test_the_store_pages_newest_first_and_says_how_much_is_left(client: TestClient, settings: Settings) -> None:
|
||||
_seed(settings)
|
||||
first = client.get("/api/atoms", params={"limit": 2})
|
||||
# A tokenless caller is nobody and would see none of these; this test reads the shelf as its owner.
|
||||
first = client.get("/api/atoms", params={"limit": 2}, headers=AS_OWNER)
|
||||
assert first.status_code == 200
|
||||
page = first.json()
|
||||
assert page["total"] == 3
|
||||
assert [atom["atom_id"] for atom in page["atoms"]] == ["shot2:frame", "shot1:frame"]
|
||||
assert page["next_cursor"]
|
||||
|
||||
second = client.get("/api/atoms", params={"limit": 2, "cursor": page["next_cursor"]})
|
||||
second = client.get("/api/atoms", params={"limit": 2, "cursor": page["next_cursor"]}, headers=AS_OWNER)
|
||||
rest = second.json()
|
||||
assert [atom["atom_id"] for atom in rest["atoms"]] == ["shot0:frame"]
|
||||
assert rest["next_cursor"] is None, "the last page must not promise another one"
|
||||
@@ -125,11 +131,11 @@ def test_browse_narrows_by_modality_and_by_holder(client: TestClient, settings:
|
||||
"the keys are on the shelf", holder_id="wife", origin=MemoryOrigin.self_
|
||||
)
|
||||
|
||||
images = client.get("/api/atoms", params={"modality": "image"}).json()
|
||||
images = client.get("/api/atoms", params={"modality": "image"}, headers=AS_OWNER).json()
|
||||
assert images["total"] == 2
|
||||
assert {atom["modality"] for atom in images["atoms"]} == {"image"}
|
||||
|
||||
his = client.get("/api/atoms", params={"holder": "husband"}).json()
|
||||
his = client.get("/api/atoms", params={"holder": "husband"}, headers=AS_OWNER).json()
|
||||
assert [atom["atom_id"] for atom in his["atoms"]] == ["shot1:frame"]
|
||||
|
||||
|
||||
@@ -142,7 +148,7 @@ def test_an_unreadable_cursor_is_refused_with_400(client: TestClient, settings:
|
||||
|
||||
def test_a_picture_atom_serves_the_bytes_behind_it(client: TestClient, settings: Settings) -> None:
|
||||
_seed(settings, pictures=1)
|
||||
response = client.get("/api/atoms/shot0:frame/image")
|
||||
response = client.get("/api/atoms/shot0:frame/image", headers=AS_OWNER)
|
||||
assert response.status_code == 200
|
||||
assert response.content == PNG
|
||||
assert response.headers["content-type"] == "image/png"
|
||||
@@ -152,15 +158,15 @@ def test_a_memory_with_no_picture_says_so(client: TestClient, settings: Settings
|
||||
atom = MemoryService(settings, Database(settings)).remember(
|
||||
"the keys are on the shelf", holder_id="wife", origin=MemoryOrigin.self_
|
||||
)
|
||||
assert client.get(f"/api/atoms/{atom.atom_id}/image").status_code == 404
|
||||
assert client.get("/api/atoms/nope:image").status_code == 404
|
||||
assert client.get(f"/api/atoms/{atom.atom_id}/image", headers=AS_OWNER).status_code == 404
|
||||
assert client.get("/api/atoms/nope:image", headers=AS_OWNER).status_code == 404
|
||||
|
||||
|
||||
def test_a_forgotten_picture_stops_being_served(client: TestClient, settings: Settings) -> None:
|
||||
_seed(settings, pictures=1)
|
||||
MemoryService(settings, Database(settings)).forget(atom_id="shot0:frame")
|
||||
assert client.get("/api/atoms/shot0:frame/image").status_code == 404
|
||||
assert client.get("/api/atoms").json()["total"] == 0
|
||||
assert client.get("/api/atoms/shot0:frame/image", headers=AS_OWNER).status_code == 404
|
||||
assert client.get("/api/atoms", headers=AS_OWNER).json()["total"] == 0
|
||||
|
||||
|
||||
def test_status_says_what_the_machine_can_see_and_what_it_cannot(client: TestClient, settings: Settings) -> None:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -15,6 +16,7 @@ import ely_eye.cli as cli_module
|
||||
from ely_eye.cli import app
|
||||
from ely_eye.config import Settings
|
||||
from ely_eye.db import Database
|
||||
from ely_eye.holder_store import HolderStore
|
||||
from ely_eye.ingestion import IngestionService
|
||||
from ely_eye.memory import MemoryService
|
||||
from ely_eye.migrations import SCHEMA_VERSION
|
||||
@@ -249,3 +251,45 @@ def test_the_cli_asks_and_reports_the_referral(tmp_path: Path, monkeypatch: pyte
|
||||
assert asked.exit_code == 0 and '"status": "referred"' in asked.stdout
|
||||
assert runner.invoke(app, ["holders"]).stdout.count("wife") >= 1
|
||||
assert runner.invoke(app, ["holder-set", "wife", "--contact", "not-a-pair"]).exit_code != 0
|
||||
|
||||
|
||||
def test_the_cli_mints_a_token_that_proves_who_is_asking(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
settings = Settings(home=tmp_path / "home", default_holder="husband")
|
||||
monkeypatch.setattr(cli_module, "get_settings", lambda: settings)
|
||||
runner = CliRunner()
|
||||
Store(settings).wife_remembers(HOME_KEYS)
|
||||
|
||||
issued = json.loads(runner.invoke(app, ["holder-issue", "wife"]).stdout)
|
||||
assert issued["holder_id"] == "wife" and issued["token"] and issued["issued_at"]
|
||||
assert HolderStore(Database(settings)).verify_token(issued["token"]) == "wife"
|
||||
assert issued["token"] not in runner.invoke(app, ["card"]).stdout, "the card is public and the token is not"
|
||||
|
||||
revoked = json.loads(runner.invoke(app, ["holder-revoke", "wife"]).stdout)
|
||||
assert revoked["holder_id"] == "wife" and revoked["token_issued_at"] is None
|
||||
assert HolderStore(Database(settings)).verify_token(issued["token"]) is None
|
||||
|
||||
|
||||
def test_the_card_is_read_back_from_the_store_rather_than_written_by_hand(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
settings = Settings(home=tmp_path / "home", default_holder="husband")
|
||||
monkeypatch.setattr(cli_module, "get_settings", lambda: settings)
|
||||
runner = CliRunner()
|
||||
store = Store(settings)
|
||||
store.wife_remembers(HOME_KEYS)
|
||||
store.husband_remembers(WORK_KEYS)
|
||||
store.register("neighbour", name="邻居", knows_about=["weather"])
|
||||
|
||||
answer = runner.invoke(app, ["card"])
|
||||
assert answer.exit_code == 0, answer.stdout
|
||||
card = json.loads(answer.stdout)
|
||||
assert card["schema_version"] == SCHEMA_VERSION
|
||||
assert card["atoms"] == 2 and card["sources"] == 2
|
||||
assert card["modalities"] == {"text": 2} and card["has_audio"] is False
|
||||
assert card["mounts"] == {"mounted": [], "scoped": False, "atoms_in_scope": 0}
|
||||
|
||||
named = {entry["holder_id"]: entry for entry in card["holders"]}
|
||||
assert named["wife"]["memories"] == 1 and named["wife"]["scenarios"] == ["home"]
|
||||
assert named["wife"]["newest_memory"], "a caller needs to know how stale the answer would be"
|
||||
assert named["neighbour"]["memories"] == 0 and named["neighbour"]["knows_about"] == ["weather"]
|
||||
assert HOME_KEYS not in answer.stdout, "the card says what a holder holds, never what they hold it to be"
|
||||
@@ -0,0 +1,46 @@
|
||||
"""The visibility rule is written out in five literal statements; this test keeps them one rule.
|
||||
|
||||
The SQL gate refuses a concatenated statement and a view cannot take a parameter, so the clause
|
||||
is copied rather than composed. A copy that drifts is invisible to every other test — the search
|
||||
leg, the browse page and the picture route would each keep passing while disagreeing about who
|
||||
may see a memory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE_DIR = Path(__file__).resolve().parents[1] / "src" / "ely_eye"
|
||||
FILES = ("browse.py", "store_search.py", "vector_index.py")
|
||||
CLAUSE = re.compile(r"\(\? IS NULL[^()]*?holder_id = ''.*?WHERE value = \?[^)]*\)\)\)\)", re.DOTALL)
|
||||
EXPECTED_COPIES = 5
|
||||
|
||||
|
||||
def _normalize(clause: str) -> str:
|
||||
"""Strip what the copies are allowed to differ in: whitespace, string-literal quotes, table aliases."""
|
||||
|
||||
return re.sub(r"\s+", "", clause).replace('"', "").replace("a.", "")
|
||||
|
||||
|
||||
def test_every_copy_of_the_visibility_rule_agrees() -> None:
|
||||
found: list[tuple[str, str]] = [
|
||||
(name, _normalize(match.group(0)))
|
||||
for name in FILES
|
||||
for match in CLAUSE.finditer((SOURCE_DIR / name).read_text(encoding="utf-8"))
|
||||
]
|
||||
|
||||
assert len(found) == EXPECTED_COPIES, [name for name, _ in found]
|
||||
distinct = {clause for _, clause in found}
|
||||
assert len(distinct) == 1, [name for name, clause in found if clause != next(iter(distinct))]
|
||||
|
||||
|
||||
def test_the_rule_still_carries_all_three_states() -> None:
|
||||
match = CLAUSE.search((SOURCE_DIR / "store_search.py").read_text(encoding="utf-8"))
|
||||
assert match is not None, "the words leg no longer carries a visibility clause"
|
||||
clause = _normalize(match.group(0))
|
||||
|
||||
assert "?ISNULL" in clause, "None still reads the whole library"
|
||||
assert "holder_id=''" in clause and "$.scope')='everyone'" in clause, "unowned and public rows stay open to a stranger"
|
||||
assert "?<>''" in clause, "the empty string must stay nobody rather than matching every holder"
|
||||
assert "holder_id=?" in clause and "value=?" in clause, "the owner and the visible_to list are still checked"
|
||||
Reference in new issue
Block a user