Accept PUT from CardDAV clients
The RFC's two no-uid-conflict clauses collapse into one rule here —
the card's UID must name the href it is PUT to — because the id is
the UID in this server.
Assisted-by: GLM-5.3 via pi
diff --git a/AGENTS.md b/AGENTS.md
index c576538..fc1c112 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,6 @@
# Project conventions
-A read-only CardDAV server for a family address book, in Ruby on Roda.
+A CardDAV server for a family address book, in Ruby on Roda.
Contacts are vCard 3.0 documents in SQLite, served as the bytes that
were stored.
diff --git a/README.md b/README.md
index 146a75f..4b24f40 100644
--- a/README.md
+++ b/README.md
@@ -20,8 +20,8 @@ does work is under Status below.
## Status
-Read-only and serving real data. Contacts live in a SQLite database at
-`data/contacts.db` (override the root with `PRO_TACTS_DATA_DIR`, or the
+Serving real data, reads and writes both. Contacts live in a SQLite
+database at `data/contacts.db` (override the root with `PRO_TACTS_DATA_DIR`, or the
database path alone with `PRO_TACTS_DATABASE`), one row per contact,
holding the vCard itself:
@@ -48,7 +48,9 @@ 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. A contact's etag hashes the card it serves and the
collection tags hash the membership, so a change to a card reaches synced
-clients on their next poll.
+clients on their next poll. Writes arrive the same way: PUT stores the
+submitted card verbatim (RFC 6352 section 6.3.2), and the change log the
+sync tokens count on is written with it, in one transaction.
Requests are authenticated by the `Tailscale-User-Login` header that
`tailscale serve` injects, which it strips from incoming requests so a
@@ -90,6 +92,12 @@ carry:
- `REPORT sync-collection`: `getetag` only — the client refetches changed
cards through multiget or `GET` on its own.
- `GET` a card: the vCard body plus an `ETag` header.
+- `PUT` a card: 201 for a create at an unmapped href, 204 for a replace,
+ both carrying the strong `ETag` — permitted because what is stored is
+ what was submitted (RFC 6352 section 6.3.2.3). A refused write is a
+ 412 naming the precondition in a `DAV:error` body. This row follows
+ the RFC rather than client evidence: no live write had been answered
+ when it was written, so the first synced device to edit is its test.
Three properties are load-bearing in non-obvious ways, documented in
`docs/macos-contacts.md`: the collection's `resourcetype` (without
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 8bbf088..dd1494b 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -134,6 +134,20 @@ module ProTacts
nil
end
+ # The id of the card whose UID property holds this value, if one
+ # does — the read behind the no-uid-conflict precondition (RFC 6352
+ # section 6.3.2.1). It reads the index, so a card that failed to
+ # parse is invisible here; no card that arrived by PUT can be in
+ # that state, because PUT rejects what will not parse before
+ # storing it. `sole` for the same reason `contact` uses it: two
+ # cards sharing a UID is a corruption to raise on, not a choice.
+ #: (String uid) -> String?
+ def card_id_with_uid(uid)
+ card_properties.where(name: "UID", value: text(uid)).sole.fetch(:card_id).to_s
+ rescue Sequel::NoMatchingRow
+ nil
+ end
+
# Stores a card and everything that has to move with it: the
# change-log entry a client's sync token counts on, carrying the etag
# this card hashed to now, and the index rows read off the card. One
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index fe132b8..a54fc34 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -49,6 +49,31 @@ module ProTacts
# @rbs skip
Property = Data.define(:group, :name, :parameters, :value)
+ # Whether parsed properties carry the envelope RFC 2426 section 4
+ # requires of a card: BEGIN:VCARD first, END:VCARD last, and a
+ # VERSION in between. Deciding that is deliberately not the
+ # parser's job — it reads lines without judging the card — so it
+ # lives here, where a caller that has to decide (a PUT) can ask.
+ # The VERSION's value is not judged: any version is stored verbatim,
+ # and this only decides whether there is a card at all.
+ #: (Array[Property] properties) -> bool
+ def self.card?(properties)
+ return false if properties.empty?
+
+ first, last = properties.first, properties.last
+ first.name.casecmp?("BEGIN") && first.value.casecmp?("VCARD") &&
+ last.name.casecmp?("END") && last.value.casecmp?("VCARD") &&
+ properties.any? { it.name.casecmp?("VERSION") }
+ end
+
+ # The value of the card's UID property, if it carries one. Names
+ # compare without case, as the index's NOCASE collation already
+ # assumes for them.
+ #: (Array[Property] properties) -> String?
+ def self.uid(properties)
+ properties.find { it.name.casecmp?("UID") }&.value
+ end
+
# Text values escape backslash, the component separator, and the
# sub-component separator (RFC 2426 section 2.4.2); CRLF and CR are
# normalized to the `\n` escape because a raw line break would end
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 6d9633a..80da52d 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -130,7 +130,7 @@ module ProTacts
# support (RFC 6352 section 6.1); the header is RFC 4918 section 10.1.
r.options do
response["DAV"] = "addressbook"
- response["Allow"] = "OPTIONS, PROPFIND, REPORT"
+ response["Allow"] = "OPTIONS, PROPFIND, REPORT, PUT"
""
end
@@ -191,14 +191,16 @@ module ProTacts
# is not standardized — an Apple CalendarServer extension in the
# calendarserver.org namespace, kept because macOS polls it.
#
- # The privileges are advertised before the methods granting them
- # exist. macOS Contacts asks for this property on every poll and
- # attempts no write without it, so claiming the privileges is
- # what makes the client send a PUT this server can capture in
- # log/unhandled — the point of the experiment, not an oversight.
- # DAV:write covers PUT and PROPPATCH (RFC 3744 section 3.2),
- # DAV:bind adding a member to the collection (section 3.9), and
- # DAV:unbind removing one (section 3.10).
+ # The privileges are advertised ahead of the methods
+ # granting some of them. macOS Contacts asks for this
+ # property on every poll and attempts no write without it,
+ # so claiming them is what makes the client send writes at
+ # all — the PUT they prompted was what log/unhandled
+ # captured them for. DAV:write covers PUT and PROPPATCH
+ # (RFC 3744 section 3.2), and PUT is the one of the pair
+ # answered; DAV:bind is adding a member to the collection
+ # (section 3.9) and DAV:unbind removing one (section 3.10),
+ # neither of which has a route yet.
collection_response = <<~XML
<d:response>
<d:href>/dav/addressbook/</d:href>
@@ -327,6 +329,20 @@ module ProTacts
contact.vcard
end
end
+
+ # PUT to an unmapped URI creates the card there and PUT to a
+ # mapped one replaces it (RFC 6352 section 6.3.2). macOS
+ # creates with If-None-Match: * and a UUID it mints into both
+ # the URI and the card's UID, and updates with If-Match
+ # carrying the strong etag it was last served — see
+ # docs/macos-contacts.md, "What a write looks like on the wire".
+ r.put String do |filename|
+ id = filename.delete_suffix(".vcf")
+ # A last segment that is not this server's <id>.vcf shape
+ # cannot address a resource here, created or read — the
+ # same fall-through-to-404 the GET handler gives it.
+ write_card(id) if id.match?(Contact::ID_FORMAT)
+ end
end
end
end
@@ -359,6 +375,131 @@ module ProTacts
self.class.store
end
+ # The whole of the PUT route: a private method because a Roda route
+ # block cannot return early, so each refusal is a value the block
+ # ends with rather than a branch it exits. The checks run in the
+ # order RFC 7232 section 5 sets for a request with preconditions —
+ # the request's own validity first, the conditionals on stored
+ # state after — so a card that cannot be stored hears
+ # CARDDAV:valid-address-data even when its If-Match is stale too.
+ #: (String id) -> String
+ def write_card(id)
+ vcard = request.body.read
+ # Rack reads a body in as ASCII-8BIT; relabel rather than convert,
+ # the way Store#text does, so the charset check and the insert see
+ # the same bytes.
+ vcard = vcard.dup.force_encoding(Encoding::UTF_8) if vcard.encoding == Encoding::BINARY
+
+ # 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.
+ return precondition("supported-address-data") unless request.media_type == "text/vcard"
+
+ # CARDDAV:valid-address-data: bytes that are the UTF-8 the media
+ # type declares, parsing into a card with the envelope RFC 2426
+ # section 4 requires. Invalid UTF-8 would otherwise surface as a
+ # 500 out of SQLite on the insert, past the point where the
+ # client could be told what was wrong with the body.
+ return precondition("valid-address-data") unless vcard.valid_encoding?
+
+ properties = parse_card(vcard)
+ return precondition("valid-address-data") unless properties && VCard.card?(properties)
+
+ # CARDDAV:no-uid-conflict: the submitted UID must not belong to a
+ # different resource, and a mapped URI must not be overwritten by
+ # a card carrying a different UID. Here the id is the card's UID
+ # (see Contact), so both clauses come down to: the card carries a
+ # UID naming the resource being written, and no other card claims
+ # it. The href in the body is the SHOULD that section attaches to
+ # the first clause — report where the UID already lives.
+ uid = VCard.uid(properties)
+ owner = uid && store.card_id_with_uid(uid)
+ return precondition("no-uid-conflict", owner) if uid != id || owner && owner != id
+
+ existing = store.contact(id)
+
+ # The lost-update conditionals, RFC 7232 sections 3.1 and 3.2.
+ # macOS sends If-Match on updates and If-None-Match: * on creates;
+ # a PUT carrying neither is unconditional and allowed to proceed.
+ if_match = request.env["HTTP_IF_MATCH"]
+ return plain_412 if if_match && !if_match_satisfied?(if_match, existing)
+
+ if_none_match = request.env["HTTP_IF_NONE_MATCH"]
+ return plain_412 if if_none_match && if_none_match_failed?(if_none_match, existing)
+
+ stored = store.put(id, vcard)
+ response.status = existing ? 204 : 201
+ # A strong ETag belongs on the answer because what was stored is
+ # the submitted bytes, octet for octet — the one case RFC 6352
+ # section 6.3.2.3 says a client may rely on the tag it gets back.
+ # Nothing subtracts from a card before storage yet; when a group
+ # contributes properties at write time, that PUT must stop
+ # sending one.
+ response["ETag"] = stored.etag
+ ""
+ end
+
+ # A parse that answers nil rather than raising when the bytes are
+ # not a card — the same tolerance Store#properties_of gives the
+ # index, at the point where a caller must decide.
+ #: (String vcard) -> Array[VCard::Property]?
+ def parse_card(vcard)
+ VCard::Parser.parse(vcard)
+ rescue VCard::ParseError
+ nil
+ end
+
+ # A 412 whose body names the CardDAV precondition that failed, in
+ # the DAV:error form RFC 4918 section 16 defines; the element names
+ # are RFC 6352 section 6.3.2.1's, qualified by the card: namespace
+ # the body declares. no-uid-conflict carries the conflicting card's
+ # href when there is one.
+ #: (String element, ?String? conflict_id) -> String
+ def precondition(element, conflict_id = nil)
+ named = if conflict_id
+ "<card:#{element}><d:href>#{contact_href(conflict_id)}</d:href></card:#{element}>"
+ else
+ "<card:#{element}/>"
+ end
+ response.status = 412
+
+ <<~XML
+ <?xml version="1.0" encoding="UTF-8"?>
+ <d:error xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
+ #{named}
+ </d:error>
+ XML
+ end
+
+ # A bare 412: these failures are HTTP's own conditionals (RFC 7232),
+ # not a WebDAV precondition with an element to name in a body.
+ #: () -> String
+ def plain_412
+ response.status = 412
+ ""
+ end
+
+ # RFC 7232 section 3.1: If-Match passes when the current etag is one
+ # of those listed, or, for `*`, when there is a current
+ # representation at all.
+ #: (String header, Contact? existing) -> bool
+ def if_match_satisfied?(header, existing)
+ return !existing.nil? if header.strip == "*"
+
+ !existing.nil? && header.split(",").map(&:strip).include?(existing.etag)
+ end
+
+ # RFC 7232 section 3.2: If-None-Match fails a non-GET request when
+ # the current etag is one of those listed, or, for `*`, whenever the
+ # resource exists. The create — If-None-Match: * against an unmapped
+ # URI — is the case CardDAV clients send (RFC 6352 section 6.3.2).
+ #: (String header, Contact? existing) -> bool
+ def if_none_match_failed?(header, existing)
+ return !existing.nil? if header.strip == "*"
+
+ !existing.nil? && header.split(",").map(&:strip).include?(existing.etag)
+ end
+
#: (Array[String] responses) -> String
def multistatus(responses)
<<~XML
diff --git a/lib/roda/plugins/dav_verbs.rb b/lib/roda/plugins/dav_verbs.rb
index b9bef93..9bff638 100644
--- a/lib/roda/plugins/dav_verbs.rb
+++ b/lib/roda/plugins/dav_verbs.rb
@@ -5,12 +5,11 @@ class Roda
module RodaPlugins
# Adds routing methods for WebDAV/CardDAV HTTP verbs.
#
- # Only PROPFIND and REPORT are implemented since this is a read-only
- # CardDAV server. PROPFIND handles discovery and property retrieval,
- # REPORT handles addressbook-query and addressbook-multiget requests.
- #
- # Other WebDAV verbs (PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK)
- # would be needed for write support.
+ # Only PROPFIND and REPORT are implemented here because they are the
+ # two with no routing method in Roda itself; PUT arrives through
+ # plugin :all_verbs. Other WebDAV verbs (PROPPATCH, MKCOL, COPY,
+ # MOVE, LOCK, UNLOCK) would each need a line here when one is
+ # answered.
module DavVerbs
module RequestMethods
%w[propfind report].each do |verb|
diff --git a/sig/gems/roda.rbs b/sig/gems/roda.rbs
index 9203d73..cf6737b 100644
--- a/sig/gems/roda.rbs
+++ b/sig/gems/roda.rbs
@@ -28,6 +28,12 @@ class Roda
def on: (*untyped matchers) { (*untyped) -> untyped } -> void
def get: (*untyped matchers) ?{ (*untyped) -> untyped } -> void
def options: (*untyped matchers) ?{ (*untyped) -> untyped } -> void
+ def put: (*untyped matchers) ?{ (*untyped) -> untyped } -> void
+
+ # The media type of the request body, without parameters — inherited
+ # from Rack::Request in the gem, restated here because the local
+ # RodaRequest does not.
+ def media_type: () -> String?
# Halts the request, so nothing after it in a route block runs.
def redirect: (String path, ?Integer status) -> bot
diff --git a/test/fixtures/macos-exchange/03-options-principal/response b/test/fixtures/macos-exchange/03-options-principal/response
index df20a27..6f2b25a 100644
--- a/test/fixtures/macos-exchange/03-options-principal/response
+++ b/test/fixtures/macos-exchange/03-options-principal/response
@@ -1,5 +1,5 @@
200
Content-Type: text/html
DAV: addressbook
-Allow: OPTIONS, PROPFIND, REPORT
+Allow: OPTIONS, PROPFIND, REPORT, PUT
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index fb31300..e991873 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -48,6 +48,32 @@ class StoreTest < Minitest::Test
with_store { assert_nil it.contact("nobody") }
end
+ ## UIDs
+
+ # The read behind the no-uid-conflict precondition (RFC 6352 section
+ # 6.3.2.1): which card, if any, owns this UID.
+ def test_the_card_holding_a_uid_is_found_by_it
+ with_store({"aiden" => AIDEN}) do |store|
+ assert_equal "aiden", store.card_id_with_uid("aiden")
+ end
+ end
+
+ def test_a_uid_no_card_holds_finds_no_owner
+ with_store({"aiden" => AIDEN}) do |store|
+ assert_nil store.card_id_with_uid("znorth")
+ end
+ end
+
+ # The lookup runs through the index, whose name column ignores case,
+ # so a card that spelled the property lowercase is still found.
+ def test_a_lowercase_uid_property_is_found
+ lowercase = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nuid:aiden\r\nEND:VCARD\r\n"
+
+ with_store({"aiden" => lowercase}) do |store|
+ assert_equal "aiden", store.card_id_with_uid("aiden")
+ end
+ end
+
def test_putting_the_same_id_replaces_the_card
with_store({"aiden" => AIDEN}) do |store|
updated = AIDEN.sub("Aiden", "Aiden Smith")
diff --git a/test/pro_tacts/test_vcard.rb b/test/pro_tacts/test_vcard.rb
index f762c54..dd1ff18 100644
--- a/test/pro_tacts/test_vcard.rb
+++ b/test/pro_tacts/test_vcard.rb
@@ -51,4 +51,43 @@ class VCardTest < Minitest::Test
raise "unfold did not reverse fold" unless ProTacts::VCard.unfold(ProTacts::VCard.fold(line)) == line
end
end
+
+ ## The envelope
+
+ # What a PUT checks before storing: RFC 2426 section 4 has a card
+ # begin and end itself and declare a version, and lines that parse
+ # into properties are not yet a card.
+ CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
+
+ def test_properties_with_the_envelope_are_a_card
+ assert ProTacts::VCard.card?(ProTacts::VCard::Parser.parse(CARD))
+ end
+
+ def test_no_properties_are_not_a_card
+ refute ProTacts::VCard.card?([])
+ end
+
+ def test_each_piece_of_the_envelope_is_required
+ no_begin = "VERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
+ no_end = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\n"
+ no_version = "BEGIN:VCARD\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
+
+ [no_begin, no_end, no_version].each do |card|
+ refute ProTacts::VCard.card?(ProTacts::VCard::Parser.parse(card)), card
+ end
+ end
+
+ def test_envelope_names_compare_without_case
+ lowercase = "begin:vcard\r\nversion:3.0\r\nfn:Aiden\r\nuid:aiden\r\nend:vcard\r\n"
+ properties = ProTacts::VCard::Parser.parse(lowercase)
+
+ assert ProTacts::VCard.card?(properties)
+ assert_equal "aiden", ProTacts::VCard.uid(properties)
+ end
+
+ def test_a_card_without_a_uid_has_none
+ card = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nEND:VCARD\r\n"
+
+ assert_nil ProTacts::VCard.uid(ProTacts::VCard::Parser.parse(card))
+ end
end
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index aefd62e..cdf570e 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -147,6 +147,235 @@ class WebTest < Minitest::Test
assert_equal "Not Found", last_response.body
end
+ ## PUT
+
+ # One PUT, so each test reads as a client's request rather than as
+ # Rack env assembly.
+ def put_request(id, body, headers = {})
+ request "/dav/addressbook/#{id}.vcf", { method: "PUT", input: body }.merge(headers)
+ end
+
+ VCARD = "text/vcard"
+
+ # RFC 6352 section 6.3.2: a PUT to an unmapped URI creates, and the
+ # strong ETag comes back because the stored bytes are the submitted
+ # bytes — the one case section 6.3.2.3 lets a client rely on it.
+ def test_put_creates_a_contact
+ card = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:New\r\nUID:new\r\nEND:VCARD\r\n"
+
+ with_contacts({}) do |store|
+ put_request "new", card, "CONTENT_TYPE" => VCARD, "HTTP_IF_NONE_MATCH" => "*"
+
+ assert_equal 201, last_response.status
+ assert_equal %("#{Digest::SHA256.hexdigest(card)}"), last_response["ETag"]
+ assert_empty last_response.body
+
+ get "/dav/addressbook/new.vcf"
+ assert_equal card, last_response.body
+
+ # The change-log entry a sync token counts on landed with the card.
+ change = store.changes.last
+ assert_equal "new", change.card_id
+ assert_equal "put", change.action
+ end
+ end
+
+ def test_put_with_if_none_match_star_refuses_an_existing_card
+ with_contacts({"aiden" => "Aiden"}) do
+ get "/dav/addressbook/aiden.vcf"
+ served = last_response.body
+
+ put_request "aiden", card("aiden", "Aiden Smith"),
+ "CONTENT_TYPE" => VCARD, "HTTP_IF_NONE_MATCH" => "*"
+
+ assert_equal 412, last_response.status
+
+ get "/dav/addressbook/aiden.vcf"
+ assert_equal served, last_response.body
+ end
+ end
+
+ # If-None-Match is the client's SHOULD, not the server's requirement:
+ # a bare PUT to an unmapped URI creates all the same (RFC 6352
+ # section 6.3.2).
+ def test_put_without_a_conditional_creates
+ with_contacts({}) do
+ put_request "new", card("new", "New"), "CONTENT_TYPE" => VCARD
+
+ assert_equal 201, last_response.status
+ end
+ end
+
+ def test_put_with_the_current_etag_updates
+ with_contacts({"aiden" => "Aiden"}) do
+ get "/dav/addressbook/aiden.vcf"
+ stale = last_response["ETag"]
+
+ updated = card("aiden", "Aiden Smith")
+ put_request "aiden", updated, "CONTENT_TYPE" => VCARD, "HTTP_IF_MATCH" => stale
+
+ assert_equal 204, last_response.status
+ etag = %("#{Digest::SHA256.hexdigest(updated)}")
+ assert_equal etag, last_response["ETag"]
+
+ # The tag the PUT returned is the one every read now reports, which
+ # is what lets a client keep syncing against its own write.
+ get "/dav/addressbook/aiden.vcf"
+ assert_equal updated, last_response.body
+ assert_equal etag, last_response["ETag"]
+
+ request "/dav/addressbook/", method: "PROPFIND", "HTTP_DEPTH" => "1", input: etag_only_propfind
+ assert_includes last_response.body, "<d:getetag>#{etag}</d:getetag>"
+ end
+ end
+
+ def test_put_with_a_stale_etag_is_refused_and_changes_nothing
+ with_contacts({"aiden" => "Aiden"}) do
+ get "/dav/addressbook/aiden.vcf"
+ before = last_response.body
+
+ put_request "aiden", card("aiden", "Aiden Smith"),
+ "CONTENT_TYPE" => VCARD, "HTTP_IF_MATCH" => %("#{"0" * 64}")
+
+ assert_equal 412, last_response.status
+
+ get "/dav/addressbook/aiden.vcf"
+ assert_equal before, last_response.body
+ end
+ end
+
+ # If-Match on a resource that does not exist fails rather than creating
+ # — the client named a representation it never saw (RFC 7232 section
+ # 3.1).
+ def test_put_with_if_match_against_nothing_is_refused
+ with_contacts({}) do
+ put_request "new", card("new", "New"),
+ "CONTENT_TYPE" => VCARD, "HTTP_IF_MATCH" => %("#{"0" * 64}")
+
+ assert_equal 412, last_response.status
+ end
+ end
+
+ # RFC 7232 section 3.1 allows If-Match a list of tags, and `*` for any
+ # current representation.
+ def test_if_match_accepts_a_list_and_a_star
+ with_contacts({"aiden" => "Aiden"}) do
+ get "/dav/addressbook/aiden.vcf"
+ etag = last_response["ETag"]
+ updated = card("aiden", "Aiden Smith")
+
+ put_request "aiden", updated, "CONTENT_TYPE" => VCARD,
+ "HTTP_IF_MATCH" => %("#{"0" * 64}", #{etag})
+ assert_equal 204, last_response.status
+
+ put_request "aiden", card("aiden", "Aiden"), "CONTENT_TYPE" => VCARD, "HTTP_IF_MATCH" => "*"
+ assert_equal 204, last_response.status
+ end
+ end
+
+ def test_put_without_a_conditional_overwrites
+ with_contacts({"aiden" => "Aiden"}) do
+ put_request "aiden", card("aiden", "Aiden Smith"), "CONTENT_TYPE" => VCARD
+
+ assert_equal 204, last_response.status
+
+ get "/dav/addressbook/aiden.vcf"
+ assert_includes last_response.body, "Aiden Smith"
+ end
+ end
+
+ # The preconditions of RFC 6352 section 6.3.2.1, marshalled as 412s
+ # in the DAV:error form RFC 4918 section 16 defines.
+ def test_put_of_a_non_vcard_media_type_names_its_precondition
+ with_contacts({}) do
+ put_request "new", card("new", "New"), "CONTENT_TYPE" => "text/plain"
+
+ assert_equal 412, last_response.status
+ assert_includes last_response.body, "<card:supported-address-data/>"
+ end
+ end
+
+ def test_put_of_bytes_that_are_not_utf_8_names_its_precondition
+ with_contacts({}) do
+ put_request "new", "\xFF\xFE".b, "CONTENT_TYPE" => VCARD
+
+ assert_equal 412, last_response.status
+ assert_includes last_response.body, "<card:valid-address-data/>"
+ end
+ end
+
+ def test_put_of_something_that_is_not_a_card_names_its_precondition
+ with_contacts({}) do
+ put_request "new", "not a vCard at all", "CONTENT_TYPE" => VCARD
+ assert_equal 412, last_response.status
+
+ # Lines that parse are still not a card without the envelope.
+ put_request "new", "FN:Nope\r\n", "CONTENT_TYPE" => VCARD
+ assert_equal 412, last_response.status
+
+ assert_includes last_response.body, "<card:valid-address-data/>"
+ end
+ end
+
+ def test_put_of_a_card_whose_uid_belongs_to_another_card
+ with_contacts({"aiden" => "Aiden"}) do
+ put_request "znorth", card("aiden", "Aiden"), "CONTENT_TYPE" => VCARD
+
+ assert_equal 412, last_response.status
+ assert_includes last_response.body, "<card:no-uid-conflict>"
+ # Where the UID already lives, which RFC 6352 section 6.3.2.1 asks
+ # the error to say.
+ assert_includes last_response.body, "<d:href>/dav/addressbook/aiden.vcf</d:href>"
+ end
+ end
+
+ # The id is the card's UID here, so a card whose UID names nothing at
+ # all is still a conflict: the href it was PUT to would not be the
+ # href it answers at.
+ def test_put_of_a_card_whose_uid_is_not_its_href
+ with_contacts({}) do
+ put_request "new", card("other", "New"), "CONTENT_TYPE" => VCARD
+
+ assert_equal 412, last_response.status
+ assert_includes last_response.body, "<card:no-uid-conflict/>"
+ refute_includes last_response.body, "d:href"
+ end
+ end
+
+ def test_put_of_a_card_with_no_uid
+ with_contacts({}) do
+ card = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:New\r\nEND:VCARD\r\n"
+ put_request "new", card, "CONTENT_TYPE" => VCARD
+
+ assert_equal 412, last_response.status
+ assert_includes last_response.body, "<card:no-uid-conflict/>"
+ end
+ end
+
+ # RFC 6352 section 6.3.2.2: what the server does not model survives
+ # the trip, because the submitted bytes are the stored bytes.
+ def test_put_stores_what_it_was_sent_verbatim
+ card = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:New\r\nX-APPLE-FOO:kept\r\nUID:new\r\nEND:VCARD\r\n"
+
+ with_contacts({}) do
+ put_request "new", card, "CONTENT_TYPE" => VCARD
+
+ get "/dav/addressbook/new.vcf"
+ assert_equal card, last_response.body
+ end
+ end
+
+ # A last segment that is not the <id>.vcf shape can address no
+ # resource here, created or read — the 404 the GET handler gives it.
+ def test_put_to_a_uri_that_cannot_address_a_card
+ with_contacts({}) do
+ request "/dav/addressbook/bad.id.vcf",
+ method: "PUT", input: card("bad.id", "Bad"), "CONTENT_TYPE" => VCARD
+
+ assert_equal 404, last_response.status
+ end
+ end
+
# A card as Contacts would send one, so the routes are exercised
# against stored bytes rather than anything this test renders.
def card(id, name)