diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift index 8751456864..b2cb06336e 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift @@ -1,13 +1,13 @@ import Foundation +import UniformTypeIdentifiers /// Warning codes for network body capture issues. /// /// Raw values must match the frontend constants so the Sentry UI renders the correct warnings. /// - SeeAlso: https://github.com/getsentry/sentry/blob/8b79857b2eff86f4df2f3abaf1e46c74893e3781/static/app/utils/replays/replay.tsx#L5 enum NetworkBodyWarning: String { - case jsonTruncated = "JSON_TRUNCATED" + case jsonTruncated = "MAYBE_JSON_TRUNCATED" case textTruncated = "TEXT_TRUNCATED" - case invalidJson = "INVALID_JSON" case bodyParseError = "BODY_PARSE_ERROR" } @@ -54,6 +54,151 @@ enum NetworkBodyWarning: String { self.warnings = warnings } + /// Parses raw body data based on content type. + /// + /// Returns nil if data is empty. Truncates to `maxBodySize` and adds + /// appropriate warnings. Supports JSON, form-urlencoded, and text. + init?(data: Data, contentType: String?) { + guard !data.isEmpty else { return nil } + + let limit = SentryReplayNetworkDetails.maxBodySize + let isTruncated = data.count > limit + let slice = data.prefix(limit) + + var warnings = [NetworkBodyWarning]() + let (mimeType, encoding) = Body.parseMimeAndEncoding(from: contentType) + + if mimeType == "application/x-www-form-urlencoded" { + if isTruncated { warnings.append(.textTruncated) } + self = Body.parseFormEncoded(slice, encoding: encoding, warnings: &warnings) + } else if #available(macOS 11, *), let parsed = Body.parseByMimeType(mimeType, data: slice, encoding: encoding, isTruncated: isTruncated, warnings: &warnings) { + self = parsed + } else { + let description = "[Body not captured: contentType=\(contentType ?? "unknown") (\(data.count) bytes)]" + self = Body(content: description) + } + } + + // MARK: - Private Parsing + + /// Extracts MIME type and string encoding from a Content-Type header value. + /// + /// Returns `.utf8` when the charset parameter is missing or unrecognized. + /// + /// Examples: + /// - `"application/json"` → `("application/json", .utf8)` + /// - `"text/html; charset=iso-8859-1"` → `("text/html", .isoLatin1)` + /// - `nil` → `(nil, .utf8)` + static func parseMimeAndEncoding(from contentType: String?) -> (mimeType: String?, encoding: String.Encoding) { + guard let contentType else { return (nil, .utf8) } + + let parts = contentType.split(separator: ";") + let mimeType = parts.first.map { String($0).trimmingCharacters(in: .whitespaces).lowercased() } + + var encoding: String.Encoding = .utf8 + for part in parts.dropFirst() { + let trimmed = part.trimmingCharacters(in: .whitespaces) + guard trimmed.lowercased().hasPrefix("charset=") else { continue } + let charsetValue = String(trimmed.dropFirst("charset=".count)) + .trimmingCharacters(in: .whitespaces) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + encoding = stringEncoding(fromCharset: charsetValue) + break + } + return (mimeType, encoding) + } + + /// Converts an IANA charset name to a `String.Encoding`. + /// + /// Returns `.utf8` for unrecognized or empty charset names. + private static func stringEncoding(fromCharset charset: String) -> String.Encoding { + guard !charset.isEmpty else { return .utf8 } + let cfEncoding = CFStringConvertIANACharSetNameToEncoding(charset as CFString) + guard cfEncoding != kCFStringEncodingInvalidId else { return .utf8 } + return String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(cfEncoding)) + } + + /// Uses UTType to detect JSON/text content types. Returns nil for + /// unrecognized types so the caller can fall through to a placeholder. + /// UTType requires macOS 11+; so this will not compile there. + @available(macOS 11, *) + private static func parseByMimeType(_ mimeType: String?, data: Data, encoding: String.Encoding, isTruncated: Bool, warnings: inout [NetworkBodyWarning]) -> Body? { + guard let utType = mimeType.flatMap({ UTType(mimeType: $0) }) else { + return nil + } + if utType.conforms(to: .json) { + if isTruncated { warnings.append(.jsonTruncated) } + return parseJSON(data, encoding: encoding, warnings: &warnings) + } + if utType.conforms(to: .text) { + if isTruncated { warnings.append(.textTruncated) } + return parseText(data, encoding: encoding, warnings: &warnings) + } + return nil + } + + private static func parseJSON(_ data: Data, encoding: String.Encoding = .utf8, warnings: inout [NetworkBodyWarning]) -> Body { + do { + let json = try JSONSerialization.jsonObject(with: data, options: .mutableContainers) + return Body(content: json, warnings: warnings) + } catch { + warnings.append(.bodyParseError) + return parseText(data, encoding: encoding, warnings: &warnings) + } + } + + /// Parses `application/x-www-form-urlencoded` data into a dictionary. + private static func parseFormEncoded(_ data: Data, encoding: String.Encoding, warnings: inout [NetworkBodyWarning]) -> Body { + guard let urlEncodedFormData = String(data: data, encoding: encoding) ?? String(data: data, encoding: .utf8) else { + warnings.append(.bodyParseError) + return parseText(data, encoding: encoding, warnings: &warnings) + } + + var formData = [String: Any]() + for rawElement in urlEncodedFormData.components(separatedBy: "&") where !rawElement.isEmpty { + let comps = rawElement.components(separatedBy: "=") + if comps.count < 2 { + warnings.append(.bodyParseError) + return parseText(data, encoding: encoding, warnings: &warnings) + } + let key = decodeFormComponent(comps[0]) + let value = decodeFormComponent(comps.dropFirst().joined(separator: "=")) + guard !key.isEmpty else { continue } + if let existing = formData[key] { + if var list = existing as? [String] { + list.append(value) + formData[key] = list + } else if let text = existing as? String { + formData[key] = [text, value] + } + } else { + formData[key] = value + } + } + return Body(content: formData, warnings: warnings) + } + + /// Decodes a form-urlencoded component: converts `+` to space and removes percent-encoding. + /// Falls back to the `+`-to-space result if percent-decoding fails (e.g. `%ZZ`). + private static func decodeFormComponent(_ component: String) -> String { + let plusDecoded = component.replacingOccurrences(of: "+", with: " ") + return plusDecoded.removingPercentEncoding ?? plusDecoded + } + + private static func parseText(_ data: Data, encoding: String.Encoding = .utf8, warnings: inout [NetworkBodyWarning]) -> Body { + // Truncation at a multi-byte boundary (e.g. UTF-8 CJK, emoji) makes + // String(data:encoding:) return nil. Try dropping up to 3 trailing bytes + // to find a valid boundary before giving up. + for drop in 0...min(3, data.count) { + let slice = drop == 0 ? data : data.dropLast(drop) + if let string = String(data: slice, encoding: encoding) ?? String(data: slice, encoding: .utf8) { + return Body(content: string, warnings: warnings) + } + } + warnings.append(.bodyParseError) + return Body(content: "", warnings: warnings) + } + func serialize() -> [String: Any] { var result = [String: Any]() result["body"] = content.serializedValue @@ -79,11 +224,18 @@ enum NetworkBodyWarning: String { } } - // MARK: - Properties + // MARK: - Constants + + /// Maximum body size in bytes before truncation. + /// Mirrors `NETWORK_BODY_MAX_SIZE` from sentry-javascript's replay-internal: + /// https://github.com/getsentry/sentry-javascript/blob/399cc859ce250ba5db3656685bd05794f571bee5/packages/replay-internal/src/constants.ts#L33 + static let maxBodySize = 150_000 /// Key used to store network details in breadcrumb data dictionary. @objc public static let replayNetworkDetailsKey = "_networkDetails" + // MARK: - Properties + private(set) var method: String? private(set) var statusCode: NSNumber? private(set) var request: Detail? @@ -111,13 +263,14 @@ enum NetworkBodyWarning: String { /// - Parameters: /// - size: Request body size in bytes, or nil if unknown. /// - body: Pre-parsed body content (dictionary, array, or string), or nil if not captured. - /// - headers: Filtered HTTP request headers. + /// - allHeaders: All headers from the request (e.g. from `NSURLRequest.allHTTPHeaderFields`). + /// - configuredHeaders: Header names to extract, matched case-insensitively. @objc - public func setRequest(size: NSNumber?, body: Any?, headers: [String: String]) { + public func setRequest(size: NSNumber?, body: Any?, allHeaders: [String: Any]?, configuredHeaders: [String]?) { self.request = Detail( size: size, body: body.map { Body(content: $0) }, - headers: headers + headers: SentryReplayNetworkDetails.extractHeaders(from: allHeaders, matching: configuredHeaders) ) } @@ -127,17 +280,43 @@ enum NetworkBodyWarning: String { /// - statusCode: HTTP status code. /// - size: Response body size in bytes, or nil if unknown. /// - body: Pre-parsed body content (dictionary, array, or string), or nil if not captured. - /// - headers: Filtered HTTP response headers. + /// - allHeaders: All headers from the response (e.g. from `NSHTTPURLResponse.allHeaderFields`). + /// - configuredHeaders: Header names to extract, matched case-insensitively. @objc - public func setResponse(statusCode: Int, size: NSNumber?, body: Any?, headers: [String: String]) { + public func setResponse(statusCode: Int, size: NSNumber?, body: Any?, allHeaders: [String: Any]?, configuredHeaders: [String]?) { self.statusCode = NSNumber(value: statusCode) self.response = Detail( size: size, body: body.map { Body(content: $0) }, - headers: headers + headers: SentryReplayNetworkDetails.extractHeaders(from: allHeaders, matching: configuredHeaders) ) } + // MARK: - Header Extraction + + /// Extracts headers from a source dictionary using case-insensitive matching. + /// Preserves the original casing of the header key as seen in the source. + /// + /// - Parameters: + /// - sourceHeaders: All available headers (e.g. from `NSURLRequest` or `NSHTTPURLResponse`). + /// - configuredHeaders: Header names to extract, matched case-insensitively. + /// - Returns: Dictionary containing matched headers with original key casing preserved. + static func extractHeaders(from sourceHeaders: [String: Any]?, matching configuredHeaders: [String]?) -> [String: String] { + guard let sourceHeaders, let configuredHeaders else { return [:] } + + var extracted = [String: String]() + for configured in configuredHeaders { + let lowered = configured.lowercased() + for (key, value) in sourceHeaders { + if key.lowercased() == lowered { + extracted[key] = (value as? String) ?? "\(value)" + break + } + } + } + return extracted + } + // MARK: - Serialization /// Serializes to dictionary for inclusion in breadcrumb data. diff --git a/Tests/SentryTests/Networking/SentryReplayNetworkDetailsBodyTests.swift b/Tests/SentryTests/Networking/SentryReplayNetworkDetailsBodyTests.swift new file mode 100644 index 0000000000..19cc6a17a6 --- /dev/null +++ b/Tests/SentryTests/Networking/SentryReplayNetworkDetailsBodyTests.swift @@ -0,0 +1,496 @@ +@_spi(Private) @testable import Sentry +import XCTest + +class SentryReplayNetworkDetailsBodyTests: XCTestCase { + + private typealias Body = SentryReplayNetworkDetails.Body + + // MARK: - Initialization Tests + + func testInit_withJSONDictionary_shouldParseCorrectly() { + // -- Arrange -- + let bodyContent: [String: Any] = ["key": "value", "number": 42] + let bodyData: Data + do { + bodyData = try JSONSerialization.data(withJSONObject: bodyContent) + } catch { + return XCTFail("Failed to create JSON data: \(error)") + } + + // -- Act -- + let body = Body(data: bodyData, contentType: "application/json") + + // -- Assert -- + if case .json(let value) = body?.content { + let dict = value as? [String: Any] + XCTAssertEqual(dict?["key"] as? String, "value") + XCTAssertEqual(dict?["number"] as? Int, 42) + } else { + XCTFail("Expected .json content") + } + } + + func testInit_withJSONArray_shouldParseCorrectly() { + // -- Arrange -- + let bodyContent = ["item1", "item2", "item3"] + let bodyData: Data + do { + bodyData = try JSONSerialization.data(withJSONObject: bodyContent) + } catch { + return XCTFail("Failed to create JSON data: \(error)") + } + + // -- Act -- + let body = Body(data: bodyData, contentType: "application/json") + + // -- Assert -- + if case .json(let value) = body?.content { + let array = value as? [String] + XCTAssertEqual(array?.count, 3) + XCTAssertEqual(array?[0], "item1") + XCTAssertEqual(array?[1], "item2") + XCTAssertEqual(array?[2], "item3") + } else { + XCTFail("Expected .json content") + } + } + + func testInit_withTextData_shouldStoreAsString() { + // -- Arrange -- + let bodyContent = "This is plain text content" + guard let bodyData = bodyContent.data(using: .utf8) else { + return XCTFail("Failed to create text data") + } + + // -- Act -- + let body = Body(data: bodyData, contentType: "text/plain") + + // -- Assert -- + XCTAssertNotNil(body) + if case .text(let string) = body?.content { + XCTAssertEqual(string, bodyContent) + } else { + XCTFail("Expected .text content") + } + } + + func testInit_withEmptyData_shouldReturnNil() { + // -- Act -- + let body = Body(data: Data(), contentType: "application/json") + + // -- Assert -- + XCTAssertNil(body) + } + + func testInit_withInvalidJSON_shouldFallbackToString() { + // -- Arrange -- + let invalidJSON = "{ invalid json }" + guard let bodyData = invalidJSON.data(using: .utf8) else { + return XCTFail("Failed to create data") + } + + // -- Act -- + let body = Body(data: bodyData, contentType: "application/json") + + // -- Assert -- + XCTAssertNotNil(body) + if case .text(let string) = body?.content { + XCTAssertEqual(string, invalidJSON) + } else { + XCTFail("Expected .text fallback for invalid JSON") + } + XCTAssertTrue(body?.warnings.contains(.bodyParseError) == true) + } + + func testInit_withLargeData_shouldTruncate() { + // -- Arrange -- + let largeString = String(repeating: "a", count: 200_000) + guard let bodyData = largeString.data(using: .utf8) else { + return XCTFail("Failed to create large data") + } + + // -- Act -- + let body = Body(data: bodyData, contentType: "text/plain") + + // -- Assert -- + XCTAssertNotNil(body) + if case .text(let string) = body?.content { + XCTAssertLessThanOrEqual(string.count, SentryReplayNetworkDetails.maxBodySize) + } else { + XCTFail("Expected .text content") + } + + XCTAssertTrue(body?.warnings.contains(.textTruncated) == true) + + // Check serialized warnings + let serialized = body?.serialize() + if let warningStrings = serialized?["warnings"] as? [String] { + XCTAssertEqual(warningStrings, ["TEXT_TRUNCATED"]) + } else { + XCTFail("Expected warnings to be serialized as string array") + } + } + + func testInit_withBinaryContentType_shouldCreateArtificialString() { + // -- Arrange -- + let binaryData = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) // PNG header + let contentType = "image/png" + + // -- Act -- + let body = Body(data: binaryData, contentType: contentType) + + // -- Assert -- + XCTAssertNotNil(body) + if case .text(let string) = body?.content { + XCTAssertTrue(string.hasPrefix("[Body not captured")) + XCTAssertTrue(string.contains("8 bytes")) + XCTAssertTrue(string.contains("image/png")) + } else { + XCTFail("Expected .text content with binary description") + } + + let result = body?.serialize() + if let bodyString = result?["body"] as? String { + XCTAssertTrue(bodyString.hasPrefix("[Body not captured")) + } else { + XCTFail("Expected body to be a string with binary data prefix") + } + } + + func testInit_withNilContentType_shouldCreatePlaceholder() { + // -- Arrange -- + let data = Data([0x00, 0x01, 0x02, 0x03]) + + // -- Act -- + let body = Body(data: data, contentType: nil) + + // -- Assert -- + XCTAssertNotNil(body) + if case .text(let string) = body?.content { + XCTAssertTrue(string.hasPrefix("[Body not captured")) + XCTAssertTrue(string.contains("4 bytes")) + XCTAssertTrue(string.contains("unknown")) + } else { + XCTFail("Expected .text content with placeholder description") + } + } + + func testInit_withUnrecognizedContentType_shouldCreatePlaceholder() { + // -- Arrange -- + let data = Data("some data".utf8) + + // -- Act -- + let body = Body(data: data, contentType: "application/x-custom-format") + + // -- Assert -- + XCTAssertNotNil(body) + if case .text(let string) = body?.content { + XCTAssertTrue(string.hasPrefix("[Body not captured")) + XCTAssertTrue(string.contains("application/x-custom-format")) + XCTAssertTrue(string.contains("9 bytes")) + } else { + XCTFail("Expected .text content with placeholder description") + } + } + + // MARK: - Form URL-Encoded Parsing + + func testInit_withFormURLEncoded_shouldParseAsForm() { + // -- Arrange -- + let formString = "key1=value1&key2=value2&key3=value%20with%20spaces" + guard let bodyData = formString.data(using: .utf8) else { + return XCTFail("Failed to create form data") + } + + // -- Act -- + let body = Body(data: bodyData, contentType: "application/x-www-form-urlencoded") + + // -- Assert -- + if case .json(let value) = body?.content { + let dict = value as? [String: String] + XCTAssertEqual(dict?["key1"], "value1") + XCTAssertEqual(dict?["key2"], "value2") + XCTAssertEqual(dict?["key3"], "value with spaces") + } else { + XCTFail("Expected .json content for form data") + } + } + + func testInit_withFormURLEncoded_duplicateKeys_shouldPromoteToArray() throws { + // -- Act -- + let body = try XCTUnwrap(Body( + data: "color=red&color=blue&color=green".data(using: .utf8)!, + contentType: "application/x-www-form-urlencoded; charset=utf-8" + )) + + // -- Assert -- + let dict = try XCTUnwrap(body.serialize()["body"] as? [String: Any]) + XCTAssertEqual(dict["color"] as? [String], ["red", "blue", "green"]) + } + + func testInit_withFormURLEncoded_emptyValue_shouldParseAsEmptyString() throws { + // -- Act -- + let body = try XCTUnwrap(Body( + data: "key1=&key2=value2".data(using: .utf8)!, + contentType: "application/x-www-form-urlencoded; charset=utf-8" + )) + + // -- Assert -- + let dict = try XCTUnwrap(body.serialize()["body"] as? [String: Any]) + XCTAssertEqual(dict["key1"] as? String, "") + XCTAssertEqual(dict["key2"] as? String, "value2") + } + + func testInit_withFormURLEncoded_missingEquals_shouldFallbackToText() throws { + // -- Act -- + let body = try XCTUnwrap(Body( + data: "key1=value1&malformed&key2=value2".data(using: .utf8)!, + contentType: "application/x-www-form-urlencoded; charset=utf-8" + )) + + // -- Assert -- + let serialized = body.serialize() + let text = try XCTUnwrap(serialized["body"] as? String) + XCTAssertEqual(text, "key1=value1&malformed&key2=value2") + let warnings = try XCTUnwrap(serialized["warnings"] as? [String]) + XCTAssertTrue(warnings.contains("BODY_PARSE_ERROR")) + } + + func testInit_withFormURLEncoded_emptyKeys_shouldBeSkipped() throws { + // -- Act -- + let body = try XCTUnwrap(Body( + data: "=value1&key2=value2".data(using: .utf8)!, + contentType: "application/x-www-form-urlencoded; charset=utf-8" + )) + + // -- Assert -- + let dict = try XCTUnwrap(body.serialize()["body"] as? [String: Any]) + XCTAssertNil(dict[""]) + XCTAssertEqual(dict["key2"] as? String, "value2") + } + + func testInit_withFormURLEncoded_equalsInValue_shouldPreserve() throws { + // -- Act -- + let body = try XCTUnwrap(Body( + data: "query=a=1&token=abc".data(using: .utf8)!, + contentType: "application/x-www-form-urlencoded; charset=utf-8" + )) + + // -- Assert -- + let dict = try XCTUnwrap(body.serialize()["body"] as? [String: Any]) + XCTAssertEqual(dict["query"] as? String, "a=1") + XCTAssertEqual(dict["token"] as? String, "abc") + } + + func testInit_withFormURLEncoded_plusAsSpace_shouldDecode() throws { + // -- Act -- + let body = try XCTUnwrap(Body( + data: "greeting=hello+world&name=Jane+Doe".data(using: .utf8)!, + contentType: "application/x-www-form-urlencoded; charset=utf-8" + )) + + // -- Assert -- + let dict = try XCTUnwrap(body.serialize()["body"] as? [String: Any]) + XCTAssertEqual(dict["greeting"] as? String, "hello world") + XCTAssertEqual(dict["name"] as? String, "Jane Doe") + } + + func testFormEncoded_malformedPercentEncoding_shouldPreservePlusDecodingAndEquals() throws { + // %ZZ is invalid percent-encoding → removingPercentEncoding returns nil. + // The fallback should still preserve +-to-space and = in values. + let body = try XCTUnwrap(Body( + data: "key=a%ZZ=b&greeting=hello+world".data(using: .utf8)!, + contentType: "application/x-www-form-urlencoded; charset=utf-8" + )) + + let dict = try XCTUnwrap(body.serialize()["body"] as? [String: Any]) + // Value preserves the joined "=" and the +-to-space conversion on the valid pair + XCTAssertEqual(dict["key"] as? String, "a%ZZ=b") + XCTAssertEqual(dict["greeting"] as? String, "hello world") + } + + // MARK: - Multi-byte Truncation + + func testInit_withTruncatedMultiByteUTF8_shouldRecoverValidPrefix() throws { + // UTF-8 byte widths: + // 3-byte chars: CJK (e.g. "你" = E4 BD A0) + // 4-byte chars: emoji (e.g. "😀" = F0 9F 98 80) + // 2-byte chars: accented Latin (e.g. "é" = C3 A9) + + // -- dropLast(1): 3-byte char split after 2 bytes -- + // "你好" = 6 bytes; prefix(5) cuts second char after 2 of 3 bytes + let cjk = "你好".data(using: .utf8)! + XCTAssertEqual(cjk.count, 6) + let body1 = try XCTUnwrap(Body(data: cjk.prefix(5), contentType: "text/plain; charset=utf-8")) + XCTAssertEqual(body1.serialize()["body"] as? String, "你") + + // -- dropLast(2): 3-byte char split after 1 byte -- + // prefix(4) cuts second char after 1 of 3 bytes + let body2 = try XCTUnwrap(Body(data: cjk.prefix(4), contentType: "text/plain; charset=utf-8")) + XCTAssertEqual(body2.serialize()["body"] as? String, "你") + + // -- dropLast(3): 4-byte emoji split after 1 byte -- + // "A😀" = 1 + 4 = 5 bytes; prefix(2) cuts emoji after 1 of 4 bytes + let emoji = "A😀".data(using: .utf8)! + XCTAssertEqual(emoji.count, 5) + let body3 = try XCTUnwrap(Body(data: emoji.prefix(2), contentType: "text/plain; charset=utf-8")) + XCTAssertEqual(body3.serialize()["body"] as? String, "A") + + // -- no truncation needed: clean boundary -- + // prefix(3) is exactly "你", no bytes to drop + let body4 = try XCTUnwrap(Body(data: cjk.prefix(3), contentType: "text/plain; charset=utf-8")) + XCTAssertEqual(body4.serialize()["body"] as? String, "你") + + // -- pure ASCII: never affected -- + let ascii = "hello".data(using: .utf8)! + let body5 = try XCTUnwrap(Body(data: ascii.prefix(3), contentType: "text/plain; charset=utf-8")) + XCTAssertEqual(body5.serialize()["body"] as? String, "hel") + + // -- 2-byte char split after 1 byte -- + // "Aé" = 1 + 2 = 3 bytes; prefix(2) cuts "é" after 1 of 2 bytes + let accented = "Aé".data(using: .utf8)! + XCTAssertEqual(accented.count, 3) + let body6 = try XCTUnwrap(Body(data: accented.prefix(2), contentType: "text/plain; charset=utf-8")) + XCTAssertEqual(body6.serialize()["body"] as? String, "A") + } + + // MARK: - Serialization Tests + + func testSerialize_withStringBody_shouldReturnDictionary() { + // -- Arrange -- + let bodyContent = "test body" + guard let bodyData = bodyContent.data(using: .utf8) else { + return XCTFail("Failed to create data") + } + let body = Body(data: bodyData, contentType: "text/plain") + + // -- Act -- + let result = body?.serialize() + + // -- Assert -- + XCTAssertNotNil(result) + XCTAssertEqual(result?["body"] as? String, bodyContent) + } + + func testSerialize_withJSONDictionary_shouldReturnDictionary() { + // -- Arrange -- + let bodyContent: [String: Any] = ["user": "test", "id": 123] + let bodyData: Data + do { + bodyData = try JSONSerialization.data(withJSONObject: bodyContent) + } catch { + return XCTFail("Failed to create JSON data: \(error)") + } + let body = Body(data: bodyData, contentType: "application/json") + + // -- Act -- + let result = body?.serialize() + + // -- Assert -- + XCTAssertNotNil(result) + guard let bodyDict = result?["body"] as? NSDictionary else { + return XCTFail("Expected body to be NSDictionary") + } + XCTAssertEqual(bodyDict["user"] as? String, "test") + XCTAssertEqual(bodyDict["id"] as? Int, 123) + } + + func testSerialize_withJSONArray_shouldReturnArray() { + // -- Arrange -- + let bodyContent = ["item1", "item2", "item3"] + let bodyData: Data + do { + bodyData = try JSONSerialization.data(withJSONObject: bodyContent) + } catch { + return XCTFail("Failed to create JSON data: \(error)") + } + let body = Body(data: bodyData, contentType: "application/json") + + // -- Act -- + let result = body?.serialize() + + // -- Assert -- + XCTAssertNotNil(result) + guard let bodyArray = result?["body"] as? NSArray else { + return XCTFail("Expected body to be NSArray") + } + XCTAssertEqual(bodyArray.count, 3) + XCTAssertEqual(bodyArray[0] as? String, "item1") + } + + func testSerialize_withNoContentType_shouldCreatePlaceholder() { + // -- Arrange -- + let bodyContent = "some content" + guard let bodyData = bodyContent.data(using: .utf8) else { + return XCTFail("Failed to create data") + } + let body = Body(data: bodyData, contentType: nil) + + // -- Act -- + let result = body?.serialize() + + // -- Assert -- + XCTAssertNotNil(result) + let bodyString = result?["body"] as? String + XCTAssertTrue(bodyString?.hasPrefix("[Body not captured") == true) + } + + // MARK: - parseMimeAndEncoding + + func testParseMimeAndEncoding_shouldHandleEdgeCases() { + // nil → nil mime, UTF-8 default + let (nilMime, nilEnc) = Body.parseMimeAndEncoding(from: nil) + XCTAssertNil(nilMime) + XCTAssertEqual(nilEnc, .utf8) + + // No charset → mime only, UTF-8 default + let (jsonMime, jsonEnc) = Body.parseMimeAndEncoding(from: "application/json") + XCTAssertEqual(jsonMime, "application/json") + XCTAssertEqual(jsonEnc, .utf8) + + // Standard format with spaces + let (stdMime, stdEnc) = Body.parseMimeAndEncoding(from: "text/html; charset=utf-8") + XCTAssertEqual(stdMime, "text/html") + XCTAssertEqual(stdEnc, .utf8) + + // No spaces around semicolon + let (noSpMime, noSpEnc) = Body.parseMimeAndEncoding(from: "text/html;charset=UTF-8") + XCTAssertEqual(noSpMime, "text/html") + XCTAssertEqual(noSpEnc, .utf8) + + // Quoted charset value + let (qMime, qEnc) = Body.parseMimeAndEncoding(from: "text/html; charset=\"utf-8\"") + XCTAssertEqual(qMime, "text/html") + XCTAssertEqual(qEnc, .utf8) + + // ISO-8859-1 charset + let (isoMime, isoEnc) = Body.parseMimeAndEncoding(from: "text/html; charset=iso-8859-1") + XCTAssertEqual(isoMime, "text/html") + XCTAssertEqual(isoEnc, .isoLatin1) + + // Non-charset parameter only → UTF-8 default + let (mpMime, mpEnc) = Body.parseMimeAndEncoding(from: "multipart/form-data; boundary=----WebKitFormBoundary") + XCTAssertEqual(mpMime, "multipart/form-data") + XCTAssertEqual(mpEnc, .utf8) + + // Multiple params — charset extracted correctly + let (multiMime, multiEnc) = Body.parseMimeAndEncoding(from: "application/json; charset=utf-8; boundary=something") + XCTAssertEqual(multiMime, "application/json") + XCTAssertEqual(multiEnc, .utf8) + + // Completely malformed → returned as-is, UTF-8 default + let (badMime, badEnc) = Body.parseMimeAndEncoding(from: "totally-not-a-content-type") + XCTAssertEqual(badMime, "totally-not-a-content-type") + XCTAssertEqual(badEnc, .utf8) + + // Unrecognized charset name → UTF-8 fallback + let (unkMime, unkEnc) = Body.parseMimeAndEncoding(from: "text/html; charset=made-up-encoding") + XCTAssertEqual(unkMime, "text/html") + XCTAssertEqual(unkEnc, .utf8) + + // Empty string → nil mime, UTF-8 default + let (emptyMime, emptyEnc) = Body.parseMimeAndEncoding(from: "") + XCTAssertNil(emptyMime) + XCTAssertEqual(emptyEnc, .utf8) + } +} diff --git a/Tests/SentryTests/Networking/SentryReplayNetworkDetailsHeaderTests.swift b/Tests/SentryTests/Networking/SentryReplayNetworkDetailsHeaderTests.swift new file mode 100644 index 0000000000..089f322ef6 --- /dev/null +++ b/Tests/SentryTests/Networking/SentryReplayNetworkDetailsHeaderTests.swift @@ -0,0 +1,96 @@ +@_spi(Private) @testable import Sentry +import XCTest + +class SentryReplayNetworkDetailsHeaderTests: XCTestCase { + + // MARK: - Header Extraction Tests + + func testExtractHeaders_caseInsensitiveMatching() { + // -- Arrange -- + let sourceHeaders: [String: Any] = [ + "Content-Type": "application/json", + "AUTHORIZATION": "Bearer token", + "x-request-id": "123" + ] + let configuredHeaders = ["content-type", "Authorization", "X-Request-ID"] + + // -- Act -- + let extracted = SentryReplayNetworkDetails.extractHeaders( + from: sourceHeaders, + matching: configuredHeaders + ) + + // -- Assert -- + XCTAssertEqual(extracted.count, 3) + // Should preserve original casing from source + XCTAssertEqual(extracted["Content-Type"], "application/json") + XCTAssertEqual(extracted["AUTHORIZATION"], "Bearer token") + XCTAssertEqual(extracted["x-request-id"], "123") + } + + func testExtractHeaders_withNilInputs_returnsEmptyDict() { + // Test nil source headers + XCTAssertEqual( + SentryReplayNetworkDetails.extractHeaders(from: nil, matching: ["test"]), + [:] + ) + + // Test nil configured headers + XCTAssertEqual( + SentryReplayNetworkDetails.extractHeaders(from: ["test": "value"], matching: nil), + [:] + ) + + // Test both nil + XCTAssertEqual( + SentryReplayNetworkDetails.extractHeaders(from: nil, matching: nil), + [:] + ) + } + + func testExtractHeaders_nonStringValues_convertedToStrings() { + // -- Arrange -- + let sourceHeaders: [String: Any] = [ + "Content-Length": NSNumber(value: 9_876), + "Retry-After": 60, + "X-Bool": true, + "X-Double": 3.14159 + ] + let configuredHeaders = ["Content-Length", "Retry-After", "X-Bool", "X-Double"] + + // -- Act -- + let extracted = SentryReplayNetworkDetails.extractHeaders( + from: sourceHeaders, + matching: configuredHeaders + ) + + // -- Assert -- + XCTAssertEqual(extracted.count, 4) + XCTAssertEqual(extracted["Content-Length"], "9876") + XCTAssertEqual(extracted["Retry-After"], "60") + XCTAssertEqual(extracted["X-Bool"], "true") + XCTAssertEqual(extracted["X-Double"], "3.14159") + } + + func testExtractHeaders_unconfiguredHeadersAreExcluded() { + // -- Arrange -- + let sourceHeaders: [String: Any] = [ + "Content-Type": "application/json", + "Authorization": "Bearer token", + "X-Custom": "should not appear" + ] + let configuredHeaders = ["Content-Type", "Authorization"] + + // -- Act -- + let extracted = SentryReplayNetworkDetails.extractHeaders( + from: sourceHeaders, + matching: configuredHeaders + ) + + // -- Assert -- + XCTAssertEqual(extracted.count, 2) + XCTAssertEqual(extracted["Content-Type"], "application/json") + XCTAssertEqual(extracted["Authorization"], "Bearer token") + XCTAssertNil(extracted["X-Custom"]) + } +} diff --git a/Tests/SentryTests/Networking/SentryReplayNetworkDetailsIntegrationTests.swift b/Tests/SentryTests/Networking/SentryReplayNetworkDetailsIntegrationTests.swift new file mode 100644 index 0000000000..110a0b7465 --- /dev/null +++ b/Tests/SentryTests/Networking/SentryReplayNetworkDetailsIntegrationTests.swift @@ -0,0 +1,156 @@ +@_spi(Private) @testable import Sentry +import XCTest + +class SentryReplayNetworkDetailsIntegrationTests: XCTestCase { + + private typealias Body = SentryReplayNetworkDetails.Body + + // MARK: - Initialization Tests + + func testInit_withMethod_shouldSetMethod() { + // -- Arrange & Act -- + let details = SentryReplayNetworkDetails(method: "POST") + + // -- Assert -- + XCTAssertEqual(details.method, "POST") + XCTAssertNil(details.statusCode) + XCTAssertNil(details.requestBodySize) + XCTAssertNil(details.responseBodySize) + } + + // MARK: - Serialization Tests + + func testSerialize_withFullData_shouldReturnCompleteDictionary() { + // -- Arrange -- + let details = SentryReplayNetworkDetails(method: "PUT") + + details.setRequest( + size: 100, + body: ["name": "test"], + allHeaders: ["Content-Type": "application/json", "Authorization": "Bearer token", "Accept": "*/*"], + configuredHeaders: ["Content-Type", "Authorization"] + ) + details.setResponse( + statusCode: 201, + size: 150, + body: ["id": 123, "name": "test"], + allHeaders: ["Content-Type": "application/json", "Cache-Control": "no-cache", "Set-Cookie": "session=123"], + configuredHeaders: ["Content-Type", "Cache-Control"] + ) + + // -- Act -- + let result = details.serialize() + + // -- Assert -- + let expectedJSON = """ + { + "method": "PUT", + "statusCode": 201, + "requestBodySize": 100, + "responseBodySize": 150, + "request": { + "size": 100, + "headers": { + "Authorization": "Bearer token", + "Content-Type": "application/json" + }, + "body": { + "body": { + "name": "test" + } + } + }, + "response": { + "size": 150, + "headers": { + "Cache-Control": "no-cache", + "Content-Type": "application/json" + }, + "body": { + "body": { + "id": 123, + "name": "test" + } + } + } + } + """ + + assertJSONEqual(result, expectedJSON: expectedJSON) + } + + func testSerialize_withPartialData_shouldOnlyIncludeSetFields() { + // -- Arrange -- + let details = SentryReplayNetworkDetails(method: "GET") + details.setResponse( + statusCode: 404, + size: nil, + body: nil, + allHeaders: ["Cache-Control": "no-cache", "Content-Type": "text/plain", "X-Custom": "value"], + configuredHeaders: ["Cache-Control", "Content-Type"] + ) + + // -- Act -- + let result = details.serialize() + + // -- Assert -- + let expectedJSON = """ + { + "method": "GET", + "statusCode": 404, + "response": { + "headers": { + "Cache-Control": "no-cache", + "Content-Type": "text/plain" + } + } + } + """ + + assertJSONEqual(result, expectedJSON: expectedJSON) + } + + func testSerialize_withHeaderFiltering_shouldOnlyIncludeConfiguredHeaders() { + // -- Arrange -- + let details = SentryReplayNetworkDetails(method: "GET") + details.setRequest( + size: nil, + body: nil, + allHeaders: [ + "Content-Type": "application/json", + "Authorization": "Bearer secret", + "X-Internal": "hidden", + "Cookie": "session=abc" + ], + configuredHeaders: ["Content-Type"] + ) + + // -- Act -- + let result = details.serialize() + + // -- Assert -- + guard let request = result["request"] as? [String: Any], + let headers = request["headers"] as? [String: String] else { + return XCTFail("Expected request with headers") + } + XCTAssertEqual(headers.count, 1) + XCTAssertEqual(headers["Content-Type"], "application/json") + XCTAssertNil(headers["Authorization"]) + } + + // MARK: - Test Helpers + + private func assertJSONEqual(_ result: [String: Any], expectedJSON: String) { + guard let expectedData = expectedJSON.data(using: .utf8) else { + return XCTFail("Failed to convert expected JSON string to data") + } + + do { + let expectedDict = try JSONSerialization.jsonObject(with: expectedData, options: []) as? NSDictionary + let actualDict = result as NSDictionary + XCTAssertEqual(actualDict, expectedDict) + } catch { + XCTFail("Failed to parse expected JSON: \(error)") + } + } +}