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
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
require_relative "../test_helper"
require "rack/test"
require "tmpdir"

require "pro_tacts/exchange_log"

class ExchangeLogTest < Minitest::Test
  include Sentry::TestHelper
  include SentryMessages
  include Rack::Test::Methods

  # Answers whatever the test asks for, so each case drives the logging
  # decision directly. It reads the body first, as the DAV routes do.
  class Stub
    attr_accessor :status, :report, :error

    def initialize
      @status = 200
    end

    def call(env)
      env["rack.input"]&.read
      Sentry.capture_message("reported") if @report
      raise @error if @error

      [@status, { "content-type" => "text/plain" }, ["body from the app"]]
    end
  end

  def setup
    setup_sentry
    @directory = Pathname.new(Dir.mktmpdir("exchange-log"))
    @stub = Stub.new
    @everything = false
  end

  def teardown
    teardown_sentry_test
    FileUtils.remove_entry(@directory)
  end

  def app
    @app ||= ProTacts::ExchangeLog.new(@stub, path: @directory / "exchange.log", everything: @everything)
  end

  # Logger heads a new file with a comment line of its own.
  def logged
    path = @directory / "exchange.log"
    path.exist? ? path.readlines.reject { it.start_with?("#") }.join : ""
  end

  # The last request's head and body, read back out of the log.
  def read_back
    id = last_request.env.fetch(ProTacts::ExchangeLog::ENV_KEY)
    ProTacts::ExchangeLog.read_request(id, @directory / "exchange.log")
  end

  def test_a_successful_exchange_is_not_logged
    request "/dav/addressbook/", method: "PROPFIND"

    assert_empty logged
  end

  def test_a_client_or_server_error_is_logged
    [400, 403, 404, 410, 412, 500].each do |status|
      @stub.status = status

      request "/dav/addressbook/", method: "PROPFIND"

      assert_includes logged, "<< #{status}", status
    end
  end

  # An unauthenticated request is refused at the identity gate, and its
  # body came from outside the tailnet.
  def test_a_401_is_not_logged
    @stub.status = 401

    request "/dav/addressbook/", method: "PROPFIND"

    assert_empty logged
  end

  # An arrival report on a PUT that succeeded: the alert carries an
  # exchange id, so the exchange has to be there to read.
  def test_an_exchange_that_reported_to_sentry_is_logged_though_it_succeeded
    @stub.status = 201
    @stub.report = true

    put "/dav/addressbook/new.vcf", "BEGIN:VCARD"

    assert_includes logged, "<< 201 Created"
  end

  def test_an_exchange_that_raised_is_logged_and_still_raises
    @stub.error = RuntimeError.new("boom")

    assert_raises(RuntimeError) do
      request "/dav/addressbook/", method: "REPORT", input: "<nonsense/>"
    end

    assert_includes logged, ">> REPORT /dav/addressbook/"
    assert_includes logged, "!! RuntimeError: boom"
  end

  # A log that cannot be written raises, where Logger's own default
  # would print a warning and carry on with the record lost. The file
  # is closed under the Logger holding it: a write to it raises
  # IOError, as one to a full disk raises Errno::ENOSPC.
  def test_a_log_that_will_not_write_raises
    @everything = true
    app.instance_variable_get(:@logger).instance_variable_get(:@logdev).dev.close

    assert_raises(IOError) { request "/dav/addressbook/", method: "PROPFIND" }
  end

  def test_the_request_is_logged_whole
    @stub.status = 404

    request "/dav/addressbook/", method: "REPORT", input: "<sync-collection>\n  <sync-token/>\n</sync-collection>",
      "CONTENT_TYPE" => "text/xml", "HTTP_DEPTH" => "1"

    assert_match(%r{>> REPORT /dav/addressbook/ HTTP/1\.\d$}, logged)
    assert_includes logged, ">> Depth: 1"
    assert_includes logged, ">> Content-Type: text/xml"
    assert_includes logged, ">> <sync-collection>"
    assert_includes logged, ">>   <sync-token/>"
  end

  def test_the_response_is_logged_whole
    @stub.status = 404

    get "/dav/addressbook/nope.vcf"

    assert_includes logged, "<< 404 Not Found"
    assert_includes logged, "<< content-type: text/plain"
    assert_includes logged, "<< body from the app"
  end

  def test_every_line_carries_the_id_sentry_was_given
    @stub.status = 412
    @stub.report = true

    put "/dav/addressbook/new.vcf", "BEGIN:VCARD\r\nEND:VCARD\r\n"

    id = sentry_events.last.tags.fetch(:exchange)

    refute_empty logged
    logged.each_line do |line|
      assert_match(/\A\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3} #{id} (>>|<<) /, line)
    end
  end

  def test_the_client_still_gets_the_whole_response
    @stub.status = 404

    get "/dav/addressbook/nope.vcf"

    assert_equal 404, last_response.status
    assert_equal "body from the app", last_response.body
  end

  def test_admin_traffic_is_not_logged
    @stub.status = 404

    get "/contacts/nope"
    post "/contacts"

    assert_empty logged
  end

  def test_a_dav_verb_to_any_path_is_logged
    @stub.status = 404

    request "/nowhere", method: "PROPFIND"

    assert_includes logged, ">> PROPFIND /nowhere"
  end

  def test_everything_logs_a_successful_exchange_too
    @everything = true

    request "/", method: "PROPFIND"

    assert_includes logged, "<< 200 OK"
  end

  def test_a_logged_request_reads_back_byte_for_byte
    @stub.status = 412
    card = "BEGIN:VCARD\r\nNOTE:Zoë\r\n\r\nEND:VCARD\r\n"

    put "/dav/addressbook/new.vcf", card, "CONTENT_TYPE" => "text/vcard", "HTTP_IF_MATCH" => '"abc"'

    head, body = read_back
    assert_match %r{\APUT /dav/addressbook/new\.vcf HTTP/1\.\d\z}, head.first
    assert_includes head, "Content-Type: text/vcard"
    assert_includes head, 'If-Match: "abc"'
    assert_equal card.b, body
  end

  def test_a_body_without_a_final_newline_reads_back_without_one
    @stub.status = 412

    put "/dav/addressbook/new.vcf", "BEGIN:VCARD"

    assert_equal "BEGIN:VCARD", read_back.last
  end

  def test_a_request_with_no_body_reads_back_with_an_empty_one
    @stub.status = 404

    delete "/dav/addressbook/nope.vcf"

    assert_equal "", read_back.last
  end

  def test_a_request_reads_back_from_a_rotated_log
    @stub.status = 404
    request "/dav/addressbook/", method: "PROPFIND"
    (@directory / "exchange.log").rename(@directory / "exchange.log.0")

    assert_match %r{\APROPFIND /dav/addressbook/ }, read_back.first.first
  end

  def test_an_id_no_log_holds_reads_back_as_nil
    assert_nil ProTacts::ExchangeLog.read_request("0123456789ab", @directory / "exchange.log")
  end

  def test_a_body_that_is_not_utf_8_logs_beside_one_that_is
    @stub.status = 412
    app
    @stub.define_singleton_method(:call) { |_env| [412, {}, ["Zoë"]] }

    put "/dav/addressbook/new.vcf", "\xFF\xFE".b

    assert_includes logged.b, "<< Zoë".b
  end
end