Refuse sync tokens from a replaced database
Every deployed client takes one 410 and resyncs at its first sync
after this lands: an old token names no database, so there is nothing
to check it against.
Assisted-by: GLM-5.3 via pi
diff --git a/Rakefile b/Rakefile
index 40a5a93..04c1f07 100644
--- a/Rakefile
+++ b/Rakefile
@@ -59,7 +59,7 @@ task :fixtures do
# deleting a card, and the recording after it must not start from
# what that one left behind.
ExchangeFixtures.all.each do |recording|
- store = FixtureData.install(data_dir / recording.directory.basename)
+ store = FixtureData.install(data_dir / recording.directory.basename, genesis: ExchangeFixtures::GENESIS)
ProTacts::Web.store = store
recording.record_responses(ProTacts::Web)
store.close
diff --git a/docs/plans/2026-09-24-sync-tokens-name-their-database.md b/docs/plans/2026-09-24-sync-tokens-name-their-database.md
new file mode 100644
index 0000000..1fa8de3
--- /dev/null
+++ b/docs/plans/2026-09-24-sync-tokens-name-their-database.md
@@ -0,0 +1,75 @@
+# Sync tokens name their database
+
+2026-09-24. A sync token now carries the id of the database that minted
+it, so a token held against a replaced database is refused with the
+410 that makes a client resync, rather than answered with a delta
+computed against a history it never saw.
+
+## What went wrong
+
+The token was `http://pro-tacts/sync/<sequence>/<digest>` — the change
+log's sequence and the book's digest, and nothing that said which
+database minted it. Sequences are per-database counters, so any other
+database with overlapping numbers would accept it: `rake dev` reseeds a
+throwaway database on every start, to the same ctag every time, and a
+dump restored onto the same machine replays a truncated history. In
+both, a device's token at or below the new latest sequence passed the
+route's guard and was answered with a delta from a log it had never
+seen. Nothing errored. The divergence is silent and permanent: the new
+log holds no entries for cards the old one deleted, so a client never
+hears those removals and keeps serving them out of its cache forever.
+
+This was found, not theorized: the per-user-books scenario testing
+(2026-09-16, task `mny`) watched a client token from an earlier dev
+session validate against a reseeded database and said so in a comment
+— "a per-database value in the token would 410 it."
+
+## The token
+
+The spelling is now `http://pro-tacts/sync/<sequence>/<digest>/<database>`,
+where the database half is `Store#database_id`: the first sixteen hex
+digits of the SHA-256 of the change log's first entry — its `created_at`
+alone. `SyncToken.read` takes the id, so a token not carrying this
+database's gets the 410 with `DAV:valid-sync-token` the route already
+sent (RFC 6578 section 3.2, marshalled per RFC 4918 section 16), and
+the client takes the bootstrap path both clients were proven to walk
+(task `mny`, scenario 6).
+
+The id is derived, not stored. A reseed or a restore writes a new
+first entry at a new moment, so every replaced database refuses every
+token the old one issued. A live database's first entry never moves:
+the log is append-only, no write touches a row's `created_at`, and
+`Store#reid` — the one writer that rewrites a log row — moves only its
+`card_id`, which is why the stamp is the digest's whole input.
+
+Rejected on the way here:
+
+- A minted id in a one-row table — correct, but a seventh kind of
+ state: a migration, a mint-on-open, and a fact to keep out of the
+ dump, all heavier than the bug it closes.
+- The hostname, configured or off the request — it names the
+ deployment, not the database, and both scenarios above happen on one
+ machine under one name. It would also refuse the same database
+ reached under a second name, a resync nothing asked for.
+- A digest of the whole log — O(history) on every poll, and macOS
+ polls.
+
+## The costs
+
+Every deployed device 410s once, at its first sync after this lands,
+and bootstraps. That is unavoidable, not merely cheap: an old token
+names no database, so there is nothing to check it against. Two edges
+are accepted with it: a token taken from a book that was empty 410s
+once after the first card lands, a resync of nothing; and two
+databases first written within the same millisecond would share an id —
+a coincidence no real replacement fits, and the price of deriving the
+id rather than minting one.
+
+## The fixtures
+
+Recorded responses embed the token, so a replay book must answer as
+the same database every run: `FixtureData.install(genesis:)` pins its
+first change's stamp (`ExchangeFixtures::GENESIS`, year 2000 on
+purpose), and the replay and `rake fixtures` both pass it. `rake dev`
+seeds unpinned, so every dev start is a fresh id — which is the
+original observation, now the behavior.
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 304d1eb..fba4068 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -1,4 +1,5 @@
require "date"
+require "digest"
require "json"
require "pathname"
require "sequel"
@@ -261,6 +262,25 @@ module ProTacts
change_log.order(Sequel.desc(:sequence)).first&.fetch(:sequence).to_i
end
+ # The id a sync token names this database by: a digest of the
+ # change log's first entry — the write every token's sequences
+ # count from, and the one row nothing ever rewrites. #reid can move
+ # its card_id, so the stamp is the digest's whole input; no write
+ # touches a log row's created_at. Derived rather than stored
+ # (docs/plans/2026-09-24-sync-tokens-name-their-database.md), so a
+ # reseed or a dump restored — a new first entry at a new moment —
+ # makes every token the old database issued stop reading, and the
+ # route answers the 410 rather than a delta against a history the
+ # client never saw. An empty log digests nothing, so a token taken
+ # from a book that was empty 410s once after its first card lands:
+ # a resync of nothing.
+ #: () -> String
+ def database_id
+ first = change_log.order(:sequence).first
+ stamp = first.nil? ? "" : first.fetch(:created_at).to_s
+ Digest::SHA256.hexdigest(stamp)[0, 16].to_s
+ end
+
# Birthdays that land on a coming calendar day, in arrival order:
# today's first, and one already passed this year wrapped onto next
# year rather than dropped — the wrap that keeps "upcoming" a
diff --git a/lib/pro_tacts/sync_token.rb b/lib/pro_tacts/sync_token.rb
index d321f94..671701a 100644
--- a/lib/pro_tacts/sync_token.rb
+++ b/lib/pro_tacts/sync_token.rb
@@ -1,12 +1,17 @@
require "digest"
module ProTacts
- # A sync token as this server spells one: the ctag it was minted at
- # and the book it was minted for, in a URI. Opaque to the client
- # (RFC 6578 section 3) and the URI form is conventional, but not
- # opaque to this server, which has to tell its own token from one
- # another book minted — the one a rename leaves a user holding
- # included (docs/plans/2026-09-12-per-user-books.md, "The wire").
+ # A sync token as this server spells one: the ctag it was minted at,
+ # the book it was minted for, and the database that minted it, in a
+ # URI. Opaque to the client (RFC 6578 section 3) and the URI form is
+ # conventional, but not opaque to this server, which has to tell its
+ # own token from one another book minted — the one a rename leaves a
+ # user holding included (docs/plans/2026-09-12-per-user-books.md,
+ # "The wire") — and from one another database minted: a reseed or a
+ # dump restored repeats the sequences, and the id is what refuses
+ # that token rather than answering a delta against a history it
+ # never saw
+ # (docs/plans/2026-09-24-sync-tokens-name-their-database.md).
# Built on the ctag so that a client polling either one sees changes
# at the same points.
module SyncToken
@@ -19,18 +24,19 @@ module ProTacts
Digest::SHA256.hexdigest(login)[0, 16].to_s
end
- #: (String ctag, String book) -> String
- def self.mint(ctag, book)
- "#{PREFIX}#{ctag}/#{book}"
+ #: (String ctag, String book, String database_id) -> String
+ def self.mint(ctag, book, database_id)
+ "#{PREFIX}#{ctag}/#{book}/#{database_id}"
end
- # The sequence a token carries, or nil for one this book did not
- # mint — another book's, a rename's leftover, or a string that is
- # no token of ours at all. Whether the sequence is one the change
- # log can still answer from is the caller's to judge.
- #: (String token, String book) -> Integer?
- def self.read(token, book)
- token[%r{\A#{Regexp.escape(PREFIX)}(\d+)/#{Regexp.escape(book)}\z}, 1]&.to_i
+ # The sequence a token carries, or nil for one this database did
+ # not mint — another database's (a reseed's, a restore's), another
+ # book's, a rename's leftover, or a string that is no token of
+ # ours at all. Whether the sequence is one the change log can
+ # still answer from is the caller's to judge.
+ #: (String token, String book, String database_id) -> Integer?
+ def self.read(token, book, database_id)
+ token[%r{\A#{Regexp.escape(PREFIX)}(\d+)/#{Regexp.escape(book)}/#{Regexp.escape(database_id)}\z}, 1]&.to_i
end
end
end
diff --git a/lib/pro_tacts/web/dav.rb b/lib/pro_tacts/web/dav.rb
index 8af8cb8..bfbbc97 100644
--- a/lib/pro_tacts/web/dav.rb
+++ b/lib/pro_tacts/web/dav.rb
@@ -162,8 +162,9 @@ 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 and whose book
- # it was issued for (SyncToken), and the delta is the
+ # The token carries the change log's sequence, whose book
+ # it was issued for, and which database issued it
+ # (SyncToken), 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,
@@ -190,7 +191,7 @@ module ProTacts
etag_response(d, it)
end
end
- elsif (sequence = SyncToken.read(token, book_digest)) && sequence <= store.latest_sequence
+ elsif (sequence = SyncToken.read(token, book_digest, store.database_id)) && sequence <= store.latest_sequence
net = store.changes(after: sequence).map { it.card_id }.uniq
multistatus("card", sync_token:) do |d|
net.each do |id|
@@ -199,8 +200,9 @@ module ProTacts
end
end
else
- # A token this server never issued, one issued for another
- # book, or one naming a state past the present: the
+ # A token this server never issued, one another
+ # database or book minted, 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 —
@@ -346,10 +348,12 @@ module ProTacts
end
# This request's token, minted at the ctag read above so that the
- # one served here and the one a delta is measured from agree.
+ # one served here and the one a delta is measured from agree, and
+ # for the database this store serves, so no other's client can
+ # spend it (Store#database_id).
#: () -> String
def sync_token
- SyncToken.mint(ctag, book_digest)
+ SyncToken.mint(ctag, book_digest, store.database_id)
end
#: () -> String
diff --git a/test/fixture_data.rb b/test/fixture_data.rb
index 21307b5..ce28b48 100644
--- a/test/fixture_data.rb
+++ b/test/fixture_data.rb
@@ -182,8 +182,14 @@ module FixtureData
end
# Builds the database and returns a store still open on it, for the
- # caller to hand to the app the way config.ru does.
- def self.install(directory)
+ # caller to hand to the app the way config.ru does. `genesis` pins the
+ # first change's stamp, and with it the database id its sync tokens
+ # embed (Store#database_id): a recorded session's responses are byte
+ # for byte the replay's only if both books answer as the same
+ # database, and a real seed moment never is twice. Unpinned by
+ # default — `rake dev` reseeding is what a fresh id per start is for
+ # (ExchangeFixtures::GENESIS for the pinned spelling).
+ def self.install(directory, genesis: nil)
directory = Pathname.new(directory)
FileUtils.rm_rf(directory)
FileUtils.mkdir_p(directory)
@@ -193,6 +199,7 @@ module FixtureData
seed.each do |id, card|
store.put(id, ProTacts::VCard.new(card))
end
+ database(store)[:changes].where(sequence: 1).update(created_at: genesis) if genesis
backdate(store, seed.keys)
seed_groups(store)
store
diff --git a/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response b/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
index 9257e90..098b4af 100644
--- a/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
+++ b/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
@@ -19,7 +19,7 @@ Content-Type: text/xml
</d:supported-report>
</d:supported-report-set>
<cs:getctag>22</cs:getctag>
- <d:sync-token>http://pro-tacts/sync/22/336e00f5e1ec2674</d:sync-token>
+ <d:sync-token>http://pro-tacts/sync/22/336e00f5e1ec2674/6ce9dc47db17f26e</d:sync-token>
<d:current-user-privilege-set>
<d:privilege>
<d:read/>
diff --git a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
index 8313cd5..0ea83f5 100644
--- a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
@@ -19,7 +19,7 @@ Content-Type: text/xml
</d:supported-report>
</d:supported-report-set>
<cs:getctag>22</cs:getctag>
- <d:sync-token>http://pro-tacts/sync/22/336e00f5e1ec2674</d:sync-token>
+ <d:sync-token>http://pro-tacts/sync/22/336e00f5e1ec2674/6ce9dc47db17f26e</d:sync-token>
<d:current-user-privilege-set>
<d:privilege>
<d:read/>
diff --git a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
index 9257e90..098b4af 100644
--- a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
+++ b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
@@ -19,7 +19,7 @@ Content-Type: text/xml
</d:supported-report>
</d:supported-report-set>
<cs:getctag>22</cs:getctag>
- <d:sync-token>http://pro-tacts/sync/22/336e00f5e1ec2674</d:sync-token>
+ <d:sync-token>http://pro-tacts/sync/22/336e00f5e1ec2674/6ce9dc47db17f26e</d:sync-token>
<d:current-user-privilege-set>
<d:privilege>
<d:read/>
diff --git a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
index 8313cd5..0ea83f5 100644
--- a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
@@ -19,7 +19,7 @@ Content-Type: text/xml
</d:supported-report>
</d:supported-report-set>
<cs:getctag>22</cs:getctag>
- <d:sync-token>http://pro-tacts/sync/22/336e00f5e1ec2674</d:sync-token>
+ <d:sync-token>http://pro-tacts/sync/22/336e00f5e1ec2674/6ce9dc47db17f26e</d:sync-token>
<d:current-user-privilege-set>
<d:privilege>
<d:read/>
diff --git a/test/fixtures/macos-exchange/README.md b/test/fixtures/macos-exchange/README.md
index 8b93211..06c3502 100644
--- a/test/fixtures/macos-exchange/README.md
+++ b/test/fixtures/macos-exchange/README.md
@@ -38,8 +38,11 @@ session received.
Step 12 is a 410 the client really received, captured 2026-09-16 under
`rake dev` with the dev login switched so the token's digest named
-another login's book. Its replay refuses it for the same reason: the
-digest is not the replay login's. After the 410, macOS sent a ctag
+another login's book. Its replay refuses it one step earlier: the
+recorded token predates tokens naming their database
+(docs/plans/2026-09-24-sync-tokens-name-their-database.md), so no part
+of it names the replay book's database — the same 410 either way. After
+the 410, macOS sent a ctag
PROPFIND, the Depth 1 listing, a GET for the one card it lacked, the
bootstrap PROPFIND, and a sync-collection report with the fresh token.
The listing and bootstrap match steps 06 and 09 byte for byte, and the
diff --git a/test/pro_tacts/exchange_fixtures.rb b/test/pro_tacts/exchange_fixtures.rb
index cec37a4..8f743af 100644
--- a/test/pro_tacts/exchange_fixtures.rb
+++ b/test/pro_tacts/exchange_fixtures.rb
@@ -34,6 +34,13 @@ class ExchangeFixtures < Data.define(:directory)
# a login back.
REPLAY_LOGIN = "replay@example.com"
+ # The stamp a replayed book's first change carries (FixtureData.install
+ # pins it), so the database id its sync tokens embed — the digest of
+ # that first entry, Store#database_id — answers the same recorded as
+ # replayed. A real seed moment differs every run, and the recorded
+ # responses would drift with it.
+ GENESIS = "2000-01-01T00:00:00.000Z" #: String
+
# Every recording, for the callers that run all of them.
def self.all
[MACOS, IOS]
diff --git a/test/pro_tacts/test_exchanges.rb b/test/pro_tacts/test_exchanges.rb
index 05eed77..dd194bf 100644
--- a/test/pro_tacts/test_exchanges.rb
+++ b/test/pro_tacts/test_exchanges.rb
@@ -42,7 +42,7 @@ class ExchangesTest < Minitest::Test
# one the rest of the suite reads, built fresh so a replay never depends
# on what a previous run left behind.
directory = Pathname.new(__dir__).parent.parent / "tmp" / recording.directory.basename
- store = FixtureData.install(directory)
+ store = FixtureData.install(directory, genesis: ExchangeFixtures::GENESIS)
original = ProTacts::Web.store
ProTacts::Web.store = store
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 603ec83..ee8cc50 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1,6 +1,7 @@
require_relative "../test_helper"
require_relative "../photo_card"
+require "digest"
require "pathname"
require "tmpdir"
@@ -242,6 +243,57 @@ class StoreTest < Minitest::Test
end
end
+ ## The database a sync token names
+
+ def test_the_databases_id_is_the_digest_of_its_first_change
+ with_store({"aiden" => AIDEN}) do |store|
+ stamp = database(store)[:changes].where(sequence: 1).sole.fetch(:created_at)
+
+ assert_equal Digest::SHA256.hexdigest(stamp)[0, 16], store.database_id
+ end
+ end
+
+ def test_the_databases_id_survives_reopening
+ Dir.mktmpdir do |dir|
+ path = Pathname.new(dir) / "contacts.db"
+ ids = Array.new(2) {
+ ProTacts::Store.connect(path) { |store| store.database_id }
+ }
+
+ assert_equal ids.first, ids.last
+ end
+ end
+
+ # The digest's entropy is the first write's moment, so two databases
+ # seeded alike differ by their stamps — a reseed and a restore, both
+ # moments apart. Two stores in one test can land inside the same
+ # millisecond, so this seeds a different first card: the digest then
+ # answers the difference whatever the clock did.
+ def test_two_databases_answer_different_ids
+ ids = [AIDEN, ZED].map { |card|
+ Dir.mktmpdir do |dir|
+ ProTacts::Store.connect(Pathname.new(dir) / "contacts.db") do |store|
+ store.put("first", vcard(card))
+ store.database_id
+ end
+ end
+ }
+
+ refute_equal ids.first, ids.last
+ end
+
+ # The empty book's edge: an empty log digests nothing, so its token
+ # 410s once after the first card lands — a resync of nothing.
+ def test_an_empty_book_answers_an_id_until_its_first_card_lands
+ with_store do |store|
+ empty = store.database_id
+ assert_match(/\A\h{16}\z/, empty)
+
+ store.put("aiden", vcard(AIDEN))
+ refute_equal empty, store.database_id
+ end
+ end
+
## Timestamps
def test_a_stored_card_is_stamped
diff --git a/test/pro_tacts/test_sync_token.rb b/test/pro_tacts/test_sync_token.rb
index 2cee828..6ffca1d 100644
--- a/test/pro_tacts/test_sync_token.rb
+++ b/test/pro_tacts/test_sync_token.rb
@@ -4,32 +4,41 @@ require "pro_tacts/sync_token"
class SyncTokenTest < Minitest::Test
BOOK = ProTacts::SyncToken.book("alpha@example.com")
+ DATABASE = "0f1e2d3c4b5a6978" #: String
def test_a_minted_token_reads_back_as_its_sequence
- assert_equal 22, ProTacts::SyncToken.read(ProTacts::SyncToken.mint("22", BOOK), BOOK)
+ assert_equal 22, ProTacts::SyncToken.read(ProTacts::SyncToken.mint("22", BOOK, DATABASE), BOOK, DATABASE)
end
# Zero is what a book nothing has been written to carries, and it has
# to read back as a sequence rather than as no token at all.
def test_the_zero_sequence_reads_back
- assert_equal 0, ProTacts::SyncToken.read(ProTacts::SyncToken.mint("0", BOOK), BOOK)
+ assert_equal 0, ProTacts::SyncToken.read(ProTacts::SyncToken.mint("0", BOOK, DATABASE), BOOK, DATABASE)
end
def test_another_books_token_reads_as_nothing
other = ProTacts::SyncToken.book("zed@example.com")
- assert_nil ProTacts::SyncToken.read(ProTacts::SyncToken.mint("22", other), BOOK)
+ assert_nil ProTacts::SyncToken.read(ProTacts::SyncToken.mint("22", other, DATABASE), BOOK, DATABASE)
end
- # The shape macOS was recorded sending before books existed
- # (test/fixtures/macos-exchange/08-report-sync-collection).
- def test_a_token_from_before_books_reads_as_nothing
- assert_nil ProTacts::SyncToken.read("http://pro-tacts/sync/1", BOOK)
+ def test_another_databases_token_reads_as_nothing
+ other = "1111111111111111"
+
+ assert_nil ProTacts::SyncToken.read(ProTacts::SyncToken.mint("22", BOOK, other), BOOK, DATABASE)
+ end
+
+ # The shapes macOS was recorded sending before tokens named their
+ # database: no book at all (fixture 08), and a book but no database
+ # (fixture 12's request, the spelling this change retires).
+ def test_a_token_from_before_databases_reads_as_nothing
+ assert_nil ProTacts::SyncToken.read("http://pro-tacts/sync/1", BOOK, DATABASE)
+ assert_nil ProTacts::SyncToken.read("http://pro-tacts/sync/22/#{BOOK}", BOOK, DATABASE)
end
def test_anything_that_is_not_a_token_reads_as_nothing
- ["", "1", "http://pro-tacts/sync//#{BOOK}", "http://pro-tacts/sync/22/#{BOOK}/"].each do |text|
- assert_nil ProTacts::SyncToken.read(text, BOOK), text
+ ["", "1", "http://pro-tacts/sync//#{BOOK}/#{DATABASE}", "http://pro-tacts/sync/22/#{BOOK}/#{DATABASE}/"].each do |text|
+ assert_nil ProTacts::SyncToken.read(text, BOOK, DATABASE), text
end
end
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index ee16760..4c1a84f 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -792,8 +792,9 @@ class WebTest < Minitest::Test
end
# A sync token as the route issues one to a user (Web#sync_token).
- def issued_token(sequence, login: "test@example.com")
- "http://pro-tacts/sync/#{sequence}/#{Digest::SHA256.hexdigest(login)[0, 16]}"
+ # `database` spells a token another database minted, the wyn case.
+ def issued_token(store, sequence, login: "test@example.com", database: nil)
+ "http://pro-tacts/sync/#{sequence}/#{Digest::SHA256.hexdigest(login)[0, 16]}/#{database || store.database_id}"
end
def etag_only_propfind
@@ -891,7 +892,7 @@ class WebTest < Minitest::Test
# only, address-data never.
def test_sync_collection_reports_the_delta_since_the_token
with_contacts({"aiden" => "Aiden"}) do |store|
- request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(0))
+ request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(store, 0))
assert_equal 207, last_response.status
assert_includes last_response.body, "/dav/addressbook/aiden.vcf"
@@ -915,20 +916,20 @@ class WebTest < Minitest::Test
# The initial sync, RFC 6578 section 3.4.
def test_sync_collection_with_no_token_lists_every_member
- with_contacts({"aiden" => "Aiden", "znorth" => "Znorth"}) do
+ with_contacts({"aiden" => "Aiden", "znorth" => "Znorth"}) do |store|
request "/dav/addressbook/", method: "REPORT", input: sync_collection("")
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>#{issued_token(2)}</d:sync-token>"
+ assert_includes last_response.body, "<d:sync-token>#{issued_token(store, 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(issued_token(2))
+ request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(store, 2))
token = last_response.body[%r{<d:sync-token>(.+)</d:sync-token>}, 1]
store.delete("znorth")
@@ -946,8 +947,8 @@ class WebTest < Minitest::Test
# present: the DAV:valid-sync-token precondition of RFC 6578
# 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(issued_token(9))
+ with_contacts({"aiden" => "Aiden"}) do |store|
+ request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(store, 9))
assert_equal 410, last_response.status
assert_includes last_response.body, "<d:valid-sync-token/>"
@@ -962,14 +963,33 @@ class WebTest < Minitest::Test
# 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, login: "zoë@example.com"))
+ with_contacts({"aiden" => "Aiden"}) do |store|
+ request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(store, 1, login: "zoë@example.com"))
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))
+ request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(store, 1))
+ assert_equal 207, last_response.status
+ end
+ end
+
+ # A token names the database that minted it, so a device holding one
+ # from before a reseed or a restore — the same sequences over a
+ # different history — resyncs rather than taking a delta against a
+ # history it never saw
+ # (docs/plans/2026-09-24-sync-tokens-name-their-database.md).
+ def test_sync_collection_refuses_a_token_from_another_database
+ with_contacts({"aiden" => "Aiden"}) do |store|
+ request "/dav/addressbook/", method: "REPORT",
+ input: sync_collection(issued_token(store, 0, database: "1111111111111111"))
+
+ assert_equal 410, last_response.status
+ assert_includes last_response.body, "<d:valid-sync-token/>"
+
+ request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(store, 0))
+
assert_equal 207, last_response.status
end
end