fixtures: extract a logged exchange's request as a step
A capture used to be copied, its headers cut out by hand;
now fixtures:extract keeps the six a routing step demands.
It won't write over evidence, and it leaves the response alone:
rake fixtures records that half, from what this server has shown.

Assisted-by: Claude Opus 5 via Claude Code
change kyunulnplqrunrmooktyxpspttvzqlvt
commit 1e688b0e14017aa677edadafb7574ae435e7db80
author Alpha Chen <alpha@kejadlen.dev>
date
parent rvzqmwsp
diff --git a/AGENTS.md b/AGENTS.md
index 6825c53..a7d4adc 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -14,6 +14,8 @@ response — the shape of those responses is empirical, not arbitrary.
 rake                  # Tests (the default task)
 rake steep            # Type check lib against the RBS comments in it
 rake fixtures         # Re-record response fixtures from current behavior
+rake fixtures:extract # A logged exchange's request as a fixture step
+                      # (EXCHANGE=id STEP=test/fixtures/<recording>/NN-name)
 rake index:rebuild    # Derive the parsed index again from the stored cards
 rake db:dump          # Write the cards, birthdays, and groups to data/dump
                       # (DUMP=path to move it)
@@ -96,9 +98,9 @@ not a fixture; edit a `.vcf` to change what the replay serves.
 - DAV exchanges that went wrong (a status of 400 or more but 401, a
   crash, or a report to Sentry) are written whole to `log/exchange.log`,
   each line prefixed by the id Sentry carries as the `exchange` tag.
-  When implementing something a client asked for, look there first — and
-  strip the identifying headers before promoting an exchange into
-  `test/fixtures`.
+  When implementing something a client asked for, look there first, and
+  promote an exchange into `test/fixtures` with `rake fixtures:extract`,
+  which strips the identifying headers.
 - No request body reaches Sentry: `send_default_pii` is off in
   `config.ru`, because card content never leaves the machine. The URL,
   headers, exception messages, and `capture_message` text still do, so
diff --git a/tasks/fixtures.rake b/tasks/fixtures.rake
new file mode 100644
index 0000000..12f218c
--- /dev/null
+++ b/tasks/fixtures.rake
@@ -0,0 +1,31 @@
+# Promotes a logged DAV exchange to a fixture step: the request the
+# client sent, less the headers that identify the tailnet
+# (docs/plans/2026-09-13-dav-observability.md, "Every DAV exchange is
+# logged locally"). The response is `rake fixtures`'s to write, because
+# a response file is a snapshot of this server rather than evidence.
+
+namespace :fixtures do
+  desc "Write logged exchange EXCHANGE's request into fixture step directory STEP"
+  task :extract do
+    require "pathname"
+    require "pro_tacts"
+    require "pro_tacts/exchange_log"
+    require_relative "../test/pro_tacts/exchange_fixtures"
+
+    id = ENV.fetch("EXCHANGE") { abort "EXCHANGE= names the exchange: the id on its log lines and its Sentry exchange tag." }
+    step = Pathname.new(ENV.fetch("STEP") { abort "STEP= names the step directory to write, e.g. test/fixtures/ios-exchange/07-delete-contact." })
+    request = step / "request"
+    abort "#{request} exists. A request file is evidence; delete it first if this exchange supersedes it." if request.exist?
+
+    # The configured log, then rake dev's.
+    logs = [ProTacts.config.exchange_log_path, "log/dev.log"].uniq
+    head, body = logs.lazy.filter_map { ProTacts::ExchangeLog.read_request(id, it) }.first
+    abort "No exchange #{id} in #{logs.join(' or ')}, or their rotations." if head.nil?
+
+    dropped = ExchangeFixtures.write_request(step, head, body)
+    puts <<~DONE
+      Wrote #{request}, dropping #{dropped.empty? ? 'no headers' : dropped.join(', ')}.
+      Run `rake fixtures` to record its response, and review the diff.
+    DONE
+  end
+end
diff --git a/test/fixtures/macos-exchange/README.md b/test/fixtures/macos-exchange/README.md
index 8bdd6ad..2fa432e 100644
--- a/test/fixtures/macos-exchange/README.md
+++ b/test/fixtures/macos-exchange/README.md
@@ -12,8 +12,8 @@ later response change is measured against.
 Each step directory holds:
 
 - `request` — the request line, the headers that affect routing
-  (`Brief`, `Content-Type`, `Depth`, `If-Match`, `Prefer`), a blank
-  line, then the exact body. `Authorization`, `Host`, `User-Agent`, and
+  (`Brief`, `Content-Type`, `Depth`, `If-Match`, `If-None-Match`,
+  `Prefer`), a blank line, then the exact body. `Authorization`, `Host`, `User-Agent`, and
   the `Tailscale-*` / `X-Forwarded-*` / `X-Mme-Client-Info` headers
   were stripped: they identify the tailnet and user, and the app
   ignores them.
diff --git a/test/pro_tacts/exchange_fixtures.rb b/test/pro_tacts/exchange_fixtures.rb
index 8d61ee1..ec41766 100644
--- a/test/pro_tacts/exchange_fixtures.rb
+++ b/test/pro_tacts/exchange_fixtures.rb
@@ -23,6 +23,11 @@ class ExchangeFixtures < Data.define(:directory)
   # Date, server) is plumbing.
   RESPONSE_HEADERS = %w[Content-Type ETag DAV Allow Location].freeze
 
+  # Request headers a fixture keeps, the ones that affect routing; the
+  # rest identify the tailnet and the user, or are ignored
+  # (macos-exchange/README.md).
+  REQUEST_HEADERS = %w[Brief Content-Type Depth If-Match If-None-Match Prefer].freeze
+
   # Stand in for the Tailscale identity headers the recorded sessions
   # carried. They were stripped from the request files because they name
   # a real tailnet user; the app refuses requests without them, so the
@@ -35,6 +40,18 @@ class ExchangeFixtures < Data.define(:directory)
     [MACOS, IOS]
   end
 
+  # Writes step's request file in the layout parse_request reads, from a
+  # logged request line, headers, and body (ProTacts::ExchangeLog.read_request).
+  # Keeps only REQUEST_HEADERS, and answers the names of the headers it
+  # dropped. Rake task "fixtures:extract".
+  def self.write_request(step, head, body)
+    request_line, *headers = head
+    kept, dropped = headers.partition { REQUEST_HEADERS.include?(it.split(": ", 2).first) }
+    step.mkpath
+    (step / "request").binwrite([request_line, *kept].join("\n") + "\n\n" + body)
+    dropped.map { it.split(": ", 2).first }
+  end
+
   def steps
     Dir.children(directory)
       .select { (directory / it).directory? }
diff --git a/test/tasks/test_fixtures.rb b/test/tasks/test_fixtures.rb
new file mode 100644
index 0000000..3f4dce8
--- /dev/null
+++ b/test/tasks/test_fixtures.rb
@@ -0,0 +1,76 @@
+require_relative "../test_helper"
+
+require "pathname"
+require "rack/test"
+require "rake"
+require "tmpdir"
+
+require "pro_tacts/exchange_log"
+
+class FixturesTasksTest < Minitest::Test
+  include Rack::Test::Methods
+
+  CARD = "BEGIN:VCARD\r\nUID:new\r\nEND:VCARD\r\n"
+
+  def setup
+    @root = Pathname.new(Dir.mktmpdir("fixtures-extract"))
+  end
+
+  def teardown
+    FileUtils.remove_entry(@root)
+  end
+
+  # Refuses everything, so every exchange is logged.
+  def app
+    @app ||= ProTacts::ExchangeLog.new(->(_env) { [412, {}, []] }, path: @root / "exchange.log", everything: false)
+  end
+
+  def test_the_request_is_written_as_a_step_without_what_identifies_the_tailnet
+    put "/dav/addressbook/new.vcf", CARD,
+      "CONTENT_TYPE" => "text/vcard", "HTTP_IF_MATCH" => '"abc"', "HTTP_USER_AGENT" => "AddressBookCore",
+      "HTTP_TAILSCALE_USER_LOGIN" => "ada@example.com", "HTTP_TAILSCALE_USER_NAME" => "Ada"
+
+    extract(exchange, @root / "07-put")
+
+    assert_equal "PUT /dav/addressbook/new.vcf HTTP/1.0\nContent-Type: text/vcard\nIf-Match: \"abc\"\n\n#{CARD}",
+      (@root / "07-put" / "request").binread
+  end
+
+  def test_a_request_file_already_there_is_left_alone
+    put "/dav/addressbook/new.vcf", CARD
+    step = @root / "07-put"
+    step.mkpath
+    (step / "request").write("recorded")
+
+    assert_raises(SystemExit) { extract(exchange, step) }
+    assert_equal "recorded", (step / "request").read
+  end
+
+  def test_an_exchange_no_log_holds_writes_nothing
+    assert_raises(SystemExit) { extract("0123456789ab", @root / "07-put") }
+    refute (@root / "07-put").exist?
+  end
+
+  private
+
+  def exchange = last_request.env.fetch(ProTacts::ExchangeLog::ENV_KEY)
+
+  # Runs fixtures:extract in a Rake application of its own, reading this
+  # test's log as the configured one.
+  def extract(id, step)
+    config = ProTacts.config
+    application = Rake.application
+    overridden = ENV.to_h.slice("EXCHANGE", "STEP")
+
+    ProTacts.config = ProTacts::Config.new("PRO_TACTS_EXCHANGE_LOG" => (@root / "exchange.log").to_s)
+    ENV["EXCHANGE"] = id
+    ENV["STEP"] = step.to_s
+    Rake.application = Rake::Application.new
+    load (Pathname.new(__dir__).parent.parent / "tasks" / "fixtures.rake").to_s
+    capture_io { Rake.application["fixtures:extract"].invoke }
+  ensure
+    ProTacts.config = config
+    Rake.application = application
+    %w[EXCHANGE STEP].each { ENV[it] = overridden[it] }
+  end
+end