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