web: serve each user their own book
Each DAV route serves the requester's book, and a card outside it is a 404;
tokens name the book they came from, so step 08's old token gets a 410, not the delta it got before.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/README.md b/README.md
index f9081e9..5ea1f28 100644
--- a/README.md
+++ b/README.md
@@ -65,6 +65,11 @@ while the app is reachable through serve alone — bind it to localhost.
Tailscale documents two cases that carry no identity and so cannot get in:
Funnel traffic, which is public, and traffic from tagged devices.
+Each user syncs a book of their own rather than every card: the members
+of the group `sync:*`, which everyone gets, and of `sync:<name>`, where
+the name is the user's Tailscale display name. A card a client creates
+joins its writer's group. See `docs/plans/2026-09-12-per-user-books.md`.
+
Requests the server cannot answer — a 404, a refused report, or a crash —
are kept under `log/unhandled`, one directory per distinct request, in the
same layout as `test/fixtures/macos-exchange`. A client asking for
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index d86253f..0d84ffb 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -1,4 +1,5 @@
+require "digest"
require "pathname"
require "securerandom"
@@ -30,6 +31,7 @@ module ProTacts
# @rbs @contacts: Array[Contact]?
# @rbs @ctag: String?
# @rbs @identity: TailscaleAuth::Identity
+ # @rbs @book: Set[String]?
# The vendored Gloss CSS and the admin app's own stylesheet (see
# docs/DESIGN.md); relative to this file rather than $0 for the same
@@ -494,9 +496,13 @@ module ProTacts
# The warm-sync ask is etag-only; a changed etag sends the
# client back through multiget, so no address-data here.
#
- # The token carries the change log's sequence (see
- # #sync_token), and the delta is the log after it: one
- # response per member, the net of everything it did in the
+ # The token carries the change log's sequence and whose book
+ # it was issued for (see #sync_token), and the delta is the
+ # log after the sequence, answered from the requester's book
+ # as it is now: a card that left it answers as removed
+ # (section 3.5.2), and so does one that never was in it,
+ # which the log cannot tell apart. One response per
+ # member, the net of everything it did in the
# window — a member put twice since the token answers once,
# at its current etag, the multiple-changes case section 3.5
# allows one response for. A member whose net is a removal
@@ -514,7 +520,7 @@ module ProTacts
# No token is the initial sync: every member, changed
# (section 3.4).
multistatus(contacts.map { etag_response(it) }, sync_token:)
- elsif (sequence = token[%r{\Ahttp://pro-tacts/sync/(\d+)\z}, 1]) &&
+ elsif (sequence = token[%r{\Ahttp://pro-tacts/sync/(\d+)/#{book_digest}\z}, 1]) &&
sequence.to_i <= store.latest_sequence
net = store.changes(after: sequence.to_i).map { it.card_id }.uniq
responses = net.map { |id|
@@ -523,8 +529,9 @@ module ProTacts
}
multistatus(responses, sync_token:)
else
- # A token this server never issued, or one naming a state
- # past the present: the section's DAV:valid-sync-token
+ # A token this server never issued, one issued for another
+ # book, or one naming a state past the present: the
+ # section's DAV:valid-sync-token
# precondition, marshalled per RFC 4918 section 16. 410
# rather than 403 because its fallback is a full resync —
# the request will not always fail, and the state the
@@ -581,7 +588,7 @@ module ProTacts
# Read on its own rather than through the collection: serving
# one href has no reason to load every other contact first.
r.get String do |filename|
- contact = store.contact(filename.delete_suffix(".vcf"))
+ contact = member(filename.delete_suffix(".vcf"))
if contact
response["Content-Type"] = "text/vcard; charset=utf-8"
@@ -622,9 +629,25 @@ module ProTacts
# Read once per request — Roda builds a fresh app instance for each
# one — from the store the whole process shares. Reading a family
# address book per request is cheap and can never serve a stale one.
+ #
+ # The requester's book and nothing else: every route under /dav serves
+ # that collection (docs/plans/2026-09-12-per-user-books.md).
#: () -> Array[Contact]
def contacts
- @contacts ||= store.contacts
+ @contacts ||= store.contacts.select { book.include?(it.id) }
+ end
+
+ #: () -> Set[String]
+ def book
+ @book ||= store.book(@identity.name)
+ end
+
+ # One member of the requester's book, or nil for a card outside it
+ # as for one that does not exist: neither is a member of this
+ # collection.
+ #: (String id) -> Contact?
+ def member(id)
+ store.contact(id) if book.include?(id)
end
#: () -> String
@@ -662,10 +685,20 @@ module ProTacts
# Sync tokens are opaque to the client (RFC 6578 section 3); the URI
# form is conventional. Built on the ctag so a client polling either
- # one sees changes at the same points.
+ # one sees changes at the same points, and on the requester's name so
+ # that a token is refused by any other book, the one a rename leaves
+ # a user with included (docs/plans/2026-09-12-per-user-books.md, "The
+ # wire").
#: () -> String
def sync_token
- "http://pro-tacts/sync/#{ctag}"
+ "http://pro-tacts/sync/#{ctag}/#{book_digest}"
+ end
+
+ # The name half of a sync token: the first 16 hex digits of the
+ # SHA-256 of the requester's display name.
+ #: () -> String
+ def book_digest
+ Digest::SHA256.hexdigest(@identity.name)[0, 16].to_s
end
#: () -> Store
@@ -682,6 +715,11 @@ module ProTacts
# CARDDAV:valid-address-data even when its If-Match is stale too.
#: (String id) -> String?
def write_card(id)
+ # A card outside the requester's book is no member of this
+ # collection (#member), and its id cannot be created here either,
+ # being taken: the 404 the GET of it gets.
+ return if !book.include?(id) && store.contact(id)
+
# CARDDAV:supported-address-data (RFC 6352 section 6.3.2.1): what
# arrived must be a vCard, and text/vcard is the one media type
# this server stores. Asked before the body is read because it is
@@ -739,7 +777,7 @@ module ProTacts
report_unreadable_lines(vcard)
report_broken_assumptions(vcard)
- stored = store.put(id, vcard)
+ stored = store.put(id, vcard, sync_to: @identity.name)
response.status = existing ? 204 : 201
# A strong ETag belongs on the answer only when what the resource
# now serves is the submitted bytes, octet for octet — the one
@@ -767,8 +805,9 @@ module ProTacts
# An unmapped URI is a 404, not a silent success: RFC 4918 section
# 9.6 gives DELETE no idempotent status, and a client deleting what
# it believes exists is owed the disagreement. Falls through to the
- # not_found handler the way the GET and PUT of a bad id do.
- existing = store.contact(id)
+ # not_found handler the way the GET and PUT of a bad id do. A card
+ # outside the requester's book is unmapped here too (#member).
+ existing = member(id)
return unless existing
# The lost-update conditional, RFC 7232 section 3.1, on the same
diff --git a/test/fixture_data.rb b/test/fixture_data.rb
index 2aab554..4881d5b 100644
--- a/test/fixture_data.rb
+++ b/test/fixture_data.rb
@@ -86,10 +86,14 @@ module FixtureData
cards.keys.grep(/\Abday-/)
end
+ # The third is `sync:*` holding every card, so every user's book is
+ # the whole seed — the replay's included, whose recorded sessions saw
+ # every card (docs/plans/2026-09-12-per-user-books.md).
#: (ProTacts::Store store) -> void
def self.seed_groups(store)
seed_group(store, name: "Booles", lines: HOUSEHOLD, members: MEMBERS)
seed_group(store, members: birthday_cards)
+ seed_group(store, name: ProTacts::Store::EVERYONE, members: cards.keys)
end
# A group, seeded without a change-log entry for any member it moves
diff --git a/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response b/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
index 2dbbd28..b7d86f1 100644
--- a/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
+++ b/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
@@ -17,7 +17,7 @@ Content-Type: text/xml
</d:supported-report>
</d:supported-report-set>
<cs:getctag>18</cs:getctag>
- <d:sync-token>http://pro-tacts/sync/18</d:sync-token>
+ <d:sync-token>http://pro-tacts/sync/18/c8dae6371b33b8d6</d:sync-token>
<d:current-user-privilege-set>
<d:privilege><d:read/></d:privilege>
<d:privilege><d:write/></d:privilege>
diff --git a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
index 567431e..c949537 100644
--- a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
@@ -17,7 +17,7 @@ Content-Type: text/xml
</d:supported-report>
</d:supported-report-set>
<cs:getctag>18</cs:getctag>
- <d:sync-token>http://pro-tacts/sync/18</d:sync-token>
+ <d:sync-token>http://pro-tacts/sync/18/c8dae6371b33b8d6</d:sync-token>
<d:current-user-privilege-set>
<d:privilege><d:read/></d:privilege>
<d:privilege><d:write/></d:privilege>
diff --git a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
index 2dbbd28..b7d86f1 100644
--- a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
+++ b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
@@ -17,7 +17,7 @@ Content-Type: text/xml
</d:supported-report>
</d:supported-report-set>
<cs:getctag>18</cs:getctag>
- <d:sync-token>http://pro-tacts/sync/18</d:sync-token>
+ <d:sync-token>http://pro-tacts/sync/18/c8dae6371b33b8d6</d:sync-token>
<d:current-user-privilege-set>
<d:privilege><d:read/></d:privilege>
<d:privilege><d:write/></d:privilege>
diff --git a/test/fixtures/macos-exchange/08-report-sync-collection/response b/test/fixtures/macos-exchange/08-report-sync-collection/response
index f6c4ddb..55e4357 100644
--- a/test/fixtures/macos-exchange/08-report-sync-collection/response
+++ b/test/fixtures/macos-exchange/08-report-sync-collection/response
@@ -1,161 +1,7 @@
-207
+410
Content-Type: text/xml
<?xml version="1.0" encoding="UTF-8"?>
-<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
- <d:response>
- <d:href>/dav/addressbook/D0504597-CF12-4AF6-9894-43724366D39D.vcf</d:href>
- <d:propstat>
- <d:prop>
- <d:getetag>"6ce139775700c82011936b1958b6e081d62f27c711931804ecac2a1e5bb6e8f9"</d:getetag>
- </d:prop>
- <d:status>HTTP/1.1 200 OK</d:status>
- </d:propstat>
-</d:response>
-<d:response>
- <d:href>/dav/addressbook/bday-basic-noyear.vcf</d:href>
- <d:propstat>
- <d:prop>
- <d:getetag>"81f0da34c506a6c92460dda5bf490dc4282cdf83c8b5d0b99bc6f4348f2bbd7b"</d:getetag>
- </d:prop>
- <d:status>HTTP/1.1 200 OK</d:status>
- </d:propstat>
-</d:response>
-<d:response>
- <d:href>/dav/addressbook/bday-complete.vcf</d:href>
- <d:propstat>
- <d:prop>
- <d:getetag>"4f18a47439816f1488e1bea6ddd31b26dd098de0e5245e57e70e32944be12804"</d:getetag>
- </d:prop>
- <d:status>HTTP/1.1 200 OK</d:status>
- </d:propstat>
-</d:response>
-<d:response>
- <d:href>/dav/addressbook/bday-datetime.vcf</d:href>
- <d:propstat>
- <d:prop>
- <d:getetag>"e05100ccdcc77884ab0cd415544df3be9caaed41b37cf6f15b1675462e92bc14"</d:getetag>
- </d:prop>
- <d:status>HTTP/1.1 200 OK</d:status>
- </d:propstat>
-</d:response>
-<d:response>
- <d:href>/dav/addressbook/bday-day-only.vcf</d:href>
- <d:propstat>
- <d:prop>
- <d:getetag>"63611249675e47a061f15a77ec97c1c3c370915f6772aeb827a9dbc8b5223d2a"</d:getetag>
- </d:prop>
- <d:status>HTTP/1.1 200 OK</d:status>
- </d:propstat>
-</d:response>
-<d:response>
- <d:href>/dav/addressbook/bday-extended-noyear.vcf</d:href>
- <d:propstat>
- <d:prop>
- <d:getetag>"6dca6844193e0f29ff9b8ef6051bbe6d049d0d649467404edaa84b2abea3f6e9"</d:getetag>
- </d:prop>
- <d:status>HTTP/1.1 200 OK</d:status>
- </d:propstat>
-</d:response>
-<d:response>
- <d:href>/dav/addressbook/bday-month-only.vcf</d:href>
- <d:propstat>
- <d:prop>
- <d:getetag>"c6fdf9e92e169a012b5afedb252156de2e447a31f4ad2e9a2956504111203776"</d:getetag>
- </d:prop>
- <d:status>HTTP/1.1 200 OK</d:status>
- </d:propstat>
-</d:response>
-<d:response>
- <d:href>/dav/addressbook/bday-omit-year.vcf</d:href>
- <d:propstat>
- <d:prop>
- <d:getetag>"fe423fff45d51c9c49cc25d667a9fd2ee3f9f53dbb2be12acad15f1088b5c2a0"</d:getetag>
- </d:prop>
- <d:status>HTTP/1.1 200 OK</d:status>
- </d:propstat>
-</d:response>
-<d:response>
- <d:href>/dav/addressbook/bday-year-month.vcf</d:href>
- <d:propstat>
- <d:prop>
- <d:getetag>"5444c5bd0fad1dd3bccb1111f52590d329078e28d9003d1c65fd0ba2f7544ec7"</d:getetag>
- </d:prop>
- <d:status>HTTP/1.1 200 OK</d:status>
- </d:propstat>
-</d:response>
-<d:response>
- <d:href>/dav/addressbook/bday-year-only.vcf</d:href>
- <d:propstat>
- <d:prop>
- <d:getetag>"f8d1a272129107f5575cc0f3b186a4c830ae5d49294df31f46a80c42f5840772"</d:getetag>
- </d:prop>
- <d:status>HTTP/1.1 200 OK</d:status>
- </d:propstat>
-</d:response>
-<d:response>
- <d:href>/dav/addressbook/emoji.vcf</d:href>
- <d:propstat>
- <d:prop>
- <d:getetag>"f2de8c9cbbc34e304510ff6f076ed3275e620802bc15731550b5d43b0d0ae994"</d:getetag>
- </d:prop>
- <d:status>HTTP/1.1 200 OK</d:status>
- </d:propstat>
-</d:response>
-<d:response>
- <d:href>/dav/addressbook/household-alicia.vcf</d:href>
- <d:propstat>
- <d:prop>
- <d:getetag>"ca751ac9f3b3be48e95809594bfcfcce2774fac924698e4e6fdf342645df2af8"</d:getetag>
- </d:prop>
- <d:status>HTTP/1.1 200 OK</d:status>
- </d:propstat>
-</d:response>
-<d:response>
- <d:href>/dav/addressbook/household-george.vcf</d:href>
- <d:propstat>
- <d:prop>
- <d:getetag>"89143354379038718238e5f146f71535724c00cc28d9f21add2f864e62b3953e"</d:getetag>
- </d:prop>
- <d:status>HTTP/1.1 200 OK</d:status>
- </d:propstat>
-</d:response>
-<d:response>
- <d:href>/dav/addressbook/household-mary.vcf</d:href>
- <d:propstat>
- <d:prop>
- <d:getetag>"a94d2aeb791106372057f2759864f46ea78e1fe410cacbe15026b303e1bb174f"</d:getetag>
- </d:prop>
- <d:status>HTTP/1.1 200 OK</d:status>
- </d:propstat>
-</d:response>
-<d:response>
- <d:href>/dav/addressbook/memoji.vcf</d:href>
- <d:propstat>
- <d:prop>
- <d:getetag>"bb0734ee418c5bb60047503ea7675ccb46151d8e0084af665ba628bfc4acd804"</d:getetag>
- </d:prop>
- <d:status>HTTP/1.1 200 OK</d:status>
- </d:propstat>
-</d:response>
-<d:response>
- <d:href>/dav/addressbook/oqmvpzooywwmtwwx.vcf</d:href>
- <d:propstat>
- <d:prop>
- <d:getetag>"e63c84519663074659f6cd80ba7c4de31fe7c72c87736cbf630619bb0448d1cd"</d:getetag>
- </d:prop>
- <d:status>HTTP/1.1 200 OK</d:status>
- </d:propstat>
-</d:response>
-<d:response>
- <d:href>/dav/addressbook/photo.vcf</d:href>
- <d:propstat>
- <d:prop>
- <d:getetag>"9bd93308c95dc178599a5102bdc6f194f8cb02f838262f421fbe7e2d7c1ab281"</d:getetag>
- </d:prop>
- <d:status>HTTP/1.1 200 OK</d:status>
- </d:propstat>
-</d:response>
- <d:sync-token>http://pro-tacts/sync/18</d:sync-token>
-
-</d:multistatus>
+<d:error xmlns:d="DAV:">
+ <d:valid-sync-token/>
+</d:error>
diff --git a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
index 567431e..c949537 100644
--- a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
@@ -17,7 +17,7 @@ Content-Type: text/xml
</d:supported-report>
</d:supported-report-set>
<cs:getctag>18</cs:getctag>
- <d:sync-token>http://pro-tacts/sync/18</d:sync-token>
+ <d:sync-token>http://pro-tacts/sync/18/c8dae6371b33b8d6</d:sync-token>
<d:current-user-privilege-set>
<d:privilege><d:read/></d:privilege>
<d:privilege><d:write/></d:privilege>
diff --git a/test/fixtures/macos-exchange/README.md b/test/fixtures/macos-exchange/README.md
index b639728..8bdd6ad 100644
--- a/test/fixtures/macos-exchange/README.md
+++ b/test/fixtures/macos-exchange/README.md
@@ -27,6 +27,14 @@ behavior: regenerate them with `rake fixtures` after changing responses
deliberately, and review the diff — accidental drift is exactly what the
comparison test exists to catch.
+## The sync step
+
+Step 08's request carries `http://pro-tacts/sync/1`, a token from before
+tokens named the book they were issued for
+(`docs/plans/2026-09-12-per-user-books.md`). The replay answers it with
+410 and `DAV:valid-sync-token` rather than the 207 delta the recorded
+session received. What macOS does after a 410 has not been recorded.
+
## The PUT steps
Both are the same edit to the same contact. Step 10 is the edit, which
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 7605e4b..dc7030d 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -768,8 +768,20 @@ class WebTest < Minitest::Test
text.gsub("<", "<").gsub(">", ">").gsub("&", "&")
end
- # Contacts by id and name, each a bare card.
- def with_contacts(names, &) = super(names.to_h { |id, name| [id, card(id, name)] }, &)
+ # Contacts by id and name, each a bare card, and every one in `sync:*`
+ # so the requester's book is all of them. Seeded without a change-log
+ # entry, so the sequences the sync tests read are the puts' alone.
+ def with_contacts(names, &block)
+ super(names.to_h { |id, name| [id, card(id, name)] }) do |store|
+ FixtureData.seed_group(store, name: ProTacts::Store::EVERYONE, members: names.keys)
+ block.call(store)
+ end
+ end
+
+ # A sync token as the route issues one to a user (Web#sync_token).
+ def issued_token(sequence, name: "Test User")
+ "http://pro-tacts/sync/#{sequence}/#{Digest::SHA256.hexdigest(name)[0, 16]}"
+ end
def etag_only_propfind
<<~XML
@@ -868,7 +880,7 @@ class WebTest < Minitest::Test
# here.
def test_sync_collection_reports_the_delta_since_the_token
with_contacts({"aiden" => "Aiden"}) do |store|
- request "/dav/addressbook/", method: "REPORT", input: sync_collection("http://pro-tacts/sync/0")
+ request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(0))
assert_equal 207, last_response.status
assert_includes last_response.body, "/dav/addressbook/aiden.vcf"
@@ -898,14 +910,14 @@ class WebTest < Minitest::Test
assert_equal 207, last_response.status
assert_includes last_response.body, "/dav/addressbook/aiden.vcf"
assert_includes last_response.body, "/dav/addressbook/znorth.vcf"
- assert_includes last_response.body, "<d:sync-token>http://pro-tacts/sync/2</d:sync-token>"
+ assert_includes last_response.body, "<d:sync-token>#{issued_token(2)}</d:sync-token>"
end
end
# RFC 6578 section 3.2 spells a removal href and 404, no propstat.
def test_sync_collection_reports_a_removal_as_404
with_contacts({"aiden" => "Aiden", "znorth" => "Znorth"}) do |store|
- request "/dav/addressbook/", method: "REPORT", input: sync_collection("http://pro-tacts/sync/2")
+ request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(2))
token = last_response.body[%r{<d:sync-token>(.+)</d:sync-token>}, 1]
store.delete("znorth")
@@ -924,7 +936,7 @@ class WebTest < Minitest::Test
# section 3.2, marshalled per RFC 4918 section 16.
def test_sync_collection_refuses_a_token_it_never_issued
with_contacts({"aiden" => "Aiden"}) do
- request "/dav/addressbook/", method: "REPORT", input: sync_collection("http://pro-tacts/sync/9")
+ request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(9))
assert_equal 410, last_response.status
assert_includes last_response.body, "<d:valid-sync-token/>"
@@ -935,6 +947,94 @@ class WebTest < Minitest::Test
end
end
+ # A token names the book it was issued for, so a user whose name
+ # changed resyncs rather than taking a delta against another book —
+ # and a token from before tokens named one is refused the same way.
+ def test_sync_collection_refuses_a_token_from_another_book
+ with_contacts({"aiden" => "Aiden"}) do
+ request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(1, name: "Zoë Chen"))
+ assert_equal 410, last_response.status
+
+ request "/dav/addressbook/", method: "REPORT", input: sync_collection("http://pro-tacts/sync/1")
+ assert_equal 410, last_response.status
+
+ request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(1))
+ assert_equal 207, last_response.status
+ end
+ end
+
+ ## Books
+
+ # A card in nobody's book is no member of this collection, to any
+ # method (docs/plans/2026-09-12-per-user-books.md, "The wire").
+ def test_a_card_outside_the_book_is_not_a_member
+ with_contacts({"aiden" => "Aiden"}) do |store|
+ store.put("znorth", ProTacts::VCard.new(card("znorth", "Zed")))
+
+ request "/dav/addressbook/", method: "PROPFIND", "HTTP_DEPTH" => "1", input: etag_only_propfind
+ assert_includes last_response.body, "aiden.vcf"
+ refute_includes last_response.body, "znorth"
+
+ get "/dav/addressbook/znorth.vcf"
+ assert_equal 404, last_response.status
+
+ request "/dav/addressbook/", method: "REPORT", input: multiget("znorth")
+ assert_includes last_response.body, "HTTP/1.1 404 Not Found"
+ refute_includes last_response.body, "FN:Zed"
+
+ put_request "znorth", card("znorth", "Zed Smith"), "CONTENT_TYPE" => VCARD
+ assert_equal 404, last_response.status
+
+ delete "/dav/addressbook/znorth.vcf"
+ assert_equal 404, last_response.status
+ assert_includes store.contact("znorth").vcard.to_s, "FN:Zed\r\n"
+ end
+ end
+
+ def test_each_user_syncs_their_own_book
+ with_contacts({"aiden" => "Aiden"}) do |store|
+ store.put("znorth", ProTacts::VCard.new(card("znorth", "Zed")))
+ FixtureData.seed_group(store, name: "sync:Zoë Chen", members: ["znorth"])
+
+ request "/dav/addressbook/", method: "PROPFIND", "HTTP_DEPTH" => "1", input: etag_only_propfind
+ refute_includes last_response.body, "znorth"
+
+ header "Tailscale-User-Name", "=?utf-8?q?Zo=C3=AB_Chen?="
+ request "/dav/addressbook/", method: "PROPFIND", "HTTP_DEPTH" => "1", input: etag_only_propfind
+ assert_includes last_response.body, "aiden.vcf"
+ assert_includes last_response.body, "znorth.vcf"
+ end
+ end
+
+ # A card a client creates stays in the collection it was written to.
+ def test_a_put_create_joins_the_writers_book
+ with_contacts({}) do |store|
+ put_request "new", card("new", "New"), "CONTENT_TYPE" => VCARD, "HTTP_IF_NONE_MATCH" => "*"
+
+ assert_equal 201, last_response.status
+ assert_equal Set["new"], store.book("Test User")
+ end
+ end
+
+ # RFC 6578 section 3.5.2: a card whose mapping left the collection is
+ # reported removed, which a card leaving the book is.
+ def test_a_card_leaving_the_book_reaches_a_syncing_client_as_a_removal
+ with_contacts({"aiden" => "Aiden", "znorth" => "Zed"}) do |store|
+ request "/dav/addressbook/", method: "REPORT", input: sync_collection("")
+ token = last_response.body[%r{<d:sync-token>([^<]+)</d:sync-token>}, 1]
+ everyone = store.all_groups.find { it.name == ProTacts::Store::EVERYONE }.id
+
+ store.remove_member(everyone, "znorth")
+ request "/dav/addressbook/", method: "REPORT", input: sync_collection(token)
+
+ body = last_response.body
+ assert_equal 207, last_response.status
+ assert_includes body, "<d:href>/dav/addressbook/znorth.vcf</d:href>"
+ assert_includes body, "HTTP/1.1 404 Not Found"
+ refute_includes body, "aiden"
+ end
+ end
+
# RFC 6352 section 8.6 defines addressbook-query, which this server does
# not implement. Before the DAV:supported-report precondition was enforced
# it fell through to the multiget branch, found no hrefs, and answered with