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
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