files.com 1.1.768 → 1.1.770

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/README.md +47 -7
  4. data/_VERSION +1 -1
  5. data/docs/file.md +37 -0
  6. data/lib/files.com/api.rb +9 -4
  7. data/lib/files.com/api_client.rb +181 -55
  8. data/lib/files.com/list.rb +98 -66
  9. data/lib/files.com/models/action_log.rb +7 -2
  10. data/lib/files.com/models/action_notification_export.rb +13 -3
  11. data/lib/files.com/models/action_notification_export_result.rb +7 -2
  12. data/lib/files.com/models/ai_assistant_personality.rb +34 -8
  13. data/lib/files.com/models/ai_task.rb +39 -8
  14. data/lib/files.com/models/api_key.rb +49 -10
  15. data/lib/files.com/models/api_request_log.rb +7 -2
  16. data/lib/files.com/models/app.rb +7 -2
  17. data/lib/files.com/models/as2_incoming_message.rb +7 -2
  18. data/lib/files.com/models/as2_outgoing_message.rb +7 -2
  19. data/lib/files.com/models/as2_partner.rb +34 -8
  20. data/lib/files.com/models/as2_station.rb +34 -8
  21. data/lib/files.com/models/automation.rb +50 -10
  22. data/lib/files.com/models/automation_log.rb +7 -2
  23. data/lib/files.com/models/automation_run.rb +31 -6
  24. data/lib/files.com/models/bandwidth_snapshot.rb +7 -2
  25. data/lib/files.com/models/behavior.rb +46 -10
  26. data/lib/files.com/models/bundle.rb +39 -8
  27. data/lib/files.com/models/bundle_action.rb +7 -2
  28. data/lib/files.com/models/bundle_download.rb +7 -2
  29. data/lib/files.com/models/bundle_notification.rb +34 -8
  30. data/lib/files.com/models/bundle_recipient.rb +14 -4
  31. data/lib/files.com/models/bundle_registration.rb +7 -2
  32. data/lib/files.com/models/chat_session.rb +13 -3
  33. data/lib/files.com/models/child_site_management_policy.rb +34 -8
  34. data/lib/files.com/models/clickwrap.rb +34 -8
  35. data/lib/files.com/models/custom_domain.rb +40 -9
  36. data/lib/files.com/models/desktop_configuration_profile.rb +34 -8
  37. data/lib/files.com/models/dns_record.rb +7 -2
  38. data/lib/files.com/models/email_incoming_message.rb +7 -2
  39. data/lib/files.com/models/email_log.rb +7 -2
  40. data/lib/files.com/models/event_channel.rb +34 -8
  41. data/lib/files.com/models/event_delivery_attempt.rb +13 -3
  42. data/lib/files.com/models/event_record.rb +13 -3
  43. data/lib/files.com/models/event_subscription.rb +34 -8
  44. data/lib/files.com/models/event_target.rb +34 -8
  45. data/lib/files.com/models/exavault_api_request_log.rb +7 -2
  46. data/lib/files.com/models/expectation.rb +40 -9
  47. data/lib/files.com/models/expectation_evaluation.rb +13 -3
  48. data/lib/files.com/models/expectation_incident.rb +31 -6
  49. data/lib/files.com/models/external_event.rb +20 -5
  50. data/lib/files.com/models/file.rb +93 -15
  51. data/lib/files.com/models/file_comment.rb +28 -7
  52. data/lib/files.com/models/file_comment_reaction.rb +11 -2
  53. data/lib/files.com/models/file_migration.rb +6 -1
  54. data/lib/files.com/models/file_migration_log.rb +7 -2
  55. data/lib/files.com/models/folder.rb +13 -3
  56. data/lib/files.com/models/form_field_set.rb +34 -8
  57. data/lib/files.com/models/ftp_action_log.rb +7 -2
  58. data/lib/files.com/models/gpg_key.rb +34 -8
  59. data/lib/files.com/models/group.rb +34 -8
  60. data/lib/files.com/models/group_user.rb +29 -7
  61. data/lib/files.com/models/history.rb +35 -10
  62. data/lib/files.com/models/history_export.rb +13 -3
  63. data/lib/files.com/models/history_export_result.rb +7 -2
  64. data/lib/files.com/models/holiday_calendar.rb +34 -8
  65. data/lib/files.com/models/holiday_region.rb +7 -2
  66. data/lib/files.com/models/inbound_s3_log.rb +7 -2
  67. data/lib/files.com/models/inbox_recipient.rb +14 -4
  68. data/lib/files.com/models/inbox_registration.rb +7 -2
  69. data/lib/files.com/models/inbox_upload.rb +7 -2
  70. data/lib/files.com/models/integration_centric_profile.rb +34 -8
  71. data/lib/files.com/models/invoice.rb +13 -3
  72. data/lib/files.com/models/ip_address.rb +28 -8
  73. data/lib/files.com/models/key_lifecycle_rule.rb +34 -8
  74. data/lib/files.com/models/lock.rb +18 -3
  75. data/lib/files.com/models/metadata_category.rb +41 -10
  76. data/lib/files.com/models/notification.rb +34 -8
  77. data/lib/files.com/models/outbound_connection_log.rb +7 -2
  78. data/lib/files.com/models/partner.rb +34 -8
  79. data/lib/files.com/models/partner_channel.rb +34 -8
  80. data/lib/files.com/models/partner_channel_template.rb +34 -8
  81. data/lib/files.com/models/partner_site.rb +4 -0
  82. data/lib/files.com/models/partner_site_request.rb +33 -4
  83. data/lib/files.com/models/payment.rb +13 -3
  84. data/lib/files.com/models/pending_work_event.rb +13 -3
  85. data/lib/files.com/models/permission.rb +18 -4
  86. data/lib/files.com/models/public_hosting_request_log.rb +7 -2
  87. data/lib/files.com/models/public_key.rb +34 -8
  88. data/lib/files.com/models/remote_bandwidth_snapshot.rb +7 -2
  89. data/lib/files.com/models/remote_mount_backend.rb +39 -8
  90. data/lib/files.com/models/remote_server.rb +52 -11
  91. data/lib/files.com/models/remote_server_credential.rb +34 -8
  92. data/lib/files.com/models/request.rb +25 -6
  93. data/lib/files.com/models/restore.rb +14 -4
  94. data/lib/files.com/models/schedule.rb +34 -8
  95. data/lib/files.com/models/scheduled_export.rb +34 -8
  96. data/lib/files.com/models/scim_log.rb +13 -3
  97. data/lib/files.com/models/secret.rb +34 -8
  98. data/lib/files.com/models/session.rb +11 -2
  99. data/lib/files.com/models/settings_change.rb +7 -2
  100. data/lib/files.com/models/sftp_action_log.rb +7 -2
  101. data/lib/files.com/models/sftp_host_key.rb +34 -8
  102. data/lib/files.com/models/share_group.rb +34 -8
  103. data/lib/files.com/models/siem_http_destination.rb +39 -8
  104. data/lib/files.com/models/siem_http_destination_event.rb +13 -3
  105. data/lib/files.com/models/site.rb +16 -3
  106. data/lib/files.com/models/site_subdomain_redirect.rb +17 -3
  107. data/lib/files.com/models/snapshot.rb +39 -8
  108. data/lib/files.com/models/sso_event.rb +13 -3
  109. data/lib/files.com/models/sso_strategy.rb +18 -3
  110. data/lib/files.com/models/style.rb +20 -5
  111. data/lib/files.com/models/sync.rb +44 -8
  112. data/lib/files.com/models/sync_log.rb +7 -2
  113. data/lib/files.com/models/sync_run.rb +13 -3
  114. data/lib/files.com/models/usage_daily_snapshot.rb +7 -2
  115. data/lib/files.com/models/usage_snapshot.rb +7 -2
  116. data/lib/files.com/models/user.rb +50 -8
  117. data/lib/files.com/models/user_additional_email_recipient.rb +34 -8
  118. data/lib/files.com/models/user_cipher_use.rb +7 -2
  119. data/lib/files.com/models/user_lifecycle_rule.rb +34 -8
  120. data/lib/files.com/models/user_request.rb +24 -5
  121. data/lib/files.com/models/user_security_event.rb +13 -3
  122. data/lib/files.com/models/user_sftp_client_use.rb +7 -2
  123. data/lib/files.com/models/web_dav_action_log.rb +7 -2
  124. data/lib/files.com/models/webhook_test.rb +7 -2
  125. data/lib/files.com/models/workspace.rb +34 -8
  126. data/lib/files.com/response.rb +24 -0
  127. data/lib/files.com/sizable_io.rb +65 -4
  128. data/lib/files.com/version.rb +1 -1
  129. data/lib/files.com.rb +23 -1
  130. data/spec/language_spec.rb +20 -10
  131. data/spec/lib/api_client_spec.rb +173 -18
  132. data/spec/list_spec.rb +203 -187
  133. data/spec/models/file_transfer_spec.rb +346 -0
  134. data/spec/models/persistence_spec.rb +167 -0
  135. data/spec/spec_helper.rb +122 -6
  136. data/spec/support/stubbed_api.rb +14 -0
  137. data/spec/support/transfer_server.rb +60 -0
  138. metadata +6 -2
@@ -0,0 +1,346 @@
1
+ require "spec_helper"
2
+ require "stringio"
3
+ require "tmpdir"
4
+ require "zlib"
5
+ require_relative "../support/transfer_server"
6
+
7
+ RSpec.describe Files::File, "transfers" do
8
+ # 22,528 bytes that include every byte value, so text handling would show.
9
+ let(:payload) { (0..255).map(&:chr).join.b * 88 }
10
+ let(:signed_target) { "/objects/report.bin?X-Amz-Credential=fixture%2Fcredential&X-Amz-Signature=fixture-signature" }
11
+ let(:sink) { StringIO.new("".b) }
12
+ let(:servers) { [] }
13
+ # A fixture server that pops this waits until the example closes it.
14
+ let(:server_gate) { Queue.new }
15
+
16
+ around do |example|
17
+ original = [ Files.api_key, Files.base_url, Files.max_network_retries, Files.session_id, Files.workspace_id ]
18
+ Files.api_key = "fixture-api-key"
19
+ Files.max_network_retries = 0
20
+ Files.session_id = "fixture-session"
21
+ Files.workspace_id = 7
22
+ example.run
23
+ ensure
24
+ Files.api_key, Files.base_url, Files.max_network_retries, Files.session_id, Files.workspace_id = original
25
+ end
26
+
27
+ after do
28
+ server_gate.close
29
+ servers.each(&:close)
30
+ end
31
+
32
+ def serve(response = nil, &respond)
33
+ respond ||= ->(client) { client.write(response) }
34
+ TransferServer.new(&respond).tap { |server| servers << server }
35
+ end
36
+
37
+ def remote_file(server)
38
+ described_class.new(path: "report.bin", download_uri: server.url(signed_target))
39
+ end
40
+
41
+ def response(...)
42
+ TransferServer.response(...)
43
+ end
44
+
45
+ def fixed_length(body, declared_bytes: body.bytesize)
46
+ response("200 OK", { "Content-Length" => declared_bytes }, body)
47
+ end
48
+
49
+ # A body in chunked transfer coding. An incomplete one stops before the final
50
+ # zero-length chunk, as when the connection drops.
51
+ def chunked(*chunks, complete: true)
52
+ encoded = chunks.map { |chunk| "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n" }.join
53
+ complete ? "#{encoded}0\r\n\r\n" : encoded
54
+ end
55
+
56
+ describe ".upload_file" do
57
+ it "raises the filesystem error when the local source does not exist" do
58
+ Dir.mktmpdir do |dir|
59
+ missing_path = File.join(dir, "missing.bin")
60
+
61
+ expect { described_class.upload_file(missing_path, "report.bin") }.to raise_error(Errno::ENOENT)
62
+ end
63
+ end
64
+ end
65
+
66
+ describe "#download_content" do
67
+ def download(raw_response, range: [])
68
+ server = serve(raw_response)
69
+ remote_file(server).download_content(sink, range: range)
70
+ server
71
+ end
72
+
73
+ it "writes a fixed-length body exactly and leaves the caller's IO open" do
74
+ download(fixed_length(payload))
75
+
76
+ expect(sink.string).to eq(payload)
77
+ expect(sink).not_to be_closed
78
+ end
79
+
80
+ it "writes a gzip body decompressed, although Content-Length counts compressed bytes" do
81
+ zipped = Zlib.gzip(payload)
82
+
83
+ download(response("200 OK", { "Content-Encoding" => "gzip", "Content-Length" => zipped.bytesize }, zipped))
84
+
85
+ expect(sink.string).to eq(payload)
86
+ end
87
+
88
+ it "writes the requested range of a partial-content response" do
89
+ part = payload.byteslice(4, 8)
90
+
91
+ server = download(response("206 Partial Content", { "Content-Range" => "bytes 4-11/#{payload.bytesize}", "Content-Length" => 8 }, part), range: [ 4, 11 ])
92
+
93
+ expect(sink.string).to eq(part)
94
+ expect(server.requests.map { |request| request.headers["range"] }).to eq([ "bytes=4-11" ])
95
+ end
96
+
97
+ it "writes a gzip-encoded range exactly as sent, without decompressing it" do
98
+ part = Zlib.gzip(payload).byteslice(0, 50)
99
+
100
+ download(response("206 Partial Content", { "Content-Encoding" => "gzip", "Content-Range" => "bytes 0-49/451", "Content-Length" => 50 }, part), range: [ 0, 49 ])
101
+
102
+ expect(sink.string).to eq(part)
103
+ end
104
+
105
+ it "writes a chunked body" do
106
+ download(response("200 OK", { "Transfer-Encoding" => "chunked" }, chunked(payload.byteslice(0, 4096), payload.byteslice(4096..))))
107
+
108
+ expect(sink.string).to eq(payload)
109
+ end
110
+
111
+ it "writes a body that ends when the server closes the connection" do
112
+ download(response("200 OK", {}, payload))
113
+
114
+ expect(sink.string).to eq(payload)
115
+ end
116
+
117
+ it "accepts an empty body" do
118
+ download(fixed_length(""))
119
+
120
+ expect(sink.string).to be_empty
121
+ expect(sink).not_to be_closed
122
+ end
123
+
124
+ it "requests the signed URL exactly as issued, without Files.com credentials" do
125
+ server = download(fixed_length(payload))
126
+
127
+ expect(server.requests.map(&:target)).to eq([ signed_target ])
128
+ expect(server.requests.first.headers.keys).not_to include("authorization", "x-filesapi-key", "x-filesapi-auth", "x-files-workspace-id")
129
+ end
130
+
131
+ it "raises EOFError when a fixed-length body ends early and leaves the caller's IO open for a retry" do
132
+ server = serve(fixed_length(payload.byteslice(0, 4096), declared_bytes: payload.bytesize))
133
+
134
+ expect { remote_file(server).download_content(sink) }.to raise_error(EOFError, "Transfer response ended after 4096 of 22528 bytes")
135
+ expect(server.requests.size).to eq(1)
136
+ expect(sink).not_to be_closed
137
+ end
138
+
139
+ it "raises EOFError when a partial-content body ends early" do
140
+ server = serve(response("206 Partial Content", { "Content-Range" => "bytes 4-11/#{payload.bytesize}", "Content-Length" => 8 }, payload.byteslice(4, 4)))
141
+
142
+ expect { remote_file(server).download_content(sink, range: [ 4, 11 ]) }.to raise_error(EOFError, "Transfer response ended after 4 of 8 bytes")
143
+ end
144
+
145
+ it "raises EOFError when a gzip-encoded range ends early, since Net::HTTP passes ranges through as sent" do
146
+ part = Zlib.gzip(payload).byteslice(0, 50)
147
+ server = serve(response("206 Partial Content", { "Content-Encoding" => "gzip", "Content-Range" => "bytes 0-49/451", "Content-Length" => 50 }, part.byteslice(0, 25)))
148
+
149
+ expect { remote_file(server).download_content(sink, range: [ 0, 49 ]) }.to raise_error(EOFError, "Transfer response ended after 25 of 50 bytes")
150
+ end
151
+
152
+ it "raises EOFError when an identity-encoded body ends early" do
153
+ server = serve(response("200 OK", { "Content-Encoding" => "identity", "Content-Length" => payload.bytesize }, payload.byteslice(0, 4096)))
154
+
155
+ expect { remote_file(server).download_content(sink) }.to raise_error(EOFError, "Transfer response ended after 4096 of 22528 bytes")
156
+ end
157
+
158
+ it "raises EOFError when a chunked body ends early, without requesting and writing it again" do
159
+ first_chunk = payload.byteslice(0, 4096)
160
+ server = serve(response("200 OK", { "Transfer-Encoding" => "chunked" }, chunked(first_chunk, complete: false)))
161
+
162
+ expect { remote_file(server).download_content(sink) }.to raise_error(EOFError)
163
+ expect(server.requests.size).to eq(1)
164
+ expect(sink.string).to eq(first_chunk)
165
+ end
166
+
167
+ it "raises for a refused transfer with an empty body and closes the caller's IO, without revealing the signed URL" do
168
+ server = serve(response("503 Service Unavailable", { "Content-Length" => 0 }))
169
+
170
+ expect { remote_file(server).download_content(sink) }.to raise_error(Net::HTTPFatalError, '503 "Service Unavailable"') { |error|
171
+ expect(error.full_message).not_to include("/objects/", "X-Amz", "fixture-signature")
172
+ }
173
+ expect(sink).to be_closed
174
+ end
175
+
176
+ it "does not write a refused transfer's error body" do
177
+ error_body = "<Error>Expired</Error>"
178
+ server = serve(response("403 Forbidden", { "Content-Length" => error_body.bytesize }, error_body))
179
+
180
+ expect { remote_file(server).download_content(sink) }.to raise_error(Net::HTTPClientException, '403 "Forbidden"')
181
+ expect(sink.string).to be_empty
182
+ end
183
+
184
+ it "raises the caller IO's own write error, even when closing that IO fails too" do
185
+ write_error = IOError.new("destination is full")
186
+ failing_sink = Class.new(StringIO) do
187
+ define_method(:<<) { |_bytes| raise write_error }
188
+
189
+ def close
190
+ super
191
+ raise IOError, "close failed too"
192
+ end
193
+ end.new
194
+ server = serve(fixed_length(payload))
195
+
196
+ expect { remote_file(server).download_content(failing_sink) }.to raise_error(IOError) { |error| expect(error).to equal(write_error) }
197
+ expect(failing_sink).to be_closed
198
+ expect(server.requests.size).to eq(1)
199
+ end
200
+ end
201
+
202
+ describe "#download_file" do
203
+ it "raises when the body ends early and closes the local file it created" do
204
+ server = serve(fixed_length(payload.byteslice(0, 4096), declared_bytes: payload.bytesize))
205
+
206
+ Dir.mktmpdir do |dir|
207
+ local_path = File.join(dir, "report.bin")
208
+
209
+ expect { remote_file(server).download_file(local_path) }.to raise_error(EOFError)
210
+ expect(ObjectSpace.each_object(File).select { |file| file.path == local_path }).to all(be_closed)
211
+ end
212
+ end
213
+ end
214
+
215
+ describe "#read_io" do
216
+ def open_pipes
217
+ ObjectSpace.each_object(Files::SizableIO).reject(&:closed?)
218
+ end
219
+
220
+ # Returns a file whose read_io has received the first 4096 of 22,528 bytes.
221
+ # The connection then drops, or later when the example closes server_gate,
222
+ # so the download fails while the caller is reading.
223
+ def file_whose_download_fails_later(drop_connection: true)
224
+ server = serve do |client|
225
+ client.write(fixed_length(payload.byteslice(0, 4096), declared_bytes: payload.bytesize))
226
+ server_gate.pop
227
+ end
228
+ file = remote_file(server)
229
+ file.read_io
230
+ server_gate.close if drop_connection
231
+ file
232
+ end
233
+
234
+ {
235
+ "read" => ->(reader) { reader.read },
236
+ "read(n) until eof?" => ->(reader) { reader.read(1024) until reader.eof? },
237
+ "gets" => ->(reader) { nil while reader.gets },
238
+ "readline" => ->(reader) { loop { reader.readline } },
239
+ "readpartial" => ->(reader) { loop { reader.readpartial(1024) } },
240
+ "each_line" => ->(reader) { reader.each_line.to_a },
241
+ "each" => ->(reader) { reader.each(&:itself) },
242
+ }.each do |reading, read_to_end|
243
+ it "raises a download failure that happens after reading began through #{reading}, then closes quietly" do
244
+ reader = file_whose_download_fails_later.read_io
245
+
246
+ expect { read_to_end.call(reader) }.to raise_error(EOFError, "Transfer response ended after 4096 of 22528 bytes")
247
+ expect { reader.close }.not_to raise_error
248
+ expect(reader).to be_closed
249
+ end
250
+ end
251
+
252
+ it "raises from eof? a failure that happens after the last full read" do
253
+ reader = file_whose_download_fails_later(drop_connection: false).read_io
254
+ expect(reader.read(4096).bytesize).to eq(4096)
255
+ server_gate.close
256
+
257
+ expect { reader.eof? }.to raise_error(EOFError, "Transfer response ended after 4096 of 22528 bytes")
258
+ expect { reader.close }.not_to raise_error
259
+ end
260
+
261
+ it "raises a download failure that happens after reading began from Files::File#read" do
262
+ file = file_whose_download_fails_later
263
+
264
+ expect { file.read }.to raise_error(EOFError, "Transfer response ended after 4096 of 22528 bytes")
265
+ ensure
266
+ file&.read_io&.close
267
+ end
268
+
269
+ it "raises from close a failure that IO.copy_stream read past" do
270
+ reader = file_whose_download_fails_later.read_io
271
+
272
+ Dir.mktmpdir do |dir|
273
+ copied_bytes = File.open(File.join(dir, "report.bin"), "wb") { |copy| IO.copy_stream(reader, copy) }
274
+ expect(copied_bytes).to eq(4096)
275
+ end
276
+ expect { reader.close }.to raise_error(EOFError, "Transfer response ended after 4096 of 22528 bytes")
277
+ expect(reader).to be_closed
278
+ end
279
+
280
+ it "raises from close a refusal that arrives after read_io stops waiting, releasing the pipe" do
281
+ server = serve do |client|
282
+ server_gate.pop
283
+ client.write(response("403 Forbidden", { "Content-Length" => 7 }, "refused"))
284
+ end
285
+ pipes_before = open_pipes
286
+ reader = remote_file(server).read_io # returns after its five-second wait for the first bytes
287
+ server_gate.close
288
+
289
+ nil while reader.getbyte
290
+
291
+ expect { reader.close }.to raise_error(Net::HTTPClientException, '403 "Forbidden"') { |close_error|
292
+ expect { reader.error! }.to raise_error(Net::HTTPClientException) { |stored_error| expect(stored_error).to equal(close_error) }
293
+ }
294
+ expect(open_pipes - pipes_before).to be_empty
295
+ end
296
+
297
+ it "does not let close replace an exception the caller is already raising" do
298
+ reader = file_whose_download_fails_later.read_io
299
+ callers_error = RuntimeError.new("caller failed")
300
+
301
+ expect {
302
+ begin
303
+ nil while reader.getbyte
304
+ raise callers_error
305
+ ensure
306
+ reader.close
307
+ end
308
+ }.to raise_error(RuntimeError) { |error| expect(error).to equal(callers_error) }
309
+ expect(reader).to be_closed
310
+ end
311
+
312
+ it "stops downloading once the caller closes the reader" do
313
+ body_bytes = 32 * 1024 * 1024 # far more than socket and pipe buffers hold
314
+ block = ("x" * 65_536).b
315
+ sent_everything = false
316
+ server = serve do |client|
317
+ client.write(response("200 OK", { "Content-Length" => body_bytes }))
318
+ (body_bytes / block.bytesize).times { client.write(block) }
319
+ sent_everything = true
320
+ end
321
+
322
+ reader = remote_file(server).read_io
323
+ reader.read(1024)
324
+ reader.close
325
+ server.close # returns once the server has stopped writing
326
+
327
+ expect(sent_everything).to be(false)
328
+ end
329
+
330
+ it "raises a refused transfer and closes the pipe it created" do
331
+ server = serve(response("403 Forbidden", { "Content-Length" => 0 }))
332
+ pipes_before = open_pipes
333
+
334
+ expect { remote_file(server).read_io }.to raise_error(Net::HTTPClientException, '403 "Forbidden"')
335
+ expect(open_pipes - pipes_before).to be_empty
336
+ end
337
+
338
+ it "raises a failed metadata lookup instead of returning an empty reader" do
339
+ not_found = { error: "Not Found", 'http-code': 404, type: "not-found" }.to_json
340
+ server = serve(response("404 Not Found", { "Content-Type" => "application/json", "Content-Length" => not_found.bytesize }, not_found))
341
+ Files.base_url = server.url("")
342
+
343
+ expect { described_class.new(path: "report.bin").read_io }.to raise_error(Files::NotFoundError)
344
+ end
345
+ end
346
+ end
@@ -0,0 +1,167 @@
1
+ require "spec_helper"
2
+ require_relative "../support/stubbed_api"
3
+
4
+ RSpec.describe "Saving and updating models" do
5
+ include StubbedApi
6
+
7
+ let(:requests) { [] }
8
+
9
+ # The API's canonical form of a workspace name.
10
+ def canonical_workspace(env, id)
11
+ name = Faraday::Utils.parse_nested_query(env.body)["name"]
12
+ { id: id, name: name.strip.capitalize }
13
+ end
14
+
15
+ it "creates a new workspace with one POST, then updates the same object in place with one PATCH each" do
16
+ client = stubbed_api_client do |stub|
17
+ stub.post("/api/rest/v1/workspaces") do |env|
18
+ requests << :post
19
+ json_response(201, canonical_workspace(env, 42))
20
+ end
21
+ stub.patch("/api/rest/v1/workspaces/42") do |env|
22
+ requests << :patch
23
+ json_response(200, canonical_workspace(env, 42))
24
+ end
25
+ end
26
+ workspace = Files::Workspace.new({ name: " operations " }, api_key: "key-a", client: client)
27
+
28
+ expect(workspace.save).to be(true)
29
+ expect(workspace.attributes).to eq(id: 42, name: "Operations")
30
+
31
+ workspace.name = " finance "
32
+ expect(workspace.save).to be(true)
33
+ expect(workspace.name).to eq("Finance")
34
+
35
+ response, options = workspace.update(name: " legal ")
36
+ expect(response).to be_a(Files::Response)
37
+ expect(response.data).to eq(id: 42, name: "Legal")
38
+ expect(options).to include(api_key: "key-a", client: client)
39
+ expect(workspace.attributes).to eq(id: 42, name: "Legal")
40
+ expect(workspace.options).to include(api_key: "key-a", client: client)
41
+ expect(requests).to eq(%i[post patch patch])
42
+ end
43
+
44
+ it "leaves the object and its options unchanged when the update fails" do
45
+ client = stubbed_api_client do |stub|
46
+ stub.patch("/api/rest/v1/workspaces/42") { json_response(404, { error: "Workspace not found", type: "not-found" }) }
47
+ end
48
+ workspace = Files::Workspace.new({ id: 42, name: "Operations" }, api_key: "key-a")
49
+ workspace.name = "Finance"
50
+
51
+ expect { client.request { workspace.save } }.to raise_error(Files::NotFoundError, "Workspace not found")
52
+ expect(workspace.attributes).to eq(id: 42, name: "Finance")
53
+ expect(workspace.options).to eq(api_key: "key-a")
54
+ end
55
+
56
+ { "JSON null" => "null", "a JSON list" => "[]" }.each do |description, body|
57
+ it "rejects #{description} as the updated workspace, leaving the object and its options unchanged" do
58
+ client = stubbed_api_client do |stub|
59
+ stub.patch("/api/rest/v1/workspaces/42") do
60
+ requests << :patch
61
+ [ 200, { "Content-Type" => "application/json" }, body ]
62
+ end
63
+ end
64
+ workspace = Files::Workspace.new({ id: 42, name: "Operations" }, api_key: "key-a")
65
+ workspace.name = "Finance"
66
+
67
+ expect { client.request { workspace.save } }.to raise_error(Files::APIConnectionError) { |error|
68
+ expect(error).to be_an_instance_of(Files::APIConnectionError)
69
+ expect(error.http_body).to eq(body)
70
+ }
71
+ expect(workspace.attributes).to eq(id: 42, name: "Finance")
72
+ expect(workspace.options).to eq(api_key: "key-a")
73
+ expect(requests).to eq([ :patch ])
74
+ end
75
+ end
76
+
77
+ it "rejects JSON null as the created workspace, keeping the unsaved object and its options" do
78
+ client = stubbed_api_client do |stub|
79
+ stub.post("/api/rest/v1/workspaces") do
80
+ requests << :post
81
+ [ 201, { "Content-Type" => "application/json" }, "null" ]
82
+ end
83
+ end
84
+ workspace = Files::Workspace.new({ name: "Draft" }, api_key: "key-a")
85
+
86
+ expect { client.request { workspace.save } }.to raise_error(Files::APIConnectionError) { |error|
87
+ expect(error).to be_an_instance_of(Files::APIConnectionError)
88
+ expect(error.http_status).to eq(201)
89
+ }
90
+ expect(workspace.attributes).to eq(name: "Draft")
91
+ expect(workspace.options).to eq(api_key: "key-a")
92
+ expect(requests).to eq([ :post ])
93
+ end
94
+
95
+ it "rejects class-level responses of the wrong shape without changing options, and accepts empty ones" do
96
+ json = { "Content-Type" => "application/json" }
97
+ client = stubbed_api_client do |stub|
98
+ stub.get("/api/rest/v1/workspaces/42") { [ 200, json, "null" ] }
99
+ stub.patch("/api/rest/v1/workspaces/42") { [ 200, json, "null" ] }
100
+ stub.get("/api/rest/v1/file_actions/zip_list/null.zip") { [ 200, json, "null" ] }
101
+ stub.post("/api/rest/v1/file_actions/begin_upload/object.csv") { [ 200, json, { part_number: 1 }.to_json ] }
102
+ stub.post("/api/rest/v1/file_actions/begin_upload/null-part.csv") { [ 200, json, "[null]" ] }
103
+ stub.get("/api/rest/v1/workspaces/7") { json_response(200, {}) }
104
+ stub.get("/api/rest/v1/file_actions/zip_list/empty.zip") { json_response(200, []) }
105
+ stub.post("/api/rest/v1/file_actions/begin_upload/empty-part.csv") { json_response(200, [ {} ]) }
106
+ end
107
+ options = { api_key: "key-a" }
108
+
109
+ client.request do
110
+ expect { Files::Workspace.find(42, {}, options) }.to raise_error(Files::APIConnectionError, /a Workspace object/)
111
+ expect { Files::Workspace.update(42, { name: "Legal" }, options) }.to raise_error(Files::APIConnectionError, /a Workspace object/)
112
+ expect { Files::File.zip_list_contents("null.zip", {}, options) }.to raise_error(Files::APIConnectionError, /a list of ZipListEntry objects/)
113
+ expect { Files::File.begin_upload("object.csv", {}, options) }.to raise_error(Files::APIConnectionError, /a list of FileUploadPart objects/)
114
+ expect { Files::File.begin_upload("null-part.csv", {}, options) }.to raise_error(Files::APIConnectionError, /a list of FileUploadPart objects/)
115
+ end
116
+ expect(options).to eq(api_key: "key-a")
117
+
118
+ client.request do
119
+ expect(Files::Workspace.find(7, {}, options).attributes).to eq({})
120
+ expect(Files::File.zip_list_contents("empty.zip", {}, options)).to eq([])
121
+ expect(Files::File.begin_upload("empty-part.csv", {}, options).map { |part| [ part.class, part.attributes ] }).to eq([ [ Files::FileUploadPart, {} ] ])
122
+ end
123
+ expect(options).to include(api_key: "key-a", client: client)
124
+ end
125
+
126
+ it "refreshes each model from its own client, keeping false, zero, empty, null and decimal values" do
127
+ backend_client = stubbed_api_client do |stub|
128
+ stub.patch("/api/rest/v1/remote_mount_backends/7") do |env|
129
+ requests << [ :backend, env.request_headers["X-FilesAPI-Key"], env.request_headers["X-Files-Workspace-Id"] ]
130
+ json_response(200, { id: 7, enabled: false, fall: 0, status: "", canary_file_path: nil, min_free_cpu: "12.50" })
131
+ end
132
+ end
133
+ workspace_client = stubbed_api_client do |stub|
134
+ stub.patch("/api/rest/v1/workspaces/42") do |env|
135
+ requests << [ :workspace, env.request_headers["X-FilesAPI-Key"], env.request_headers["X-Files-Workspace-Id"] ]
136
+ json_response(200, canonical_workspace(env, 42))
137
+ end
138
+ end
139
+ backend = Files::RemoteMountBackend.new({ id: 7, enabled: true, fall: 3, status: "healthy", canary_file_path: "/health", min_free_cpu: "50" }, api_key: "key-b", workspace_id: 2, client: backend_client)
140
+ workspace = Files::Workspace.new({ id: 42, name: "Operations" }, api_key: "key-a", workspace_id: 1, client: workspace_client)
141
+
142
+ backend.enabled = false
143
+ expect(backend.save).to be(true)
144
+ workspace.update(name: "legal")
145
+
146
+ expect([ backend.enabled, backend.fall, backend.status, backend.canary_file_path ]).to eq([ false, 0, "", nil ])
147
+ expect(backend.min_free_cpu).to eq(BigDecimal("12.5"))
148
+ expect(backend.options).to include(api_key: "key-b", workspace_id: 2, client: backend_client)
149
+ expect(workspace.name).to eq("Legal")
150
+ expect(workspace.options).to include(api_key: "key-a", workspace_id: 1, client: workspace_client)
151
+ expect(requests).to eq([ [ :backend, "key-b", "2" ], [ :workspace, "key-a", "1" ] ])
152
+ end
153
+
154
+ it "saves a model that can only be updated with one PATCH to its path" do
155
+ client = stubbed_api_client do |stub|
156
+ stub.patch("/api/rest/v1/styles/team/reports") do
157
+ requests << :patch
158
+ json_response(200, { id: 5, path: "team/reports", logo_click_href: "https://example.test/" })
159
+ end
160
+ end
161
+ style = Files::Style.new({ path: "team/reports", logo_click_href: "https://example.test" }, api_key: "key-a", client: client)
162
+
163
+ expect(style.save).to be(true)
164
+ expect(style.attributes).to eq(id: 5, path: "team/reports", logo_click_href: "https://example.test/")
165
+ expect(requests).to eq([ :patch ])
166
+ end
167
+ end
data/spec/spec_helper.rb CHANGED
@@ -1,5 +1,102 @@
1
1
  require "files.com"
2
+ require "json"
3
+ require "net/http"
2
4
  require "pathname"
5
+ require "securerandom"
6
+
7
+ # Examples tagged :with_test_folder send the generated SDK's real HTTP requests to the Files.com mock
8
+ # server's simulator, never to Files.com. FILES_MOCK_SERVER_HOST and FILES_MOCK_SERVER_PORT name its
9
+ # loopback address, and FILES_MOCK_SCHEMA_SHA256 the schema its readiness report must name (the
10
+ # schema_sha256 in its lib/simulation/generation.json). When one is missing, or the server there is not
11
+ # that simulator, the example fails before the SDK sends anything.
12
+ module MockServerFixture
13
+ LOOPBACK_HOSTS = %w[127.0.0.1 ::1 localhost].freeze
14
+ READY_TIMEOUT = 30
15
+ # Not a real API key: the simulator accepts any key and journals it only by number.
16
+ API_KEY = "synthetic-mock-server-api-key".freeze
17
+ PARENT = Pathname.new("files_regional_worker")
18
+
19
+ def self.origin
20
+ values = %w[FILES_MOCK_SERVER_HOST FILES_MOCK_SERVER_PORT FILES_MOCK_SCHEMA_SHA256].to_h { |name| [ name, ENV[name].to_s.strip ] }
21
+ missing = values.select { |_, value| value.empty? }.keys
22
+ raise "Set #{missing.join(", ")} for the Files.com mock server's simulator; these examples never use Files.com." if missing.any?
23
+
24
+ host, port, schema = values.values
25
+ raise "FILES_MOCK_SERVER_HOST must be a loopback address (#{LOOPBACK_HOSTS.join(", ")}), not #{host.inspect}." unless LOOPBACK_HOSTS.include?(host)
26
+ raise "FILES_MOCK_SERVER_PORT must be a port number, not #{port.inspect}." unless port.match?(/\A\d{1,5}\z/) && port.to_i.between?(1, 65_535)
27
+
28
+ origin = "http://#{host == "::1" ? "[::1]" : host}:#{port}"
29
+ expected = { "status" => "ready", "mode" => "simulation", "contract_version" => 3, "schema_sha256" => schema }
30
+ reported = ready(origin).slice(*expected.keys)
31
+ raise "#{origin} is not the expected simulator: it reports #{reported}, not #{expected}." unless reported == expected
32
+
33
+ origin
34
+ end
35
+
36
+ # Waits up to READY_TIMEOUT for something to listen at origin. Whatever answers first decides: a
37
+ # legacy mock server or any other server has no readiness report.
38
+ def self.ready(origin)
39
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + READY_TIMEOUT
40
+ begin
41
+ response = control(origin, "ready")
42
+ rescue SystemCallError, Net::OpenTimeout
43
+ raise "Nothing answered at #{origin} within #{READY_TIMEOUT} seconds." if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
44
+
45
+ sleep 0.2
46
+ retry
47
+ end
48
+ raise "#{origin} answered #{response.code} for its readiness report, so it is not the simulator." unless response.code == "200"
49
+
50
+ JSON.parse(response.body)
51
+ rescue JSON::ParserError
52
+ raise "#{origin} answered its readiness report with something other than JSON, so it is not the simulator."
53
+ end
54
+
55
+ def self.control(origin, name)
56
+ uri = URI("#{origin}/__files_mock/v1/#{name}")
57
+ Net::HTTP.start(uri.hostname, uri.port, open_timeout: 5, read_timeout: 10) { |http| http.get(uri.request_uri) }
58
+ end
59
+
60
+ def self.journal(origin)
61
+ journal = JSON.parse(control(origin, "journal").body)
62
+ raise "The simulator's journal is incomplete, so it cannot show which requests the SDK sent." unless journal["complete"] == true
63
+
64
+ journal.fetch("entries")
65
+ end
66
+
67
+ # The API requests the example sent: each carried the same synthetic API key (the journal numbers keys
68
+ # and never records them), no session, and the Ruby SDK's User-Agent. Transfers to the upload and
69
+ # download URLs the simulator issued carry no credential, as presigned URLs do not.
70
+ def self.check_requests(origin, after)
71
+ entries = journal(origin).select { |entry| entry.fetch("seq") > after && entry.fetch("path").start_with?("/api/rest/v1/") }
72
+ raise "No API request reached the simulator." if entries.empty?
73
+
74
+ keys = entries.map { |entry| entry.dig("credentials", "api_key") }.uniq
75
+ raise "The SDK's requests carried API keys #{keys.inspect}, not one key on every request." unless keys.size == 1 && keys.first.is_a?(Integer)
76
+ raise "An SDK request carried a session." if entries.any? { |entry| entry.dig("credentials", "session") }
77
+
78
+ agents = entries.map { |entry| entry["user_agent"] }.uniq
79
+ raise "Requests came with User-Agents #{agents.inspect}, not only the Ruby SDK's." unless agents.all? { |agent| agent.to_s.start_with?("Files.com Ruby SDK v") }
80
+ end
81
+
82
+ # Deletes the example's folder with everything in it, then the sibling made beside it, checking after
83
+ # each delete that only that folder went: the parent stays, and the sibling, whose name begins with the
84
+ # example folder's, stays until its own delete.
85
+ def self.remove(created, test_folder, sibling, options)
86
+ if created.include?(test_folder)
87
+ Files::Folder.delete(test_folder.to_s, { recursive: true }, options)
88
+ left = [ test_folder, test_folder.join("nested") ].select { |folder| Files::Folder.exist?(folder.to_s, options) }
89
+ raise "Deleting #{test_folder} left #{left.join(", ")}." if left.any?
90
+ end
91
+ lost = [ PARENT, (sibling if created.include?(sibling)) ].compact.reject { |folder| Files::Folder.exist?(folder.to_s, options) }
92
+ raise "Deleting #{test_folder} also removed #{lost.join(", ")}." if lost.any?
93
+ return unless created.include?(sibling)
94
+
95
+ Files::Folder.delete(sibling.to_s, {}, options)
96
+ raise "Deleting #{sibling} left it." if Files::Folder.exist?(sibling.to_s, options)
97
+ raise "Deleting #{sibling} also removed #{PARENT}." unless Files::Folder.exist?(PARENT.to_s, options)
98
+ end
99
+ end
3
100
 
4
101
  RSpec.configure do |config|
5
102
  config.expect_with :rspec do |expectations|
@@ -19,14 +116,33 @@ RSpec.configure do |config|
19
116
  config.include_context "API Helpers", :with_test_folder
20
117
 
21
118
  config.around(:example, :with_test_folder) do |all|
22
- @api_key = ENV["TEST_API_KEY"] || File.read("../../config/test_api_key.txt")
23
- @test_folder = Pathname.new("files_regional_worker/#{SecureRandom.uuid}")
24
- Files::Folder.exist?("files_regional_worker", api_key: @api_key)
25
- Files::Folder.mkdir("files_regional_worker", {}, api_key: @api_key) unless Files::Folder.exist?("files_regional_worker", api_key: @api_key)
26
- Files::Folder.mkdir(@test_folder.to_s, {}, api_key: @api_key)
119
+ base_url = Files.base_url
120
+ language = Files.language
121
+ origin = MockServerFixture.origin
122
+ Files.base_url = origin
123
+ after = MockServerFixture.journal(origin).map { |entry| entry.fetch("seq") }.max || 0
124
+ @api_key = MockServerFixture::API_KEY
125
+ @test_folder = MockServerFixture::PARENT.join(SecureRandom.uuid)
126
+ sibling = MockServerFixture::PARENT.join("#{@test_folder.basename}-sibling")
127
+ created = []
128
+ Files::Folder.mkdir(MockServerFixture::PARENT.to_s, {}, api_key: @api_key) unless Files::Folder.exist?(MockServerFixture::PARENT.to_s, api_key: @api_key)
129
+ [ sibling, @test_folder, @test_folder.join("nested") ].each do |folder|
130
+ Files::Folder.mkdir(folder.to_s, {}, api_key: @api_key)
131
+ created << folder
132
+ end
27
133
  all.run
28
134
  ensure
29
- Files::Folder.delete(@test_folder.to_s, { recursive: true }, api_key: @api_key)
135
+ begin
136
+ if created&.any?
137
+ MockServerFixture.remove(created, @test_folder, sibling, { api_key: @api_key })
138
+ MockServerFixture.check_requests(origin, after)
139
+ end
140
+ ensure
141
+ Files.base_url = base_url
142
+ left_language = Files.language
143
+ Files.language = language
144
+ end
145
+ raise "The example left Files.language set to #{left_language.inspect}." unless left_language == language
30
146
  end
31
147
 
32
148
  config.shared_context_metadata_behavior = :apply_to_host_groups
@@ -0,0 +1,14 @@
1
+ # Specs that include this call the Files.com API through the SDK's own
2
+ # connection, with only the network replaced by Faraday test stubs, so requests,
3
+ # responses and errors take the real SDK path.
4
+ module StubbedApi
5
+ def stubbed_api_client(&routes)
6
+ connection = Files::ApiClient.build_default_conn
7
+ connection.adapter :test, Faraday::Adapter::Test::Stubs.new(&routes)
8
+ Files::ApiClient.new(connection)
9
+ end
10
+
11
+ def json_response(status, body, headers = {})
12
+ [ status, { "Content-Type" => "application/json" }.merge(headers), body.to_json ]
13
+ end
14
+ end