Test gzip response integrity and decoded-size limits in iOS

Test gzip response integrity and decoded-size limits in iOS

When an iOS app receives compressed JSON, a packet capture may show only a few dozen KB, while the content passed to the parser is much larger. You do not need to wait for a production API to fail to find out what the app actually accepts. Start a test server that listens only on a cloud Mac’s loopback interface, then request it from an iOS simulator running on the same Mac. The three responses below—a valid body, a damaged checksum, and a body that expands substantially—let you inspect the data URLSession returns and the limit your app applies before parsing.

Distinguish transfer size from app data

gzip compresses the response body in transit. Content-Length can describe the number of compressed bytes sent, while the Data received by application code may contain the decompressed body. A limit on transfer size alone will not stop repeated characters or highly similar JSON fragments from consuming substantial memory after decompression. This example sets 256 KiB as the post-decompression limit for the sample API. It is a test threshold, not a universal value for iOS APIs; choose a limit based on your actual response sizes.

The tests must also distinguish “the network request succeeded” from “the body is usable.” A correct status code and a request that did not time out are no substitute for validating the content. In particular, do not assume a damaged compressed stream will always produce one specific error. The behavior to verify is that the app never accepts damaged data as a complete application-level result.

Assert against the data the app uses, not just a transfer header or the compressed size shown in a network inspector.

Generate three gzip responses locally

Save the following script as /tmp/gzip_fixture.py and run python3 /tmp/gzip_fixture.py on the cloud Mac. The server binds only to 127.0.0.1:8765 and is not exposed to the public internet. /ok returns a short body; /broken changes the checksum in the gzip trailer; /large sends a highly compressible body that expands to 1 MiB. All three paths send actual compressed data and set the response header to the compressed length.

import gzip
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

SMALL = b'{"status":"ready"}'
LARGE = b"A" * 1_048_576

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path not in ("/ok", "/broken", "/large"):
            self.send_error(404)
            return

        payload = LARGE if self.path == "/large" else SMALL
        wire = bytearray(gzip.compress(payload))
        if self.path == "/broken":
            wire[-8] ^= 0xFF

        self.send_response(200)
        self.send_header("Content-Type", "application/octet-stream")
        self.send_header("Content-Encoding", "gzip")
        self.send_header("Content-Length", str(len(wire)))
        self.end_headers()
        self.wfile.write(wire)

ThreadingHTTPServer(("127.0.0.1", 8765), Handler).serve_forever()

The script deliberately changes the checksum instead of arbitrarily cutting off the TCP connection, so you can observe “complete transfer, damaged content” separately from a connection interruption. These are fixed inputs, not claims about the compression ratio or throughput of a real API. Before testing, run lsof -nP -iTCP:8765 -sTCP:LISTEN to confirm that the listener is the process you just started. If the port is occupied, stop the old service first so the tests do not connect to another program by mistake.

Inspect the body in the simulator

Add the following XCTest example to an existing test target in your project. It applies only to a simulator running on the same Mac as the server: 127.0.0.1 points to that machine here. A physical device requires a different server address and access-control design. The test project must permit local HTTP requests in this development environment; do not weaken the production build’s security settings for remote requests just to run these tests.

import Foundation
import XCTest

final class GzipResponseTests: XCTestCase {
    private let base = URL(string: "http://127.0.0.1:8765")!

    private func fetch(_ path: String) async throws -> Data {
        let url = base.appendingPathComponent(path)
        let (data, response) = try await URLSession.shared.data(from: url)
        XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
        return data
    }

    func testValidBody() async throws {
        let data = try await fetch("ok")
        XCTAssertEqual(data, Data(#"{"status":"ready"}"#.utf8))
    }

    func testLargeDecodedBodyExceedsAppLimit() async throws {
        let data = try await fetch("large")
        XCTAssertGreaterThan(data.count, 256 * 1024)
    }

    func testDamagedBodyIsNotAcceptedAsComplete() async {
        do {
            let data = try await fetch("broken")
            XCTAssertNotEqual(
                data,
                Data(#"{"status":"ready"}"#.utf8)
            )
        } catch {
            return
        }
    }
}

Run the valid case first, followed by the large-body and damaged-body cases. If the valid case receives raw gzip bytes rather than the expected text, check the response headers, test environment, and request implementation before changing the assertion to “any data passes.” The damaged-body case accepts either of two observable outcomes: the call throws, or the returned data differs from the complete expected body. It does not guarantee that the system rejects a checksum error at a particular stage, and it does not replace application-level validation of JSON fields.

Enforce the size limit before parsing

In the app, check Data.count before passing the body to a decoder. The following gate runs after the full response has arrived. It prevents further parsing of an oversized body, but cannot prevent memory already used during download and decompression. If an API might return extremely large content, the app architecture also needs chunked reception, temporary disk storage, or a server-side limit; this one check is not enough.

enum ResponseError: Error {
    case tooLarge
}

func checkedBody(_ data: Data) throws -> Data {
    guard data.count <= 256 * 1024 else {
        throw ResponseError.tooLarge
    }
    return data
}

Extend the large-body test to assert that checkedBody(data) throws tooLarge; the valid body should pass. That moves the test from observing a difference to verifying that the app enforces its boundary. If your API returns JSON, decode it after the size check and verify required fields. Do not mistake “it parses” for “it came from the expected API.”

Leave a reproducible review checklist

Before committing these tests, check five things: the server listens only on the local machine; the tests explicitly use a simulator on that same Mac; the valid response is compared against the entire body; the damaged response cannot be accepted as complete content; and the size limit applies to the Data URLSession gives the app. Finally, stop the temporary server, confirm that port 8765 is no longer listening, and check that the test target has not carried a local HTTP exception into the production build.

This fixture is intentionally small. To cover empty bodies, error status codes, or different encodings later, add separate paths and assertions rather than packing several failure modes into one response that changes unpredictably. Fixed inputs, explicit boundaries, and checks against the bytes the app actually uses are much more reliable than judging safety from compressed length in a packet capture alone.

Frequently asked questions

Can Content-Length enforce the response size limit?

No. It describes the compressed bytes on the wire. Apply the app's limit to the Data returned after decoding.

Must a corrupted gzip checksum make URLSession throw?

Do not tie the test to one failure mode. Require that the app never accepts the damaged response as the complete expected body; an error or failed content check both qualify.

Ready to put it to the test?

Rent a dedicated Mac mini and run your build

Choose from two M4 dedicated Mac models, then select a rental term and location to suit your needs. Check real-time availability in the console.

Rent a Mac mini now