Serve contacts from KDL files on disk
The fixture contact keeps the ID from the recorded macOS exchange so
the byte-for-byte replay still resolves the hrefs the client actually
requested.
Assisted-by: GLM-5.3 via pi
diff --git a/.gitignore b/.gitignore
index e6a5295..8533978 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@
/servers
/carddav.mobileconfig
/bin
+/data
diff --git a/README.md b/README.md
index f53eeb2..fed7ef5 100644
--- a/README.md
+++ b/README.md
@@ -17,12 +17,23 @@ A CardDAV server for my family.
## Status
-A read-only skeleton. Every response in `lib/pro_tacts/web.rb` is hardcoded:
-one principal, one address book, one vCard. Nothing is parsed, stored, or
-looked up yet. macOS Contacts displays the hardcoded card over Tailscale
-serve as of 2026-08-14, so later work has a known-good baseline to change.
-See `docs/plans/2026-08-12-one-card-on-macos.md` for what that milestone
-established.
+Read-only and serving real data. Contacts live as KDL files under
+`data/contacts` (override with `PRO_TACTS_CONTACTS_DIR`), one contact per
+file, the filename doubling as the contact ID and the vCard UID:
+
+```kdl
+contact {
+ name "John Smith"
+ phone "+1-555-1234" type="mobile"
+ email "john@example.com"
+}
+```
+
+macOS Contacts displays them over Tailscale serve as of 2026-08-14, so
+later work has a known-good baseline to change. See
+`docs/plans/2026-08-12-one-card-on-macos.md` for what that milestone
+established. Still placeholders: etags and ctags are constants rather
+than derived from file state, and there is no authentication yet.
## The minimal set macOS Contacts needs
@@ -82,7 +93,7 @@ implementing it means reading several RFCs together:
[rfc6350]: https://datatracker.ietf.org/doc/html/rfc6350
RFC 6352 requires an address book collection to support vCard 3.0 and treats
-4.0 as optional, which is why the hardcoded card here is `VERSION:3.0` even
+4.0 as optional, which is why contacts are rendered as `VERSION:3.0` even
though `docs/plans/2026-01-12-carddav-reference.md` shows 4.0 examples.
Two properties macOS depends on are not in any RFC. They come from Apple's
diff --git a/Rakefile b/Rakefile
index 5006457..8a4365a 100644
--- a/Rakefile
+++ b/Rakefile
@@ -19,6 +19,9 @@ end
desc "Regenerate macOS exchange response fixtures from current responses"
task :fixtures do
ENV["RACK_ENV"] = "test"
+ # Mirrors test/test_helper.rb, which cannot be required here without
+ # minitest/autorun running its at_exit hook inside rake.
+ ENV["PRO_TACTS_CONTACTS_DIR"] = File.expand_path("test/fixtures/contacts", __dir__)
require "pro_tacts/web"
require_relative "test/pro_tacts/exchange_fixtures"
ExchangeFixtures.record_responses(ProTacts::Web)
diff --git a/lib/pro_tacts/config.rb b/lib/pro_tacts/config.rb
index 9781472..3d9804b 100644
--- a/lib/pro_tacts/config.rb
+++ b/lib/pro_tacts/config.rb
@@ -32,6 +32,12 @@ module ProTacts
!value.nil? && value.match?(TRUTHY)
end
+ # Directory of contact KDL files, one contact per file; the filename
+ # is the contact ID. See docs/plans/2026-01-12-carddav-architecture.md.
+ def contacts_dir
+ @env.fetch("PRO_TACTS_CONTACTS_DIR", "data/contacts")
+ end
+
# Where the debug logger writes. A path, overridable with
# PRO_TACTS_DEBUG_LOG; "stderr" keeps it on the process's stderr.
def debug_log_path
diff --git a/lib/pro_tacts/contacts.rb b/lib/pro_tacts/contacts.rb
new file mode 100644
index 0000000..70c2e6d
--- /dev/null
+++ b/lib/pro_tacts/contacts.rb
@@ -0,0 +1,57 @@
+require "kdl"
+require "pathname"
+require "sentry-ruby"
+
+require "pro_tacts/vcard"
+
+module ProTacts
+ # Reads contacts from a directory of KDL files, one contact per file.
+ # The filename minus extension is the contact ID, which maps to the
+ # vCard UID (see docs/plans/2026-01-12-carddav-architecture.md).
+ class Contacts
+ Contact = Struct.new(:id, :vcard, keyword_init: true)
+
+ # IDs end up in paths, and they arrive from client-supplied hrefs, so
+ # anything outside this charset simply does not exist.
+ ID_FORMAT = /\A[\w-]+\z/
+
+ attr_reader :directory
+
+ def initialize(directory)
+ @directory = Pathname.new(directory)
+ unless @directory.directory?
+ raise ArgumentError, "contacts directory not found: #{@directory}"
+ end
+ end
+
+ def all
+ directory.glob("*.kdl").sort.map { load(it) }.compact
+ end
+
+ def find(id)
+ return nil unless id.match?(ID_FORMAT)
+
+ path = directory / "#{id}.kdl"
+ load(path) if path.file?
+ end
+
+ private
+
+ # Rendering at load time doubles as validation: a file whose card
+ # cannot render is reported and skipped rather than taking the whole
+ # address book down with it. Sentry is a no-op while uninitialized,
+ # so tests need no DSN.
+ def load(path)
+ id = path.basename(".kdl").to_s
+ nodes = KDL.parse(path.read).nodes
+ unless nodes.length == 1 && nodes.first.name == "contact"
+ raise ArgumentError, "expected exactly one contact node"
+ end
+
+ Contact.new(id:, vcard: VCard.render(nodes.first, uid: id))
+ rescue KDL::Error, ArgumentError, SystemCallError => e
+ Sentry.capture_message("skipping contact file #{path}: #{e.class}: #{e.message}")
+ nil
+ end
+ end
+end
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index d8b4797..b237454 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -21,9 +21,11 @@ unless ProTacts.config.test?
end
require "rack/rewindable_input"
+require "nokogiri"
require "roda"
require "pro_tacts/debug_logger"
+require "pro_tacts/contacts"
require "roda/plugins/dav_verbs"
module ProTacts
@@ -46,6 +48,12 @@ module ProTacts
"Not Found"
end
+ # Placeholder until etags and ctags are derived from file state; the
+ # constants only need to be present and stable within a session.
+ CONTACT_ETAG = %("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2")
+ COLLECTION_CTAG = "ctag-2"
+ SYNC_TOKEN = "http://pro-tacts/sync/2"
+
route do |r|
r.is "" do
r.propfind do
@@ -138,31 +146,16 @@ module ProTacts
response["Content-Type"] = "text/xml"
response.status = 207
- contact_etag = %("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2")
- collection_ctag = "ctag-2"
depth = request.env.fetch("HTTP_DEPTH", "infinity")
# Check if this is an etag-only request (Depth:1 listing)
etag_only = body.include?("getetag") && !body.include?("displayname") && !body.include?("resourcetype")
- if etag_only
- # Etag-only ask wants the members; the collection self-entry
- # is omitted until a client is found to need it.
- collection_response = ""
-
- contact_response = <<~XML
- <d:response>
- <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
- <d:propstat>
- <d:prop>
- <d:getetag>#{contact_etag}</d:getetag>
- </d:prop>
- <d:status>HTTP/1.1 200 OK</d:status>
- </d:propstat>
- </d:response>
- XML
- else
- # Full property request (Depth:0 collection info)
+ # Etag-only asks want the members; the collection self-entry
+ # is omitted until a client is found to need it. Full property
+ # requests (Depth:0 collection info) get the collection entry.
+ collection_response = ""
+ unless etag_only
collection_response = <<~XML
<d:response>
<d:href>/dav/addressbook/</d:href>
@@ -177,20 +170,8 @@ module ProTacts
<d:report><d:sync-collection/></d:report>
</d:supported-report>
</d:supported-report-set>
- <cs:getctag>#{collection_ctag}</cs:getctag>
- <d:sync-token>http://pro-tacts/sync/2</d:sync-token>
- </d:prop>
- <d:status>HTTP/1.1 200 OK</d:status>
- </d:propstat>
- </d:response>
- XML
-
- contact_response = <<~XML
- <d:response>
- <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
- <d:propstat>
- <d:prop>
- <d:getetag>#{contact_etag}</d:getetag>
+ <cs:getctag>#{COLLECTION_CTAG}</cs:getctag>
+ <d:sync-token>#{SYNC_TOKEN}</d:sync-token>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -199,7 +180,7 @@ module ProTacts
end
# Depth: 0 returns only collection, Depth: 1 includes members
- members = depth == "0" ? "" : contact_response
+ members = depth == "0" ? "" : contacts.all.map { etag_response(it.id) }.join
<<~XML
<?xml version="1.0" encoding="UTF-8"?>
@@ -216,60 +197,106 @@ module ProTacts
response["Content-Type"] = "text/xml"
response.status = 207
- contact_etag = %("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2")
- if body.include?("sync-collection")
+ doc = Nokogiri::XML(body)
+ doc.remove_namespaces!
+
+ if doc.root.name == "sync-collection"
# The warm-sync ask is etag-only; a changed etag sends the
# client back through multiget, so no address-data here.
- card_data = ""
+ responses = contacts.all.map { etag_response(it.id) }
else
- vcard = <<~VCARD.chomp
- BEGIN:VCARD
- VERSION:3.0
- PRODID:-//Apple Inc.//macOS 14.6.1//EN
- N:Contact;Test;;;
- FN:Test Contact
- REV:2026-01-14T00:00:00Z
- UID:AB12C345-6789-0DEF-1234-567890ABCDEF
- END:VCARD
- VCARD
- card_data = "<card:address-data>#{vcard}</card:address-data>"
+ wants_cards = doc.xpath("//address-data").any?
+
+ responses = doc.xpath("//href").map { it.text }.map { |requested|
+ id = requested[%r{\A/dav/addressbook/([^/]+)\.vcf\z}, 1]
+ contact = id && contacts.find(id)
+
+ if contact
+ wants_cards ? card_response(contact) : etag_response(contact.id)
+ else
+ missing_response(requested)
+ end
+ }
end
<<~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/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
- <d:propstat>
- <d:prop>
- <d:getetag>#{contact_etag}</d:getetag>
- #{card_data unless card_data.empty?}
- </d:prop>
- <d:status>HTTP/1.1 200 OK</d:status>
- </d:propstat>
- </d:response>
+ #{responses.join}
</d:multistatus>
XML
end
- r.get String do |uid|
- response["Content-Type"] = "text/vcard; charset=utf-8"
- response["ETag"] = %("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2")
-
- <<~VCARD.gsub(/^ +/, "")
- BEGIN:VCARD
- VERSION:3.0
- PRODID:-//Apple Inc.//macOS 14.6.1//EN
- N:Contact;Test;;;
- FN:Test Contact
- REV:2026-01-14T00:00:00Z
- UID:AB12C345-6789-0DEF-1234-567890ABCDEF
- END:VCARD
- VCARD
+ r.get String do |filename|
+ contact = contacts.find(filename.delete_suffix(".vcf"))
+
+ # No match falls through to the empty-body 404 that the
+ # not_found handler fills in.
+ if contact
+ response["Content-Type"] = "text/vcard; charset=utf-8"
+ response["ETag"] = CONTACT_ETAG
+ contact.vcard
+ end
end
end
end
end
+
+ private
+
+ # Instantiated per request so tests can point it at a fixture
+ # directory through ProTacts.config=; caching belongs with real etags.
+ def contacts
+ @contacts ||= Contacts.new(ProTacts.config.contacts_dir)
+ end
+
+ def contact_href(id)
+ "/dav/addressbook/#{id}.vcf"
+ end
+
+ def etag_response(id)
+ <<~XML
+ <d:response>
+ <d:href>#{contact_href(id)}</d:href>
+ <d:propstat>
+ <d:prop>
+ <d:getetag>#{CONTACT_ETAG}</d:getetag>
+ </d:prop>
+ <d:status>HTTP/1.1 200 OK</d:status>
+ </d:propstat>
+ </d:response>
+ XML
+ end
+
+ def card_response(contact)
+ <<~XML
+ <d:response>
+ <d:href>#{contact_href(contact.id)}</d:href>
+ <d:propstat>
+ <d:prop>
+ <d:getetag>#{CONTACT_ETAG}</d:getetag>
+ <card:address-data>#{xml_escape(contact.vcard.chomp)}</card:address-data>
+ </d:prop>
+ <d:status>HTTP/1.1 200 OK</d:status>
+ </d:propstat>
+ </d:response>
+ XML
+ end
+
+ def missing_response(requested)
+ <<~XML
+ <d:response>
+ <d:href>#{xml_escape(requested)}</d:href>
+ <d:status>HTTP/1.1 404 Not Found</d:status>
+ </d:response>
+ XML
+ end
+
+ # Text nodes in XML built by interpolation; hrefs and vCard content
+ # can all contain &, <, or >.
+ def xml_escape(text)
+ text.gsub(/[&<>]/, "&" => "&", "<" => "<", ">" => ">")
+ end
end
end
diff --git a/test/fixtures/contacts/AB12C345-6789-0DEF-1234-567890ABCDEF.kdl b/test/fixtures/contacts/AB12C345-6789-0DEF-1234-567890ABCDEF.kdl
new file mode 100644
index 0000000..e1a0409
--- /dev/null
+++ b/test/fixtures/contacts/AB12C345-6789-0DEF-1234-567890ABCDEF.kdl
@@ -0,0 +1,3 @@
+contact {
+ name "Test Contact"
+}
diff --git a/test/fixtures/macos-exchange/07-report-multiget/response b/test/fixtures/macos-exchange/07-report-multiget/response
index be34f5b..2528ee4 100644
--- a/test/fixtures/macos-exchange/07-report-multiget/response
+++ b/test/fixtures/macos-exchange/07-report-multiget/response
@@ -4,20 +4,19 @@ 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/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
- <d:propstat>
- <d:prop>
- <d:getetag>"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"</d:getetag>
- <card:address-data>BEGIN:VCARD
-VERSION:3.0
-PRODID:-//Apple Inc.//macOS 14.6.1//EN
-N:Contact;Test;;;
-FN:Test Contact
-REV:2026-01-14T00:00:00Z
-UID:AB12C345-6789-0DEF-1234-567890ABCDEF
+ <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
+ <d:propstat>
+ <d:prop>
+ <d:getetag>"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"</d:getetag>
+ <card:address-data>BEGIN:VCARD
+VERSION:3.0
+N:Contact;Test;;;
+FN:Test Contact
+UID:AB12C345-6789-0DEF-1234-567890ABCDEF
END:VCARD</card:address-data>
- </d:prop>
- <d:status>HTTP/1.1 200 OK</d:status>
- </d:propstat>
- </d:response>
+ </d:prop>
+ <d:status>HTTP/1.1 200 OK</d:status>
+ </d:propstat>
+</d:response>
+
</d:multistatus>
diff --git a/test/fixtures/macos-exchange/08-report-sync-collection/response b/test/fixtures/macos-exchange/08-report-sync-collection/response
index aa8852a..7ab90e2 100644
--- a/test/fixtures/macos-exchange/08-report-sync-collection/response
+++ b/test/fixtures/macos-exchange/08-report-sync-collection/response
@@ -4,13 +4,13 @@ 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/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
- <d:propstat>
- <d:prop>
- <d:getetag>"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"</d:getetag>
-
- </d:prop>
- <d:status>HTTP/1.1 200 OK</d:status>
- </d:propstat>
- </d:response>
+ <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
+ <d:propstat>
+ <d:prop>
+ <d:getetag>"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"</d:getetag>
+ </d:prop>
+ <d:status>HTTP/1.1 200 OK</d:status>
+ </d:propstat>
+</d:response>
+
</d:multistatus>
diff --git a/test/pro_tacts/test_config.rb b/test/pro_tacts/test_config.rb
index d32215e..aaa0d52 100644
--- a/test/pro_tacts/test_config.rb
+++ b/test/pro_tacts/test_config.rb
@@ -25,6 +25,14 @@ class ConfigTest < Minitest::Test
assert_raises(KeyError) { ProTacts::Config.new({}).sentry_dsn }
end
+ def test_contacts_dir_defaults_to_data_contacts
+ assert_equal "data/contacts", ProTacts::Config.new({}).contacts_dir
+ end
+
+ def test_contacts_dir_is_overridable
+ assert_equal "/tmp/kdl", ProTacts::Config.new("PRO_TACTS_CONTACTS_DIR" => "/tmp/kdl").contacts_dir
+ end
+
def test_debug_defaults_off
refute ProTacts::Config.new({}).debug?
refute ProTacts::Config.new("PRO_TACTS_DEBUG" => nil).debug?
diff --git a/test/pro_tacts/test_contacts.rb b/test/pro_tacts/test_contacts.rb
new file mode 100644
index 0000000..ab32c9e
--- /dev/null
+++ b/test/pro_tacts/test_contacts.rb
@@ -0,0 +1,97 @@
+require_relative "../test_helper"
+
+require "tmpdir"
+
+require "pro_tacts/contacts"
+
+class ContactsTest < Minitest::Test
+ def with_contacts(files)
+ Dir.mktmpdir do |dir|
+ directory = Pathname.new(dir)
+ files.each { |name, content| (directory / name).write(content) }
+ yield ProTacts::Contacts.new(directory)
+ end
+ end
+
+ def test_all_lists_contacts_sorted_by_id
+ with_contacts({
+ "znorth.kdl" => "contact { name \"Zed\" }",
+ "aiden.kdl" => "contact { name \"Aiden\" }",
+ }) do |contacts|
+ assert_equal %w[aiden znorth], contacts.all.map { it.id }
+ end
+ end
+
+ def test_find_returns_the_contact_by_id
+ with_contacts({"aiden.kdl" => "contact { name \"Aiden\" }"}) do |contacts|
+ contact = contacts.find("aiden")
+
+ assert_equal "aiden", contact.id
+ assert_includes contact.vcard, "FN:Aiden"
+ end
+ end
+
+ def test_the_uid_comes_from_the_filename
+ with_contacts({"kqmtnwpxlrvszoyp.kdl" => "contact { name \"Aiden\" }"}) do |contacts|
+ assert_includes contacts.find("kqmtnwpxlrvszoyp").vcard, "UID:kqmtnwpxlrvszoyp"
+ end
+ end
+
+ def test_find_returns_nil_for_unknown_ids
+ with_contacts({"aiden.kdl" => "contact { name \"Aiden\" }"}) do |contacts|
+ assert_nil contacts.find("nope")
+ end
+ end
+
+ def test_find_rejects_ids_outside_the_charset
+ with_contacts({}) do |contacts|
+ assert_nil contacts.find("../secrets")
+ assert_nil contacts.find("a/b")
+ assert_nil contacts.find("a.vcf")
+ end
+ end
+
+ def test_a_missing_directory_raises
+ error = assert_raises(ArgumentError) do
+ ProTacts::Contacts.new(Pathname.new(Dir.mktmpdir) / "nonexistent")
+ end
+
+ assert_match(/contacts directory not found/, error.message)
+ end
+
+ def test_an_empty_directory_lists_no_contacts
+ with_contacts({}) do |contacts|
+ assert_empty contacts.all
+ end
+ end
+
+ def test_unparseable_files_are_skipped
+ with_contacts({
+ "broken.kdl" => "contact {",
+ "aiden.kdl" => "contact { name \"Aiden\" }",
+ }) do |contacts|
+ assert_equal %w[aiden], contacts.all.map { it.id }
+ assert_nil contacts.find("broken")
+ end
+ end
+
+ def test_files_without_exactly_one_contact_node_are_skipped
+ with_contacts({
+ "empty.kdl" => "",
+ "two.kdl" => "contact { name \"A\" }\ncontact { name \"B\" }",
+ "other.kdl" => "person { name \"A\" }",
+ }) do |contacts|
+ assert_empty contacts.all
+ end
+ end
+
+ def test_files_whose_card_cannot_render_are_skipped
+ with_contacts({
+ "nameless.kdl" => "contact { phone \"+1-555-1234\" }",
+ "aiden.kdl" => "contact { name \"Aiden\" }",
+ }) do |contacts|
+ assert_equal %w[aiden], contacts.all.map { it.id }
+ assert_nil contacts.find("nameless")
+ end
+ end
+end
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index f61aabf..70c6c61 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -1,7 +1,9 @@
require_relative "../test_helper"
require "rack/test"
+require "tmpdir"
+require "pro_tacts/config"
require "pro_tacts/web"
class WebTest < Minitest::Test
@@ -96,4 +98,116 @@ class WebTest < Minitest::Test
assert_includes last_response.body, "getctag"
assert_includes last_response.body, "AB12C345-6789-0DEF-1234-567890ABCDEF.vcf"
end
+
+ def test_get_unknown_contact_is_404
+ get "/dav/addressbook/nope.vcf"
+
+ assert_equal 404, last_response.status
+ assert_equal "Not Found", last_response.body
+ end
+
+ # Swaps in a throwaway contacts directory so the multi-contact routes
+ # can be exercised without touching the exchange fixture data.
+ def with_contacts(files)
+ Dir.mktmpdir do |dir|
+ files.each { |name, content| File.write(File.join(dir, name), content) }
+ original = ProTacts.config
+ ProTacts.config = ProTacts::Config.new({
+ "RACK_ENV" => "test",
+ "PRO_TACTS_CONTACTS_DIR" => dir,
+ })
+ begin
+ yield
+ ensure
+ ProTacts.config = original
+ end
+ end
+ end
+
+ def etag_only_propfind
+ <<~XML
+ <?xml version="1.0" encoding="UTF-8"?>
+ <A:propfind xmlns:A="DAV:">
+ <A:prop>
+ <A:getetag/>
+ </A:prop>
+ </A:propfind>
+ XML
+ end
+
+ def multiget(*ids)
+ hrefs = ids.map { "<A:href xmlns:A=\"DAV:\">/dav/addressbook/#{it}.vcf</A:href>" }.join("\n ")
+
+ <<~XML
+ <?xml version="1.0" encoding="UTF-8"?>
+ <C:addressbook-multiget xmlns:C="urn:ietf:params:xml:ns:carddav">
+ <A:prop xmlns:A="DAV:">
+ <A:getetag/>
+ <C:address-data/>
+ </A:prop>
+ #{hrefs}
+ </C:addressbook-multiget>
+ XML
+ end
+
+ def test_listing_and_multiget_serve_every_contact_on_disk
+ with_contacts({
+ "aiden.kdl" => "contact { name \"Aiden\" }",
+ "znorth.kdl" => "contact { name \"Zed\" }",
+ }) do
+ request "/dav/addressbook/", method: "PROPFIND", "HTTP_DEPTH" => "1", input: etag_only_propfind
+
+ assert_equal 207, last_response.status
+ assert_includes last_response.body, "/dav/addressbook/aiden.vcf"
+ assert_includes last_response.body, "/dav/addressbook/znorth.vcf"
+
+ request "/dav/addressbook/", method: "REPORT", input: multiget("aiden", "znorth")
+
+ assert_equal 207, last_response.status
+ assert_includes last_response.body, "FN:Aiden"
+ assert_includes last_response.body, "UID:aiden"
+ assert_includes last_response.body, "FN:Zed"
+ assert_includes last_response.body, "UID:znorth"
+ end
+ end
+
+ def test_multiget_reports_unknown_hrefs_as_404
+ with_contacts({"aiden.kdl" => "contact { name \"Aiden\" }"}) do
+ request "/dav/addressbook/", method: "REPORT", input: multiget("aiden", "nope")
+
+ assert_equal 207, last_response.status
+ assert_includes last_response.body, "FN:Aiden"
+ assert_includes last_response.body, "/dav/addressbook/nope.vcf"
+ assert_includes last_response.body, "HTTP/1.1 404 Not Found"
+ end
+ end
+
+ def test_multiget_escapes_vcard_content_for_xml
+ with_contacts({"aiden.kdl" => "contact { name \"A & B <Team>\" }"}) do
+ request "/dav/addressbook/", method: "REPORT", input: multiget("aiden")
+
+ assert_equal 207, last_response.status
+ assert_includes last_response.body, "FN:A & B <Team>"
+ end
+ end
+
+ def test_sync_collection_returns_etags_only
+ with_contacts({"aiden.kdl" => "contact { name \"Aiden\" }"}) do
+ request "/dav/addressbook/", method: "REPORT", input: <<~XML
+ <?xml version="1.0" encoding="UTF-8"?>
+ <A:sync-collection xmlns:A="DAV:">
+ <A:sync-token>http://pro-tacts/sync/1</A:sync-token>
+ <A:sync-level>1</A:sync-level>
+ <A:prop>
+ <A:getetag/>
+ </A:prop>
+ </A:sync-collection>
+ XML
+
+ assert_equal 207, last_response.status
+ assert_includes last_response.body, "/dav/addressbook/aiden.vcf"
+ assert_includes last_response.body, "getetag"
+ refute_includes last_response.body, "address-data"
+ end
+ end
end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 81d6508..3606673 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -1,6 +1,9 @@
# Marks the process as running tests before anything requires the app, so
-# web.rb skips Sentry.init and no SENTRY_DSN is needed.
+# web.rb skips Sentry.init and no SENTRY_DSN is needed. The contacts
+# directory holds the card the recorded macOS exchange asked for, so the
+# fixture replay resolves the same hrefs the client did.
ENV["RACK_ENV"] = "test"
+ENV["PRO_TACTS_CONTACTS_DIR"] = File.expand_path("fixtures/contacts", __dir__)
require "minitest/autorun"