1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
require "pathname"
require "rack/test"

require "pro_tacts/tailscale_auth"

# Loads and replays the recorded macOS Contacts exchange in
# test/fixtures/macos-exchange. See that directory's README for provenance
# and the request/response file format.
module ExchangeFixtures
  DIRECTORY = Pathname.new(__dir__).parent / "fixtures" / "macos-exchange"

  Step = Data.define(:name, :method, :path, :headers, :body)
  Response = Data.define(:status, :headers, :body)

  # Response headers worth snapshotting; everything else (Content-Length,
  # Date, server) is plumbing.
  RESPONSE_HEADERS = %w[Content-Type ETag DAV Allow Location].freeze

  class << self
    def steps
      Dir.children(DIRECTORY)
        .select { (DIRECTORY / it).directory? }
        .sort
        .map { |name| Step.new(name:, **parse_request(name)) }
    end

    def parse_request(name)
      request_line, header_lines, body = split_message(read(name, "request"))
      method, path, = request_line.split(" ")
      { method:, path:, headers: parse_headers(header_lines), body: }
    end

    def recorded_response(step)
      status_line, header_lines, body = split_message(read(step.name, "response"))
      Response.new(status: status_line.to_i, headers: parse_headers(header_lines), body:)
    end

    # Stands in for the Tailscale-User-Login the recorded session carried.
    # It was stripped from the request files because it names a real tailnet
    # user; the app now refuses requests without one, so the replay has to
    # put an identity back.
    REPLAY_LOGIN = "replay@example.com"

    # Rack env for the recorded headers: Content-Type and Depth are the only
    # ones the app reads, but replaying all of them keeps the fidelity.
    def env_for(step)
      step.headers.to_h { |name, value|
        key = name == "Content-Type" ? "CONTENT_TYPE" : "HTTP_#{name.tr('-', '_').upcase}"
        [key, value]
      }.merge(ProTacts::TailscaleAuth::LOGIN_HEADER => REPLAY_LOGIN)
    end

    # Replays every recorded request against the app and rewrites the
    # response fixtures. Rake task "fixtures"; run it after deliberately
    # changing responses, and review the diff.
    def record_responses(app)
      session = Rack::Test::Session.new(Rack::MockSession.new(app))
      steps.each do |step|
        session.request(step.path, { method: step.method, input: step.body }.merge(env_for(step)))
        write_response(step, session.last_response)
      end
    end

    private

    def read(name, file)
      File.read(DIRECTORY / name / file)
    end

    def split_message(raw)
      head, body = raw.split("\n\n", 2)
      lines = head.split("\n")
      [lines.first, lines.drop(1), body.to_s.chomp]
    end

    def parse_headers(lines)
      lines.to_h { |line| line.split(": ", 2) }
    end

    def write_response(step, response)
      headers = RESPONSE_HEADERS
        .filter_map { |name| response[name] && "#{name}: #{response[name]}" }
      content = ([response.status.to_s] + headers).join("\n") + "\n\n"
      body = response.body.chomp
      content = "#{content}#{body}\n" unless body.empty?
      File.write(DIRECTORY / step.name / "response", content)
    end
  end
end