Skip to content

Commit deee91a

Browse files
test(chunk-upload): Add test for full chunk upload
Add a test for an entire chunk upload (with only one chunk). The test verifies that the Sentry CLI makes the expected API calls to the assemble endpiont and to the chunk upload endpoint. We check that the request data (including the uploaded file chunk) are exactly the same as what we expect them to be. Our expectation is based on current behavior as of when this change is being made; we assume the current behavior is the correct behavior. Also, bump MSRV to 1.80 to allow using `LazyLock`, and expect a lint that shows up only after bumping MSRV (I will fix the lint in a separate PR).
1 parent 714c056 commit deee91a

File tree

5 files changed

+137
-2
lines changed

5 files changed

+137
-2
lines changed

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build = "build.rs"
44
name = "sentry-cli"
55
version = "2.39.1"
66
edition = "2021"
7-
rust-version = "1.65"
7+
rust-version = "1.80"
88

99
[dependencies]
1010
anylog = "0.6.3"

src/commands/sourcemaps/explain.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ fn fetch_release_artifact_file_metadata(
228228
release,
229229
&artifact.id,
230230
)?;
231+
#[expect(clippy::manual_inspect)]
231232
file_metadata
232233
.ok_or_else(|| format_err!("Could not retrieve file metadata: {}", &artifact.id))
233234
.map(|f| {
Binary file not shown.

tests/integration/debug_files/upload.rs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,23 @@
1+
use std::sync::atomic::{AtomicBool, Ordering};
2+
use std::sync::LazyLock;
3+
use std::{fs, str};
4+
15
use assert_cmd::Command;
6+
use regex::bytes::Regex;
27

38
use crate::integration::{test_utils::env, MockEndpointBuilder, TestManager};
49

10+
/// This regex is used to extract the boundary from the content-type header.
11+
/// We need to match the boundary, since it changes with each request.
12+
/// The regex matches the format as specified in
13+
/// https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html.
14+
static CONTENT_TYPE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
15+
Regex::new(
16+
r#"^multipart\/form-data; boundary=(?<boundary>[\w'\(\)+,\-\.\/:=? ]{0,69}[\w'\(\)+,\-\.\/:=?])$"#
17+
)
18+
.expect("Regex is valid")
19+
});
20+
521
#[test]
622
fn command_debug_files_upload() {
723
TestManager::new()
@@ -189,3 +205,110 @@ fn ensure_correct_assemble_call() {
189205
manager.assert_mock_endpoints();
190206
command_result.success();
191207
}
208+
209+
#[test]
210+
/// This test simulates a full chunk upload (with only one chunk).
211+
/// It verifies that the Sentry CLI makes the expected API calls to the chunk upload endpoint
212+
/// and that the data sent to the chunk upload endpoint is exactly as expected.
213+
/// It also verifies that the correct calls are made to the assemble endpoint.
214+
fn ensure_correct_chunk_upload() {
215+
let is_first_assemble_call = AtomicBool::new(true);
216+
let expected_chunk_body =
217+
fs::read("tests/integration/_expected_requests/debug_files/upload/chunk_upload.bin")
218+
.expect("expected chunk body file should be present");
219+
220+
let manager = TestManager::new()
221+
.mock_endpoint(
222+
MockEndpointBuilder::new("GET", "/api/0/organizations/wat-org/chunk-upload/")
223+
.with_response_file("debug_files/get-chunk-upload.json"),
224+
)
225+
.mock_endpoint(
226+
MockEndpointBuilder::new("POST", "/api/0/organizations/wat-org/chunk-upload/")
227+
.with_response_fn(move |request| {
228+
let content_type_headers = request.header("content-type");
229+
assert_eq!(
230+
content_type_headers.len(),
231+
1,
232+
"content-type header should be present exactly once, found {} times",
233+
content_type_headers.len()
234+
);
235+
236+
let content_type = content_type_headers[0].as_bytes();
237+
238+
let boundary = CONTENT_TYPE_REGEX
239+
.captures(content_type)
240+
.expect("content-type should match regex")
241+
.name("boundary")
242+
.expect("boundary should be present")
243+
.as_bytes();
244+
245+
let boundary_str = str::from_utf8(boundary).expect("boundary should be valid utf-8");
246+
247+
let boundary_escaped = regex::escape(boundary_str);
248+
249+
let body_regex = Regex::new(&format!(
250+
r#"^--{boundary_escaped}(?<chunk_body>(?s-u:.)*?)--{boundary_escaped}--\s*$"#
251+
))
252+
.expect("regex should be valid");
253+
254+
let body = request.body().expect("body should be readable");
255+
256+
let chunk_body = body_regex
257+
.captures(body)
258+
.expect("body should match regex")
259+
.name("chunk_body")
260+
.expect("chunk_body section should be present")
261+
.as_bytes();
262+
263+
assert_eq!(chunk_body, expected_chunk_body);
264+
265+
vec![] // Client does not expect a response body
266+
}),
267+
)
268+
.mock_endpoint(
269+
MockEndpointBuilder::new(
270+
"POST",
271+
"/api/0/projects/wat-org/wat-project/files/difs/assemble/",
272+
)
273+
.with_header_matcher("content-type", "application/json")
274+
.with_matcher(r#"{"21b76b717dbbd8c89e42d92b29667ac87aa3c124":{"name":"SrcGenSampleApp.pdb","debug_id":"c02651ae-cd6f-492d-bc33-0b83111e7106-8d8e7c60","chunks":["21b76b717dbbd8c89e42d92b29667ac87aa3c124"]}}"#)
275+
.with_response_fn(move |_| {
276+
if is_first_assemble_call.swap(false, Ordering::Relaxed) {
277+
r#"{
278+
"21b76b717dbbd8c89e42d92b29667ac87aa3c124": {
279+
"state": "not_found",
280+
"missingChunks": ["21b76b717dbbd8c89e42d92b29667ac87aa3c124"]
281+
}
282+
}"#
283+
} else {
284+
r#"{
285+
"21b76b717dbbd8c89e42d92b29667ac87aa3c124": {
286+
"state": "created",
287+
"missingChunks": []
288+
}
289+
}"#
290+
}
291+
.into()
292+
})
293+
.expect(2),
294+
);
295+
296+
let mut command = Command::cargo_bin("sentry-cli").expect("sentry-cli should be available");
297+
298+
command.args(
299+
"debug-files upload --include-sources tests/integration/_fixtures/SrcGenSampleApp.pdb"
300+
.split(' '),
301+
);
302+
303+
env::set_all(manager.server_info(), |k, v| {
304+
command.env(k, v.as_ref());
305+
});
306+
307+
let command_result = command.assert();
308+
309+
// First assert the mock was called as expected, then that the command was successful.
310+
// This is because failure with the mock assertion can cause the command to fail, and
311+
// the mock assertion failure is likely more interesting in this case.
312+
manager.assert_mock_endpoints();
313+
command_result.success();
314+
}

tests/integration/test_utils/mock_endpoint_builder.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use mockito::{IntoHeaderName, Matcher, Mock, ServerGuard};
1+
use mockito::{IntoHeaderName, Matcher, Mock, Request, ServerGuard};
22

33
/// Builder for a mock endpoint.
44
///
@@ -41,6 +41,17 @@ impl MockEndpointBuilder {
4141
self
4242
}
4343

44+
/// Set the response body of the mock endpoint using a function that takes
45+
/// the request and returns the response body.
46+
/// This function can also be used to perform arbitrary assertions on the request.
47+
pub fn with_response_fn(
48+
mut self,
49+
callback: impl Fn(&Request) -> Vec<u8> + Send + Sync + 'static,
50+
) -> Self {
51+
self.builder = Box::new(|server| (self.builder)(server).with_body_from_request(callback));
52+
self
53+
}
54+
4455
/// Set the response body of the mock endpoint to a file with the given path.
4556
/// The path is relative to the `tests/integration/_responses` directory.
4657
pub fn with_response_file(mut self, path: &str) -> Self {

0 commit comments

Comments
 (0)