Record what may be shared, ahead of federation

The bot-side half of TODO #4. Nothing federates yet; this establishes
what would be eligible to, and proves the boundary before there is a
hub that could leak across it.

- Schema v5 adds visibility, defaulting to private. Upgrading an
  existing library therefore opts nothing into sharing, which a test
  on the legacy upgrade path pins down.
- list_syncable() is the only function that will ever feed a hub, and
  it requires both public and approved. Tests force rows into states
  set_visibility refuses, so the filter is what protects the boundary
  rather than the caller being careful.
- set_visibility refuses anything not approved: media still in the
  moderation queue cannot be marked shared.
- /share lists memes with their current state and asks before making
  one public, since un-sharing stops future copies but cannot recall
  those already taken. The wording describes what sharing will mean
  once a hub exists, because by then warning is too late.
- /list shows the same markers.

Vault posts have no path into the sync set at all; being in a separate
table is what guarantees it, and there is a test saying so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goyban
2026-09-01 22:09:37 +00:00
parent a35abb0541
commit 0732f2c34b
7 changed files with 272 additions and 7 deletions
+80 -1
View File
@@ -56,6 +56,10 @@ async def test_legacy_upgrade(tmp: str):
tables = {r[0] for r in c.execute("SELECT name FROM sqlite_master WHERE type='table'")}
assert statuses == {"approved"}, f"upgraded rows must stay live: {statuses}"
assert "vault_items" in tables, "upgrading should create the vault table too"
with sqlite3.connect(path) as c:
visibilities = {r[0] for r in c.execute("SELECT visibility FROM media")}
assert visibilities == {"private"}, \
f"upgrading must not opt an existing library into sharing: {visibilities}"
print(" legacy v1 database upgrades with ids, keywords and visibility intact")
@@ -168,6 +172,78 @@ async def test_rejected_stays_hidden(tmp: str):
print(" rejected items leave the queue and never surface in search")
async def test_new_media_is_private(tmp: str):
path = os.path.join(tmp, "visibility.db")
db = load_db(path)
await db.init_db()
await db.add_media(111, "F1", "U1", "photo", ["kw"])
assert (await db.search_media("kw"))[0]["visibility"] == "private"
assert await db.list_syncable() == [], "nothing should be shared by default"
print(" media is private unless explicitly shared")
async def test_only_public_and_approved_can_sync(tmp: str):
path = os.path.join(tmp, "syncable.db")
db = load_db(path)
await db.init_db()
await db.add_media(111, "F1", "U1", "photo", ["private_approved"])
await db.add_media(111, "F2", "U2", "photo", ["public_approved"])
await db.add_media(111, "F3", "U3", "photo", ["public_pending"], status="pending")
await db.add_media(111, "F4", "U4", "photo", ["public_rejected"], status="rejected")
# Force the states set_visibility would refuse, to prove the query itself
# filters rather than relying on the writer being careful.
with sqlite3.connect(path) as c:
c.execute("UPDATE media SET visibility='public' WHERE file_unique_id IN ('U2','U3','U4')")
syncable = [r["keywords"] for r in await db.list_syncable()]
assert syncable == ["public_approved"], f"leaked: {syncable}"
assert await db.count_public() == 1
print(" only approved AND public media is ever syncable")
async def test_sharing_requires_approval(tmp: str):
path = os.path.join(tmp, "share_guard.db")
db = load_db(path)
await db.init_db()
await db.add_media(111, "F1", "U1", "photo", ["waiting"], status="pending")
pending = (await db.search_media("", status="pending"))[0]
assert await db.set_visibility(pending["id"], "public") is False, \
"unreviewed media must not be shareable"
assert await db.list_syncable() == []
print(" media awaiting moderation cannot be marked shared")
async def test_unsharing_stops_future_syncs(tmp: str):
path = os.path.join(tmp, "unshare.db")
db = load_db(path)
await db.init_db()
await db.add_media(111, "F1", "U1", "photo", ["kw"])
item = (await db.search_media("kw"))[0]
assert await db.set_visibility(item["id"], "public") is True
assert len(await db.list_syncable()) == 1
assert await db.set_visibility(item["id"], "private") is True
assert await db.list_syncable() == [], "unsharing should remove it from the sync set"
print(" unsharing takes an item back out of the sync set")
async def test_vault_never_syncs(tmp: str):
path = os.path.join(tmp, "vault_sync.db")
db = load_db(path)
await db.init_db()
await db.add_vault_item(111, 500, [1, 2], "a private post", ["personal"])
# The vault lives in its own table, so the sync query cannot reach it at all.
assert await db.list_syncable() == []
assert await db.count_public() == 0
print(" vault posts have no path into the sync set")
async def main():
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
with tempfile.TemporaryDirectory() as tmp:
@@ -175,7 +251,10 @@ async def main():
test_readd_keeps_id, test_fresh_and_idempotent,
test_pending_is_hidden, test_queue_is_oldest_first,
test_two_moderators_cannot_double_review,
test_rejected_stays_hidden):
test_rejected_stays_hidden, test_new_media_is_private,
test_only_public_and_approved_can_sync,
test_sharing_requires_approval,
test_unsharing_stops_future_syncs, test_vault_never_syncs):
await test(tmp)
print("\nAll database tests passed.")