From 210d43e8a8f35d3057c5ffe078b6ddab76992242 Mon Sep 17 00:00:00 2001 From: Graham Christensen Date: Wed, 7 Jan 2026 17:18:24 -0500 Subject: [PATCH 1/6] checkpoint: check Forbidden separately --- .claude/settings.local.json | 7 +++++++ src/libstore/http-binary-cache-store.cc | 12 +++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000000..4a1edf13a3d --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(grep:*)" + ] + } +} diff --git a/src/libstore/http-binary-cache-store.cc b/src/libstore/http-binary-cache-store.cc index d4361264edf..f29348192b5 100644 --- a/src/libstore/http-binary-cache-store.cc +++ b/src/libstore/http-binary-cache-store.cc @@ -203,9 +203,19 @@ void HttpBinaryCacheStore::getFile(const std::string & path, Sink & sink) try { getFileTransfer()->download(std::move(request), sink); } catch (FileTransferError & e) { - if (e.error == FileTransfer::NotFound || e.error == FileTransfer::Forbidden) + if (e.error == FileTransfer::NotFound) throw NoSuchBinaryCacheFile( "file '%s' does not exist in binary cache '%s'", path, config->getHumanReadableURI()); + if (e.error == FileTransfer::Forbidden) + /* Rethrow with more context, preserving the original error message which includes the HTTP status code */ + throw FileTransferError( + e.error, + e.response, + "%s\nUnable to access file '%s' in binary cache '%s'.\n" + "If the binary cache requires authentication, check that your access token is valid and not expired.", + e.what(), + path, + config->getHumanReadableURI()); maybeDisable(); throw; } From 3b3256fc92e3a1c24cc59e36245c476ebba7d68d Mon Sep 17 00:00:00 2001 From: Graham Christensen Date: Wed, 7 Jan 2026 17:33:00 -0500 Subject: [PATCH 2/6] Checkpoint: throw a different nosuchbinarycachefile error on unauthorized --- src/libstore/http-binary-cache-store.cc | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/libstore/http-binary-cache-store.cc b/src/libstore/http-binary-cache-store.cc index f29348192b5..e93147be69b 100644 --- a/src/libstore/http-binary-cache-store.cc +++ b/src/libstore/http-binary-cache-store.cc @@ -207,11 +207,8 @@ void HttpBinaryCacheStore::getFile(const std::string & path, Sink & sink) throw NoSuchBinaryCacheFile( "file '%s' does not exist in binary cache '%s'", path, config->getHumanReadableURI()); if (e.error == FileTransfer::Forbidden) - /* Rethrow with more context, preserving the original error message which includes the HTTP status code */ - throw FileTransferError( - e.error, - e.response, - "%s\nUnable to access file '%s' in binary cache '%s'.\n" + throw NoSuchBinaryCacheFile( + "%s\nUnable to access file '%s' in binary cache '%s'. " "If the binary cache requires authentication, check that your access token is valid and not expired.", e.what(), path, From 7f6a9ec99fc583a80911f590f08485b9374c7664 Mon Sep 17 00:00:00 2001 From: Graham Christensen Date: Wed, 7 Jan 2026 17:45:07 -0500 Subject: [PATCH 3/6] checkpoint: a passing test --- tests/functional/binary-cache-auth-errors.sh | 168 +++++++++++++++++++ tests/functional/meson.build | 1 + 2 files changed, 169 insertions(+) create mode 100755 tests/functional/binary-cache-auth-errors.sh diff --git a/tests/functional/binary-cache-auth-errors.sh b/tests/functional/binary-cache-auth-errors.sh new file mode 100755 index 00000000000..08deb48902f --- /dev/null +++ b/tests/functional/binary-cache-auth-errors.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash + +# Test that HTTP binary cache errors are properly distinguished: +# - 404 errors should say "does not exist in binary cache" +# - 401/403 errors should mention authentication and token expiration + +source common.sh + +TODO_NixOS + +needLocalStore "'--no-require-sigs' can't be used with the daemon" + +# Create a binary cache with some content +clearStore +clearCache +outPath=$(nix-build dependencies.nix --no-out-link) +nix copy --to "file://$cacheDir" "$outPath" + +# Start a simple HTTP server that can return various status codes +# We'll use Python's http.server with a custom handler +cat > "$TEST_ROOT/test_server.py" <<'EOF' +#!/usr/bin/env python3 +import http.server +import socketserver +import os +import sys +from urllib.parse import urlparse, parse_qs + +PORT = int(os.environ.get('TEST_SERVER_PORT', 0)) +CACHE_DIR = os.environ.get('CACHE_DIR', '') +RETURN_CODE = int(os.environ.get('RETURN_CODE', 200)) + +class TestHandler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + # Remove leading slash + path = self.path.lstrip('/') + file_path = os.path.join(CACHE_DIR, path) + + # Check if we should return an error for NAR files + # Always serve nix-cache-info and .narinfo files normally + if RETURN_CODE != 200 and (path.startswith('nar/') or path.endswith('.nar') or path.endswith('.nar.xz')): + self.send_response(RETURN_CODE) + if RETURN_CODE == 401: + self.send_header('WWW-Authenticate', 'Bearer realm="test"') + self.send_header('Content-Length', '0') + self.end_headers() + return + + # Otherwise, serve files from the cache directory + if os.path.isfile(file_path): + self.send_response(200) + self.send_header('Content-Type', 'application/octet-stream') + with open(file_path, 'rb') as f: + content = f.read() + self.send_header('Content-Length', str(len(content))) + self.end_headers() + self.wfile.write(content) + else: + self.send_response(404) + self.send_header('Content-Length', '0') + self.end_headers() + + def log_message(self, format, *args): + # Suppress logging + pass + +# Bind to a random port if PORT is 0 +with socketserver.TCPServer(("127.0.0.1", PORT), TestHandler) as httpd: + # Print the actual port we're listening on + print(httpd.server_address[1], flush=True) + httpd.serve_forever() +EOF + +chmod +x "$TEST_ROOT/test_server.py" + +# Start the HTTP server on a random port +CACHE_DIR="$cacheDir" RETURN_CODE=200 python3 "$TEST_ROOT/test_server.py" > "$TEST_ROOT/port" 2>&1 & +SERVER_PID=$! +sleep 1 +HTTP_PORT=$(cat "$TEST_ROOT/port") + +cleanup() { + kill $SERVER_PID 2>/dev/null || true +} +trap cleanup EXIT + +# Test 1: Normal operation (200 OK) should work +clearStore +nix-store --substituters "http://127.0.0.1:$HTTP_PORT" --no-require-sigs -r "$outPath" 2>&1 | tee "$TEST_ROOT/log-200" +[ -x "$outPath/program" ] + +# Stop the server and restart with 404 errors +kill $SERVER_PID +sleep 1 + +# Test 2: 404 errors should say "does not exist in binary cache" +clearStore +CACHE_DIR="$cacheDir" RETURN_CODE=404 python3 "$TEST_ROOT/test_server.py" > "$TEST_ROOT/port" 2>&1 & +SERVER_PID=$! +sleep 1 +HTTP_PORT=$(cat "$TEST_ROOT/port") + +if nix-store --substituters "http://127.0.0.1:$HTTP_PORT" --no-require-sigs -r "$outPath" 2>&1 | tee "$TEST_ROOT/log-404"; then + echo "Expected substitution to fail with 404" + exit 1 +fi + +echo "=== 404 Log contents ===" +cat "$TEST_ROOT/log-404" +echo "=== End 404 Log ===" + +grepQuiet "does not exist in binary cache" "$TEST_ROOT/log-404" + +# Stop the server and restart with 401 errors +kill $SERVER_PID +sleep 1 + +# Test 3: 401 errors should mention authentication and NOT say "does not exist" +clearStore +CACHE_DIR="$cacheDir" RETURN_CODE=401 python3 "$TEST_ROOT/test_server.py" > "$TEST_ROOT/port" 2>&1 & +SERVER_PID=$! +sleep 1 +HTTP_PORT=$(cat "$TEST_ROOT/port") + +if nix-store --substituters "http://127.0.0.1:$HTTP_PORT" --no-require-sigs -r "$outPath" 2>&1 | tee "$TEST_ROOT/log-401"; then + echo "Expected substitution to fail with 401" + exit 1 +fi + +grepQuiet "HTTP error 401" "$TEST_ROOT/log-401" +grepQuiet "access token" "$TEST_ROOT/log-401" + +# Verify it does NOT say "does not exist" for 401 errors +if grep -q "does not exist in binary cache" "$TEST_ROOT/log-401"; then + echo "ERROR: 401 error incorrectly says 'does not exist in binary cache'" + echo "Log contents:" + cat "$TEST_ROOT/log-401" + exit 1 +fi + +# Stop the server and restart with 403 errors +kill $SERVER_PID +sleep 1 + +# Test 4: 403 errors should also mention authentication and NOT say "does not exist" +clearStore +CACHE_DIR="$cacheDir" RETURN_CODE=403 python3 "$TEST_ROOT/test_server.py" > "$TEST_ROOT/port" 2>&1 & +SERVER_PID=$! +sleep 1 +HTTP_PORT=$(cat "$TEST_ROOT/port") + +if nix-store --substituters "http://127.0.0.1:$HTTP_PORT" --no-require-sigs -r "$outPath" 2>&1 | tee "$TEST_ROOT/log-403"; then + echo "Expected substitution to fail with 403" + exit 1 +fi + +grepQuiet "HTTP error 403" "$TEST_ROOT/log-403" +grepQuiet "access token" "$TEST_ROOT/log-403" + +# Verify it does NOT say "does not exist" for 403 errors +if grep -q "does not exist in binary cache" "$TEST_ROOT/log-403"; then + echo "ERROR: 403 error incorrectly says 'does not exist in binary cache'" + echo "Log contents:" + cat "$TEST_ROOT/log-403" + exit 1 +fi + +echo "All HTTP error tests passed!" diff --git a/tests/functional/meson.build b/tests/functional/meson.build index d917d91c3f3..aaa3530c016 100644 --- a/tests/functional/meson.build +++ b/tests/functional/meson.build @@ -65,6 +65,7 @@ suites = [ 'user-envs.sh', 'user-envs-migration.sh', 'binary-cache.sh', + 'binary-cache-auth-errors.sh', 'multiple-outputs.sh', 'nix-build.sh', 'gc-concurrent.sh', From 3e08cc690d315f214ba4215085af0da4d7cf8014 Mon Sep 17 00:00:00 2001 From: Graham Christensen Date: Wed, 7 Jan 2026 19:09:08 -0500 Subject: [PATCH 4/6] checkpoint: it seems to work --- tests/functional/binary-cache-auth-errors.sh | 68 ++----------------- ...nary-cache-auth-errors_test-http-server.py | 63 +++++++++++++++++ 2 files changed, 69 insertions(+), 62 deletions(-) create mode 100755 tests/functional/binary-cache-auth-errors_test-http-server.py diff --git a/tests/functional/binary-cache-auth-errors.sh b/tests/functional/binary-cache-auth-errors.sh index 08deb48902f..d154cf6b4cb 100755 --- a/tests/functional/binary-cache-auth-errors.sh +++ b/tests/functional/binary-cache-auth-errors.sh @@ -16,65 +16,9 @@ clearCache outPath=$(nix-build dependencies.nix --no-out-link) nix copy --to "file://$cacheDir" "$outPath" -# Start a simple HTTP server that can return various status codes -# We'll use Python's http.server with a custom handler -cat > "$TEST_ROOT/test_server.py" <<'EOF' -#!/usr/bin/env python3 -import http.server -import socketserver -import os -import sys -from urllib.parse import urlparse, parse_qs - -PORT = int(os.environ.get('TEST_SERVER_PORT', 0)) -CACHE_DIR = os.environ.get('CACHE_DIR', '') -RETURN_CODE = int(os.environ.get('RETURN_CODE', 200)) - -class TestHandler(http.server.SimpleHTTPRequestHandler): - def do_GET(self): - # Remove leading slash - path = self.path.lstrip('/') - file_path = os.path.join(CACHE_DIR, path) - - # Check if we should return an error for NAR files - # Always serve nix-cache-info and .narinfo files normally - if RETURN_CODE != 200 and (path.startswith('nar/') or path.endswith('.nar') or path.endswith('.nar.xz')): - self.send_response(RETURN_CODE) - if RETURN_CODE == 401: - self.send_header('WWW-Authenticate', 'Bearer realm="test"') - self.send_header('Content-Length', '0') - self.end_headers() - return - - # Otherwise, serve files from the cache directory - if os.path.isfile(file_path): - self.send_response(200) - self.send_header('Content-Type', 'application/octet-stream') - with open(file_path, 'rb') as f: - content = f.read() - self.send_header('Content-Length', str(len(content))) - self.end_headers() - self.wfile.write(content) - else: - self.send_response(404) - self.send_header('Content-Length', '0') - self.end_headers() - - def log_message(self, format, *args): - # Suppress logging - pass - -# Bind to a random port if PORT is 0 -with socketserver.TCPServer(("127.0.0.1", PORT), TestHandler) as httpd: - # Print the actual port we're listening on - print(httpd.server_address[1], flush=True) - httpd.serve_forever() -EOF - -chmod +x "$TEST_ROOT/test_server.py" - -# Start the HTTP server on a random port -CACHE_DIR="$cacheDir" RETURN_CODE=200 python3 "$TEST_ROOT/test_server.py" > "$TEST_ROOT/port" 2>&1 & +# Start the HTTP server on a random port using our test server script +TEST_SERVER="$_NIX_TEST_SOURCE_DIR/binary-cache-auth-errors_test-http-server.py" +CACHE_DIR="$cacheDir" RETURN_CODE=200 python3 "$TEST_SERVER" > "$TEST_ROOT/port" 2>&1 & SERVER_PID=$! sleep 1 HTTP_PORT=$(cat "$TEST_ROOT/port") @@ -95,7 +39,7 @@ sleep 1 # Test 2: 404 errors should say "does not exist in binary cache" clearStore -CACHE_DIR="$cacheDir" RETURN_CODE=404 python3 "$TEST_ROOT/test_server.py" > "$TEST_ROOT/port" 2>&1 & +CACHE_DIR="$cacheDir" RETURN_CODE=404 python3 "$TEST_SERVER" > "$TEST_ROOT/port" 2>&1 & SERVER_PID=$! sleep 1 HTTP_PORT=$(cat "$TEST_ROOT/port") @@ -117,7 +61,7 @@ sleep 1 # Test 3: 401 errors should mention authentication and NOT say "does not exist" clearStore -CACHE_DIR="$cacheDir" RETURN_CODE=401 python3 "$TEST_ROOT/test_server.py" > "$TEST_ROOT/port" 2>&1 & +CACHE_DIR="$cacheDir" RETURN_CODE=401 python3 "$TEST_SERVER" > "$TEST_ROOT/port" 2>&1 & SERVER_PID=$! sleep 1 HTTP_PORT=$(cat "$TEST_ROOT/port") @@ -144,7 +88,7 @@ sleep 1 # Test 4: 403 errors should also mention authentication and NOT say "does not exist" clearStore -CACHE_DIR="$cacheDir" RETURN_CODE=403 python3 "$TEST_ROOT/test_server.py" > "$TEST_ROOT/port" 2>&1 & +CACHE_DIR="$cacheDir" RETURN_CODE=403 python3 "$TEST_SERVER" > "$TEST_ROOT/port" 2>&1 & SERVER_PID=$! sleep 1 HTTP_PORT=$(cat "$TEST_ROOT/port") diff --git a/tests/functional/binary-cache-auth-errors_test-http-server.py b/tests/functional/binary-cache-auth-errors_test-http-server.py new file mode 100755 index 00000000000..278d6e55245 --- /dev/null +++ b/tests/functional/binary-cache-auth-errors_test-http-server.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +""" +Test HTTP server for binary cache authentication error testing. + +This server serves files from a directory but can be configured to return +specific HTTP status codes (401, 403, 404) for NAR files while still serving +metadata files (.narinfo, nix-cache-info) normally. + +Environment variables: +- CACHE_DIR: Directory to serve files from +- RETURN_CODE: HTTP status code to return for NAR files (default: 200) +- TEST_SERVER_PORT: Port to bind to (0 = random port, default) +""" + +import http.server +import socketserver +import os +import sys +from urllib.parse import urlparse, parse_qs + +PORT = int(os.environ.get('TEST_SERVER_PORT', 0)) +CACHE_DIR = os.environ.get('CACHE_DIR', '') +RETURN_CODE = int(os.environ.get('RETURN_CODE', 200)) + +class TestHandler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + # Remove leading slash + path = self.path.lstrip('/') + file_path = os.path.join(CACHE_DIR, path) + + # Check if we should return an error for NAR files + # Always serve nix-cache-info and .narinfo files normally + if RETURN_CODE != 200 and (path.startswith('nar/') or path.endswith('.nar') or path.endswith('.nar.xz')): + self.send_response(RETURN_CODE) + if RETURN_CODE == 401: + self.send_header('WWW-Authenticate', 'Bearer realm="test"') + self.send_header('Content-Length', '0') + self.end_headers() + return + + # Otherwise, serve files from the cache directory + if os.path.isfile(file_path): + self.send_response(200) + self.send_header('Content-Type', 'application/octet-stream') + with open(file_path, 'rb') as f: + content = f.read() + self.send_header('Content-Length', str(len(content))) + self.end_headers() + self.wfile.write(content) + else: + self.send_response(404) + self.send_header('Content-Length', '0') + self.end_headers() + + def log_message(self, format, *args): + # Suppress logging + pass + +# Bind to a random port if PORT is 0 +with socketserver.TCPServer(("127.0.0.1", PORT), TestHandler) as httpd: + # Print the actual port we're listening on + print(httpd.server_address[1], flush=True) + httpd.serve_forever() From e521473f26ea3e90aadbc9b44ef4972cc6dd5dbc Mon Sep 17 00:00:00 2001 From: Graham Christensen Date: Wed, 7 Jan 2026 19:15:25 -0500 Subject: [PATCH 5/6] Checkpoint: clean up starting --- tests/functional/binary-cache-auth-errors.sh | 31 +++++++++----------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/tests/functional/binary-cache-auth-errors.sh b/tests/functional/binary-cache-auth-errors.sh index d154cf6b4cb..a163c15d9aa 100755 --- a/tests/functional/binary-cache-auth-errors.sh +++ b/tests/functional/binary-cache-auth-errors.sh @@ -16,18 +16,24 @@ clearCache outPath=$(nix-build dependencies.nix --no-out-link) nix copy --to "file://$cacheDir" "$outPath" -# Start the HTTP server on a random port using our test server script +# Function to start the test HTTP server with a specific return code TEST_SERVER="$_NIX_TEST_SOURCE_DIR/binary-cache-auth-errors_test-http-server.py" -CACHE_DIR="$cacheDir" RETURN_CODE=200 python3 "$TEST_SERVER" > "$TEST_ROOT/port" 2>&1 & -SERVER_PID=$! -sleep 1 -HTTP_PORT=$(cat "$TEST_ROOT/port") +startHttpServer() { + local return_code=$1 + CACHE_DIR="$cacheDir" RETURN_CODE="$return_code" python3 "$TEST_SERVER" > "$TEST_ROOT/port" 2>&1 & + SERVER_PID=$! + sleep 1 + HTTP_PORT=$(cat "$TEST_ROOT/port") +} cleanup() { kill $SERVER_PID 2>/dev/null || true } trap cleanup EXIT +# Start the HTTP server on a random port +startHttpServer 200 + # Test 1: Normal operation (200 OK) should work clearStore nix-store --substituters "http://127.0.0.1:$HTTP_PORT" --no-require-sigs -r "$outPath" 2>&1 | tee "$TEST_ROOT/log-200" @@ -39,10 +45,7 @@ sleep 1 # Test 2: 404 errors should say "does not exist in binary cache" clearStore -CACHE_DIR="$cacheDir" RETURN_CODE=404 python3 "$TEST_SERVER" > "$TEST_ROOT/port" 2>&1 & -SERVER_PID=$! -sleep 1 -HTTP_PORT=$(cat "$TEST_ROOT/port") +startHttpServer 404 if nix-store --substituters "http://127.0.0.1:$HTTP_PORT" --no-require-sigs -r "$outPath" 2>&1 | tee "$TEST_ROOT/log-404"; then echo "Expected substitution to fail with 404" @@ -61,10 +64,7 @@ sleep 1 # Test 3: 401 errors should mention authentication and NOT say "does not exist" clearStore -CACHE_DIR="$cacheDir" RETURN_CODE=401 python3 "$TEST_SERVER" > "$TEST_ROOT/port" 2>&1 & -SERVER_PID=$! -sleep 1 -HTTP_PORT=$(cat "$TEST_ROOT/port") +startHttpServer 401 if nix-store --substituters "http://127.0.0.1:$HTTP_PORT" --no-require-sigs -r "$outPath" 2>&1 | tee "$TEST_ROOT/log-401"; then echo "Expected substitution to fail with 401" @@ -88,10 +88,7 @@ sleep 1 # Test 4: 403 errors should also mention authentication and NOT say "does not exist" clearStore -CACHE_DIR="$cacheDir" RETURN_CODE=403 python3 "$TEST_SERVER" > "$TEST_ROOT/port" 2>&1 & -SERVER_PID=$! -sleep 1 -HTTP_PORT=$(cat "$TEST_ROOT/port") +startHttpServer 403 if nix-store --substituters "http://127.0.0.1:$HTTP_PORT" --no-require-sigs -r "$outPath" 2>&1 | tee "$TEST_ROOT/log-403"; then echo "Expected substitution to fail with 403" From 81fa360e354665104f8a99008e16b81c406fb396 Mon Sep 17 00:00:00 2001 From: Graham Christensen Date: Wed, 7 Jan 2026 19:19:45 -0500 Subject: [PATCH 6/6] checkpoint: expectNoMatch extract --- tests/functional/binary-cache-auth-errors.sh | 30 ++++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/tests/functional/binary-cache-auth-errors.sh b/tests/functional/binary-cache-auth-errors.sh index a163c15d9aa..8b725f223e2 100755 --- a/tests/functional/binary-cache-auth-errors.sh +++ b/tests/functional/binary-cache-auth-errors.sh @@ -26,6 +26,20 @@ startHttpServer() { HTTP_PORT=$(cat "$TEST_ROOT/port") } +# Function to verify a pattern does NOT appear in a log file +expectNoMatch() { + local pattern=$1 + local logfile=$2 + local error_code=$3 + + if grep -q "$pattern" "$logfile"; then + echo "ERROR: $error_code error incorrectly says '$pattern'" + echo "Log contents:" + cat "$logfile" + exit 1 + fi +} + cleanup() { kill $SERVER_PID 2>/dev/null || true } @@ -38,6 +52,8 @@ startHttpServer 200 clearStore nix-store --substituters "http://127.0.0.1:$HTTP_PORT" --no-require-sigs -r "$outPath" 2>&1 | tee "$TEST_ROOT/log-200" [ -x "$outPath/program" ] +expectNoMatch "does not exist in binary cache" "$TEST_ROOT/log-200" "200" +expectNoMatch "HTTP error" "$TEST_ROOT/log-200" "200" # Stop the server and restart with 404 errors kill $SERVER_PID @@ -75,12 +91,7 @@ grepQuiet "HTTP error 401" "$TEST_ROOT/log-401" grepQuiet "access token" "$TEST_ROOT/log-401" # Verify it does NOT say "does not exist" for 401 errors -if grep -q "does not exist in binary cache" "$TEST_ROOT/log-401"; then - echo "ERROR: 401 error incorrectly says 'does not exist in binary cache'" - echo "Log contents:" - cat "$TEST_ROOT/log-401" - exit 1 -fi +expectNoMatch "does not exist in binary cache" "$TEST_ROOT/log-401" "401" # Stop the server and restart with 403 errors kill $SERVER_PID @@ -99,11 +110,6 @@ grepQuiet "HTTP error 403" "$TEST_ROOT/log-403" grepQuiet "access token" "$TEST_ROOT/log-403" # Verify it does NOT say "does not exist" for 403 errors -if grep -q "does not exist in binary cache" "$TEST_ROOT/log-403"; then - echo "ERROR: 403 error incorrectly says 'does not exist in binary cache'" - echo "Log contents:" - cat "$TEST_ROOT/log-403" - exit 1 -fi +expectNoMatch "does not exist in binary cache" "$TEST_ROOT/log-403" "403" echo "All HTTP error tests passed!"