Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions tests/business-critical-integration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,32 @@ To see everything that's happening:
| `./scripts/run-tests.sh push --verbose` | Test push with details |
| `open reports/` | View test reports in Finder |

## 🔐 JWT Auth Retry Screen — Response Modes

The **JWT Auth Retry** screen (inside the integration tester app) exercises the SDK's unified JWT retry logic. Its response-mode radios determine where traffic actually goes — matching the Android network-tester's behavior:

| Mode | Destination | Notes |
|------------|-------------------------------------------|--------------------------------------------------------------------------------|
| `Normal` | Real `https://api.iterable.com` | Proxied with the SDK's real JWT. Use this to validate retry against prod-shape responses. |
| `401` | Real `https://api.iterable.com` | Proxied with the `Authorization` header swapped to an **expired** JWT. Real backend returns a real `401 InvalidJwtPayload`. |
| `500` | Local synthesis inside `MockAPIServer` | No real traffic — you can't force prod to 500. |
| `Conn Err` | Local synthesis inside `MockAPIServer` | Emits `NSURLErrorNotConnectedToInternet`. Equivalent to Android's `DEAD_PORT` trick. |

### Configuring a test project

Proxied traffic lands in a real Iterable project. Use a **dedicated test project** to keep prod dashboards clean. The tester reads credentials from `integration-test-app/config/test-config.json`:

```json
{
"jwtApiKey": "<JWT-enabled API key from your test project>",
"jwtSecret": "<HMAC secret from the same test project>",
"baseUrl": "https://api.iterable.com",
"projectId": "<your test project ID>"
}
```

The JWT secret rotates on the Iterable project's settings page; if you regenerate it, update `test-config.json` and rebuild.

## 🎉 What's Next?

Once your local tests are passing:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,10 @@ extension AppDelegate {

let apiKey = loadJWTApiKeyFromConfig() ?? loadApiKeyFromConfig()

// Activate mock server BEFORE init to intercept GET requests (getInAppMessages,
// embedded-messaging) so they don't hit the real API with test emails.
// Activate mock server BEFORE init so requests route through it. Depending
// on `apiResponseMode` the protocol will either proxy to the real Iterable
// API (.normal / .jwt401) or synthesize locally (.server500 / .connectionError).
MockAPIServer.shared.jwtSecret = jwtSecret
MockAPIServer.shared.activate()

IterableAPI.initialize(apiKey: apiKey, launchOptions: nil, config: config)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ final class MockAPIServer {
static let shared = MockAPIServer()

// MARK: - API Response Mode (controlled by JWT Auth Retry panel)
//
// Matches Android MockServer.ResponseMode + the UI-only CONN_ERROR:
// - .normal → proxy to real Iterable API (see MockAPIServerURLProtocol)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is no longer true.

MockAPIServerURLProtocol.canInit returns false for .normal. It opts out, no proxy involved.

The MockAPIServerURLProtocol header comment (lines 4-5) correctly says "NOT intercepted."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 9bce3f09. The APIResponseMode docblock now matches the (correct) header comment in MockAPIServerURLProtocol.swift.

// - .jwt401 → proxy to real Iterable API with Authorization swapped
// to an expired JWT; real backend returns real 401
// - .server500 → local synthesized 500 (can't force 500 from prod)
// - .connectionError → local synthesized connection error (iOS equivalent of
// Android's DEAD_PORT trick)

enum APIResponseMode: String, CaseIterable {
case normal = "Normal"
Expand All @@ -14,6 +22,10 @@ final class MockAPIServer {

var apiResponseMode: APIResponseMode = .normal

/// JWT signing secret used by MockAPIServerURLProtocol when forging the
/// expired token for `.jwt401`. Populated by AppDelegate during SDK re-init.
var jwtSecret: String?

// MARK: - State

private(set) var isActive: Bool = false
Expand Down Expand Up @@ -79,80 +91,30 @@ final class MockAPIServer {
return true
}

/// Returns a mock response for the given request, or nil to pass through.
/// Matches Android behavior: same response mode applies to ALL requests
/// (GET and POST) uniformly. Only getRemoteConfiguration is exempt.
/// Returns a locally-synthesized mock response for `.server500` / `.connectionError`.
/// For `.normal` / `.jwt401` the request is proxied to the real Iterable API by
/// MockAPIServerURLProtocol; this method returns nil in those cases.
func mockResponse(for request: URLRequest) -> MockResponse? {
guard isActive, let url = request.url else { return nil }

let path = url.path

// getRemoteConfiguration handled by ConfigOverrideURLProtocol
if path.contains("getRemoteConfiguration") {
return nil
}

// Non-Iterable requests pass through
if url.path.contains("getRemoteConfiguration") { return nil }
guard url.host?.contains("iterable.com") == true else { return nil }

requestCount += 1

let endpoint = path.split(separator: "/").last.map(String.init) ?? path
let endpoint = url.path.split(separator: "/").last.map(String.init) ?? url.path

// Same response mode for GET and POST — matches Android MockServer.serve()
switch apiResponseMode {
case .normal:
return successResponse(for: path)

case .jwt401:
return jwt401Response(endpoint: endpoint)

case .normal, .jwt401:
return nil // handled by proxy
case .server500:
return server500Response(endpoint: endpoint)

case .connectionError:
return connectionErrorResponse()
}
}

// MARK: - Response Helpers (matching Android response bodies)

private func successResponse(for path: String) -> MockResponse {
// Return endpoint-specific valid JSON for Decodable types,
// generic success for everything else (matching Android).
let json: [String: Any]
if path.contains("embedded-messaging/messages") {
json = ["placements": []]
} else if path.contains("getMessages") {
json = ["inAppMessages": []]
} else {
json = ["msg": "Success", "code": "Success", "successCount": 1]
}
let data = (try? JSONSerialization.data(withJSONObject: json)) ?? Data()
let endpoint = path.split(separator: "/").last.map(String.init) ?? path
print("[MOCK SERVER] 200 \(endpoint)")
LogStore.shared.log("📤 \(endpoint) → 200 ✅")
return MockResponse(
statusCode: 200,
data: data,
headers: ["Content-Type": "application/json", "Connection": "close"]
)
}

private func jwt401Response(endpoint: String) -> MockResponse {
let json: [String: Any] = [
"code": "InvalidJwtPayload",
"msg": "JWT token is expired"
]
let data = (try? JSONSerialization.data(withJSONObject: json)) ?? Data()
print("[MOCK SERVER] 401 \(endpoint)")
LogStore.shared.log("📤 \(endpoint) → 401 ❌")
return MockResponse(
statusCode: 401,
data: data,
headers: ["Content-Type": "application/json", "Connection": "close"]
)
}
// MARK: - Response Helpers

private func server500Response(endpoint: String) -> MockResponse {
let json: [String: Any] = [
Expand All @@ -161,7 +123,7 @@ final class MockAPIServer {
]
let data = (try? JSONSerialization.data(withJSONObject: json)) ?? Data()
print("[MOCK SERVER] 500 \(endpoint)")
LogStore.shared.log("📤 \(endpoint) → 500 ❌")
LogStore.shared.log("📤 \(endpoint) → 500 ❌ (mocked)")
return MockResponse(
statusCode: 500,
data: data,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,28 @@
import Foundation
import IterableSDK

/// URLProtocol that intercepts Iterable API requests and returns
/// mock responses from MockAPIServer.
/// URLProtocol that routes Iterable API requests per MockAPIServer.apiResponseMode:
/// - .normal → proxy to real Iterable API with the SDK's JWT
/// - .jwt401 → proxy to real Iterable API, but with Authorization swapped
/// to an expired JWT (real backend returns a real 401
/// InvalidJwtPayload)
/// - .server500 → local synthesized 500
/// - .connectionError → local synthesized NSURLErrorNotConnectedToInternet
///
/// Registered globally via URLProtocol.registerClass() and also
/// injected into URLSessionConfiguration via swizzling, so it
/// intercepts requests from ALL URLSessions including the SDK's
/// offline task queue.
/// Registered globally via URLProtocol.registerClass() and injected into
/// URLSessionConfiguration via swizzling, so it intercepts requests from every
/// URLSession — including the SDK's offline task queue.
final class MockAPIServerURLProtocol: URLProtocol {

private static let handledKey = "MockAPIServerURLProtocol.handled"

private var forwardTask: URLSessionDataTask?

override class func canInit(with request: URLRequest) -> Bool {
// Only intercept when mock server is active
guard MockAPIServer.shared.isActive else { return false }

// Don't handle if already handled (prevent double-handling)
guard URLProtocol.property(forKey: handledKey, in: request) == nil else { return false }

// Only intercept Iterable API requests
guard let host = request.url?.host, host.contains("iterable.com") else { return false }

// Only intercept if mock server wants to handle this request
guard MockAPIServer.shared.shouldIntercept(request: request) else { return false }

return true
}

Expand All @@ -32,39 +31,102 @@ final class MockAPIServerURLProtocol: URLProtocol {
}

override func startLoading() {
// Mark as handled
let mutableRequest = (request as NSURLRequest).mutableCopy() as! NSMutableURLRequest
URLProtocol.setProperty(true, forKey: MockAPIServerURLProtocol.handledKey, in: mutableRequest)
switch MockAPIServer.shared.apiResponseMode {
case .server500, .connectionError:
serveLocal()
case .normal:
proxyToRealBackend(swapExpiredJwt: false)
case .jwt401:
proxyToRealBackend(swapExpiredJwt: true)
}
}

override func stopLoading() {
forwardTask?.cancel()
forwardTask = nil
}

// MARK: - Local synthesis (500 / connection error only)

private func serveLocal() {
guard let mockResponse = MockAPIServer.shared.mockResponse(for: request),
let url = request.url else {
client?.urlProtocol(self, didFailWithError: URLError(.unknown))
return
}

// Connection error — deliver NSError directly
if let error = mockResponse.error {
client?.urlProtocol(self, didFailWithError: error)
return
}

// Create HTTP response
let httpResponse = HTTPURLResponse(
if let response = HTTPURLResponse(
url: url,
statusCode: mockResponse.statusCode,
httpVersion: "HTTP/1.1",
headerFields: mockResponse.headers
)

if let response = httpResponse {
) {
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
}

client?.urlProtocol(self, didLoad: mockResponse.data)
client?.urlProtocolDidFinishLoading(self)
}

override func stopLoading() {
// Responses are synchronous, nothing to cancel
// MARK: - Proxy to real backend (mirrors Android MockServer.proxy())

private func proxyToRealBackend(swapExpiredJwt: Bool) {
guard let mutableRequest = (request as NSURLRequest).mutableCopy() as? NSMutableURLRequest else {
client?.urlProtocol(self, didFailWithError: URLError(.unknown))
return
}

// Mark handled so our canInit skips the re-issued request (avoids recursion).
URLProtocol.setProperty(true, forKey: MockAPIServerURLProtocol.handledKey, in: mutableRequest)

if swapExpiredJwt {
// Match MockAuthDelegate's email source so the forged expired JWT
// identifies the same user the SDK signed for — otherwise Iterable
// can return `jwtUserIdentifiersMismatched` instead of
// `InvalidJwtPayload`, exercising the wrong retry branch.
let email = IterableAPI.email ?? AppDelegate.currentTestEmail ?? ""
let secret = MockAPIServer.shared.jwtSecret ?? ""
let expired = JwtHelper.generateToken(email: email, secret: secret, expired: true) ?? "expired"
mutableRequest.setValue("Bearer \(expired)", forHTTPHeaderField: "Authorization")
}

let endpoint = request.url?.path.split(separator: "/").last.map(String.init)
?? request.url?.path
?? "?"

let task = URLSession.shared.dataTask(with: mutableRequest as URLRequest) { [weak self] data, response, error in
guard let self = self else { return }

if let error = error {
// stopLoading() cancels the forwarded task; URLProtocol contract
// forbids further client callbacks after that, so swallow the
// cancellation instead of surfacing a false-negative failure.
if (error as NSError).code == NSURLErrorCancelled { return }

LogStore.shared.log("📤 \(endpoint) → error: \(error.localizedDescription)")
self.client?.urlProtocol(self, didFailWithError: error)
return
}

if let httpResponse = response as? HTTPURLResponse {
let emoji = (200..<300).contains(httpResponse.statusCode) ? "✅" : "❌"
let tag = swapExpiredJwt ? " (expired JWT)" : ""
LogStore.shared.log("📤 \(endpoint) → \(httpResponse.statusCode) \(emoji)\(tag)")
self.client?.urlProtocol(self, didReceive: httpResponse, cacheStoragePolicy: .notAllowed)
}

if let data = data {
self.client?.urlProtocol(self, didLoad: data)
}

self.client?.urlProtocolDidFinishLoading(self)
}
self.forwardTask = task
task.resume()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ final class OfflineRetryTestViewController: UIViewController {
private var radioButtons: [UIButton] = []
private var selectedResponseMode: MockAPIServer.APIResponseMode = .normal

// Hint beside the radios: "→ real backend" vs "→ local mock"
private let responseDestinationLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 10, weight: .regular)
label.textColor = .systemGray
label.text = "→ real backend"
return label
}()

// Auth status
private let authStatusLabel: UILabel = {
let label = UILabel()
Expand Down Expand Up @@ -176,6 +185,7 @@ final class OfflineRetryTestViewController: UIViewController {
radioButtons.append(button)
radioRow.addArrangedSubview(button)
}
radioRow.addArrangedSubview(responseDestinationLabel)
// Select first radio
radioButtons.first?.isSelected = true
updateRadioAppearance()
Expand Down Expand Up @@ -573,7 +583,16 @@ final class OfflineRetryTestViewController: UIViewController {
let mode = modes[sender.tag]
selectedResponseMode = mode
MockAPIServer.shared.apiResponseMode = mode
log("Response: \(mode.rawValue)")

let destination: String
switch mode {
case .normal: destination = "→ real backend"
case .jwt401: destination = "→ real backend (expired JWT)"
case .server500: destination = "→ local mock"
case .connectionError: destination = "→ local mock"
}
responseDestinationLabel.text = destination
log("Response: \(mode.rawValue) \(destination)")
}

@objc private func showDatabase() {
Expand Down
Loading