fix: close release verification gapsUnverified
457bcb9 parent: d6184a2 modified
.github/workflows/deploy-bunny.yml +10 -2 | @@ -59,11 +59,11 @@ jobs: | ||
| 59 | 59 | node-version-file: .node-version |
| 60 | 60 | cache: npm |
| 61 | 61 | |
| 62 | - - uses: oven-sh/setup-bun@v2 | |
| 62 | + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 | |
| 63 | 63 | with: |
| 64 | 64 | bun-version: 1.4.0 |
| 65 | 65 | |
| 66 | - - uses: astral-sh/setup-uv@v6 | |
| 66 | + - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 | |
| 67 | 67 | |
| 68 | 68 | - run: npm ci |
| 69 | 69 | |
| @@ -137,6 +137,10 @@ jobs: | ||
| 137 | 137 | test -n "$BUNNY_STORAGE_ENDPOINT" |
| 138 | 138 | test -n "$BUNNY_STORAGE_PASSWORD" |
| 139 | 139 | endpoint="${BUNNY_STORAGE_ENDPOINT%/}" |
| 140 | + case "$endpoint" in | |
| 141 | + https://*) ;; | |
| 142 | + *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;; | |
| 143 | + esac | |
| 140 | 144 | mkdir -p var/deploy-backup/files |
| 141 | 145 | : > var/deploy-backup/missing |
| 142 | 146 | |
| @@ -267,6 +271,10 @@ jobs: | ||
| 267 | 271 | set -euo pipefail |
| 268 | 272 | |
| 269 | 273 | endpoint="${BUNNY_STORAGE_ENDPOINT%/}" |
| 274 | + case "$endpoint" in | |
| 275 | + https://*) ;; | |
| 276 | + *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;; | |
| 277 | + esac | |
| 270 | 278 | restore_file() { |
| 271 | 279 | local file="$1" |
| 272 | 280 | local relative_path="${file#var/deploy-backup/files/}" |
| @@ -59,11 +59,11 @@ jobs: | |||
| 59 | node-version-file: .node-version | 59 | node-version-file: .node-version |
| 60 | cache: npm | 60 | cache: npm |
| 61 | 61 | ||
| 62 | - - uses: oven-sh/setup-bun@v2 | 62 | + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 |
| 63 | with: | 63 | with: |
| 64 | bun-version: 1.4.0 | 64 | bun-version: 1.4.0 |
| 65 | 65 | ||
| 66 | - - uses: astral-sh/setup-uv@v6 | 66 | + - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 |
| 67 | 67 | ||
| 68 | - run: npm ci | 68 | - run: npm ci |
| 69 | 69 | ||
| @@ -137,6 +137,10 @@ jobs: | |||
| 137 | test -n "$BUNNY_STORAGE_ENDPOINT" | 137 | test -n "$BUNNY_STORAGE_ENDPOINT" |
| 138 | test -n "$BUNNY_STORAGE_PASSWORD" | 138 | test -n "$BUNNY_STORAGE_PASSWORD" |
| 139 | endpoint="${BUNNY_STORAGE_ENDPOINT%/}" | 139 | endpoint="${BUNNY_STORAGE_ENDPOINT%/}" |
| 140 | + case "$endpoint" in | ||
| 141 | + https://*) ;; | ||
| 142 | + *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;; | ||
| 143 | + esac | ||
| 140 | mkdir -p var/deploy-backup/files | 144 | mkdir -p var/deploy-backup/files |
| 141 | : > var/deploy-backup/missing | 145 | : > var/deploy-backup/missing |
| 142 | 146 | ||
| @@ -267,6 +271,10 @@ jobs: | |||
| 267 | set -euo pipefail | 271 | set -euo pipefail |
| 268 | 272 | ||
| 269 | endpoint="${BUNNY_STORAGE_ENDPOINT%/}" | 273 | endpoint="${BUNNY_STORAGE_ENDPOINT%/}" |
| 274 | + case "$endpoint" in | ||
| 275 | + https://*) ;; | ||
| 276 | + *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;; | ||
| 277 | + esac | ||
| 270 | restore_file() { | 278 | restore_file() { |
| 271 | local file="$1" | 279 | local file="$1" |
| 272 | local relative_path="${file#var/deploy-backup/files/}" | 280 | local relative_path="${file#var/deploy-backup/files/}" |
modified
.github/workflows/rollback-bunny.yml +11 -2 | @@ -31,8 +31,13 @@ jobs: | ||
| 31 | 31 | run: | |
| 32 | 32 | set -euo pipefail |
| 33 | 33 | [[ "$DEPLOYMENT_RUN_ID" =~ ^[1-9][0-9]*$ ]] |
| 34 | - conclusion="$(gh api "repos/$GH_REPO/actions/runs/$DEPLOYMENT_RUN_ID" --jq .conclusion)" | |
| 35 | - test "$conclusion" = success | |
| 34 | + metadata="$(gh api "repos/$GH_REPO/actions/runs/$DEPLOYMENT_RUN_ID" \ | |
| 35 | + --jq '[.status, .path, .event, .head_repository.full_name] | @tsv')" | |
| 36 | + IFS=$'\t' read -r status workflow_path event head_repository <<< "$metadata" | |
| 37 | + test "$status" = completed | |
| 38 | + test "$workflow_path" = ".github/workflows/deploy-bunny.yml" | |
| 39 | + test "$head_repository" = "$GH_REPO" | |
| 40 | + [[ "$event" = push || "$event" = workflow_dispatch ]] | |
| 36 | 41 | |
| 37 | 42 | - name: Download retained rollback bundle |
| 38 | 43 | env: |
| @@ -130,6 +135,10 @@ jobs: | ||
| 130 | 135 | set -euo pipefail |
| 131 | 136 | test -n "$CONFIG_SITE_ORIGIN" |
| 132 | 137 | origin="${CONFIG_SITE_ORIGIN%/}" |
| 138 | + case "$origin" in | |
| 139 | + https://*) ;; | |
| 140 | + *) echo "CONFIG_SITE_ORIGIN must use HTTPS" >&2; exit 1 ;; | |
| 141 | + esac | |
| 133 | 142 | |
| 134 | 143 | for attempt in 1 2 3; do |
| 135 | 144 | failed=0 |
| @@ -31,8 +31,13 @@ jobs: | |||
| 31 | run: | | 31 | run: | |
| 32 | set -euo pipefail | 32 | set -euo pipefail |
| 33 | [[ "$DEPLOYMENT_RUN_ID" =~ ^[1-9][0-9]*$ ]] | 33 | [[ "$DEPLOYMENT_RUN_ID" =~ ^[1-9][0-9]*$ ]] |
| 34 | - conclusion="$(gh api "repos/$GH_REPO/actions/runs/$DEPLOYMENT_RUN_ID" --jq .conclusion)" | 34 | + metadata="$(gh api "repos/$GH_REPO/actions/runs/$DEPLOYMENT_RUN_ID" \ |
| 35 | - test "$conclusion" = success | 35 | + --jq '[.status, .path, .event, .head_repository.full_name] | @tsv')" |
| 36 | + IFS=$'\t' read -r status workflow_path event head_repository <<< "$metadata" | ||
| 37 | + test "$status" = completed | ||
| 38 | + test "$workflow_path" = ".github/workflows/deploy-bunny.yml" | ||
| 39 | + test "$head_repository" = "$GH_REPO" | ||
| 40 | + [[ "$event" = push || "$event" = workflow_dispatch ]] | ||
| 36 | 41 | ||
| 37 | - name: Download retained rollback bundle | 42 | - name: Download retained rollback bundle |
| 38 | env: | 43 | env: |
| @@ -130,6 +135,10 @@ jobs: | |||
| 130 | set -euo pipefail | 135 | set -euo pipefail |
| 131 | test -n "$CONFIG_SITE_ORIGIN" | 136 | test -n "$CONFIG_SITE_ORIGIN" |
| 132 | origin="${CONFIG_SITE_ORIGIN%/}" | 137 | origin="${CONFIG_SITE_ORIGIN%/}" |
| 138 | + case "$origin" in | ||
| 139 | + https://*) ;; | ||
| 140 | + *) echo "CONFIG_SITE_ORIGIN must use HTTPS" >&2; exit 1 ;; | ||
| 141 | + esac | ||
| 133 | 142 | ||
| 134 | for attempt in 1 2 3; do | 143 | for attempt in 1 2 3; do |
| 135 | failed=0 | 144 | failed=0 |
modified
packages/config-page/src/catalog-client.js +41 -10 | @@ -65,6 +65,39 @@ function isPage(value, revision, page, field, placeId) { | ||
| 65 | 65 | && Array.isArray(value[field]) && (value.nextPage === null || value[field].length > 0); |
| 66 | 66 | } |
| 67 | 67 | |
| 68 | +function isSearchPlace(value) { | |
| 69 | + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; | |
| 70 | + const { searchText, ...place } = value; | |
| 71 | + return isPlaceSearchItem(place) && PLACE_ID.test(place.placeId) | |
| 72 | + && typeof searchText === "string" && searchText !== "" | |
| 73 | + && normalizeCatalogSearchText(searchText) === searchText; | |
| 74 | +} | |
| 75 | + | |
| 76 | +export function isCatalogSearchPage(value, revision, page) { | |
| 77 | + return isPage(value, revision, page, "places") | |
| 78 | + && value.places.every(isSearchPlace); | |
| 79 | +} | |
| 80 | + | |
| 81 | +export function isCatalogServicesPage(value, revision, placeId, page) { | |
| 82 | + if (!PLACE_ID.test(placeId) || !isPage(value, revision, page, "services", placeId)) return false; | |
| 83 | + const identifiers = new Set(); | |
| 84 | + return value.services.every((service) => { | |
| 85 | + if (!isServiceOption(service) || !SERVICE_ID.test(service.serviceId) | |
| 86 | + || identifiers.has(service.serviceId)) return false; | |
| 87 | + identifiers.add(service.serviceId); | |
| 88 | + return true; | |
| 89 | + }); | |
| 90 | +} | |
| 91 | + | |
| 92 | +export function isCatalogServiceDocument(value, revision, serviceId) { | |
| 93 | + return SERVICE_ID.test(serviceId) | |
| 94 | + && exactFields(value, ["schemaVersion", "revision", "service"]) | |
| 95 | + && value.schemaVersion === SCHEMA_VERSION | |
| 96 | + && value.revision === revision | |
| 97 | + && isServiceOption(value.service) | |
| 98 | + && value.service.serviceId === serviceId; | |
| 99 | +} | |
| 100 | + | |
| 68 | 101 | function compareMatches(left, right) { |
| 69 | 102 | if (left.rank !== right.rank) return left.rank - right.rank; |
| 70 | 103 | for (let index = 0; index < left.order.length; index += 1) { |
| @@ -165,13 +198,9 @@ export function createCatalogClient({ | ||
| 165 | 198 | const body = await json(`${pinned.revision}/search/${bucket}/${page}.json`, signal, |
| 166 | 199 | page === 0 ? null : "BACKEND_UNAVAILABLE"); |
| 167 | 200 | if (body === null) return []; |
| 168 | - if (!isPage(body, pinned.revision, page, "places")) throw new CatalogClientError("BACKEND_UNAVAILABLE"); | |
| 201 | + if (!isCatalogSearchPage(body, pinned.revision, page)) throw new CatalogClientError("BACKEND_UNAVAILABLE"); | |
| 169 | 202 | for (const entry of body.places) { |
| 170 | - if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new CatalogClientError("BACKEND_UNAVAILABLE"); | |
| 171 | 203 | const { searchText, ...place } = entry; |
| 172 | - if (!isPlaceSearchItem(place) || !PLACE_ID.test(place.placeId) | |
| 173 | - || typeof searchText !== "string" || searchText === "" | |
| 174 | - || normalizeCatalogSearchText(searchText) !== searchText) throw new CatalogClientError("BACKEND_UNAVAILABLE"); | |
| 175 | 204 | const words = searchText.split(" "); |
| 176 | 205 | if (!tokens.every((token) => words.some((word) => word.startsWith(token)))) continue; |
| 177 | 206 | const stop = normalizeCatalogSearchText(place.stopLabel); |
| @@ -231,9 +260,11 @@ export function createCatalogClient({ | ||
| 231 | 260 | while (true) { |
| 232 | 261 | const body = await json(`${pinned.revision}/places/${placeId}/${page}.json`, signal, |
| 233 | 262 | page === 0 ? "INVALID_SERVICE" : "BACKEND_UNAVAILABLE"); |
| 234 | - if (!isPage(body, pinned.revision, page, "services", placeId)) throw new CatalogClientError("BACKEND_UNAVAILABLE"); | |
| 263 | + if (!isCatalogServicesPage(body, pinned.revision, placeId, page)) { | |
| 264 | + throw new CatalogClientError("BACKEND_UNAVAILABLE"); | |
| 265 | + } | |
| 235 | 266 | for (const service of body.services) { |
| 236 | - if (!isServiceOption(service) || !SERVICE_ID.test(service.serviceId) || seen.has(service.serviceId)) { | |
| 267 | + if (seen.has(service.serviceId)) { | |
| 237 | 268 | throw new CatalogClientError("INVALID_SERVICE"); |
| 238 | 269 | } |
| 239 | 270 | seen.add(service.serviceId); |
| @@ -250,9 +281,9 @@ export function createCatalogClient({ | ||
| 250 | 281 | const pinned = await pinnedManifest(signal); |
| 251 | 282 | const body = await json(`${pinned.revision}/services/${serviceId}.json`, signal, null); |
| 252 | 283 | if (body === null) return null; |
| 253 | - if (!exactFields(body, ["schemaVersion", "revision", "service"]) | |
| 254 | - || body.schemaVersion !== SCHEMA_VERSION || body.revision !== pinned.revision | |
| 255 | - || !isServiceOption(body.service) || body.service.serviceId !== serviceId) throw new CatalogClientError("INVALID_SERVICE"); | |
| 284 | + if (!isCatalogServiceDocument(body, pinned.revision, serviceId)) { | |
| 285 | + throw new CatalogClientError("INVALID_SERVICE"); | |
| 286 | + } | |
| 256 | 287 | return body.service; |
| 257 | 288 | } |
| 258 | 289 | |
| @@ -65,6 +65,39 @@ function isPage(value, revision, page, field, placeId) { | |||
| 65 | && Array.isArray(value[field]) && (value.nextPage === null || value[field].length > 0); | 65 | && Array.isArray(value[field]) && (value.nextPage === null || value[field].length > 0); |
| 66 | } | 66 | } |
| 67 | 67 | ||
| 68 | +function isSearchPlace(value) { | ||
| 69 | + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; | ||
| 70 | + const { searchText, ...place } = value; | ||
| 71 | + return isPlaceSearchItem(place) && PLACE_ID.test(place.placeId) | ||
| 72 | + && typeof searchText === "string" && searchText !== "" | ||
| 73 | + && normalizeCatalogSearchText(searchText) === searchText; | ||
| 74 | +} | ||
| 75 | + | ||
| 76 | +export function isCatalogSearchPage(value, revision, page) { | ||
| 77 | + return isPage(value, revision, page, "places") | ||
| 78 | + && value.places.every(isSearchPlace); | ||
| 79 | +} | ||
| 80 | + | ||
| 81 | +export function isCatalogServicesPage(value, revision, placeId, page) { | ||
| 82 | + if (!PLACE_ID.test(placeId) || !isPage(value, revision, page, "services", placeId)) return false; | ||
| 83 | + const identifiers = new Set(); | ||
| 84 | + return value.services.every((service) => { | ||
| 85 | + if (!isServiceOption(service) || !SERVICE_ID.test(service.serviceId) | ||
| 86 | + || identifiers.has(service.serviceId)) return false; | ||
| 87 | + identifiers.add(service.serviceId); | ||
| 88 | + return true; | ||
| 89 | + }); | ||
| 90 | +} | ||
| 91 | + | ||
| 92 | +export function isCatalogServiceDocument(value, revision, serviceId) { | ||
| 93 | + return SERVICE_ID.test(serviceId) | ||
| 94 | + && exactFields(value, ["schemaVersion", "revision", "service"]) | ||
| 95 | + && value.schemaVersion === SCHEMA_VERSION | ||
| 96 | + && value.revision === revision | ||
| 97 | + && isServiceOption(value.service) | ||
| 98 | + && value.service.serviceId === serviceId; | ||
| 99 | +} | ||
| 100 | + | ||
| 68 | function compareMatches(left, right) { | 101 | function compareMatches(left, right) { |
| 69 | if (left.rank !== right.rank) return left.rank - right.rank; | 102 | if (left.rank !== right.rank) return left.rank - right.rank; |
| 70 | for (let index = 0; index < left.order.length; index += 1) { | 103 | for (let index = 0; index < left.order.length; index += 1) { |
| @@ -165,13 +198,9 @@ export function createCatalogClient({ | |||
| 165 | const body = await json(`${pinned.revision}/search/${bucket}/${page}.json`, signal, | 198 | const body = await json(`${pinned.revision}/search/${bucket}/${page}.json`, signal, |
| 166 | page === 0 ? null : "BACKEND_UNAVAILABLE"); | 199 | page === 0 ? null : "BACKEND_UNAVAILABLE"); |
| 167 | if (body === null) return []; | 200 | if (body === null) return []; |
| 168 | - if (!isPage(body, pinned.revision, page, "places")) throw new CatalogClientError("BACKEND_UNAVAILABLE"); | 201 | + if (!isCatalogSearchPage(body, pinned.revision, page)) throw new CatalogClientError("BACKEND_UNAVAILABLE"); |
| 169 | for (const entry of body.places) { | 202 | for (const entry of body.places) { |
| 170 | - if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new CatalogClientError("BACKEND_UNAVAILABLE"); | ||
| 171 | const { searchText, ...place } = entry; | 203 | const { searchText, ...place } = entry; |
| 172 | - if (!isPlaceSearchItem(place) || !PLACE_ID.test(place.placeId) | ||
| 173 | - || typeof searchText !== "string" || searchText === "" | ||
| 174 | - || normalizeCatalogSearchText(searchText) !== searchText) throw new CatalogClientError("BACKEND_UNAVAILABLE"); | ||
| 175 | const words = searchText.split(" "); | 204 | const words = searchText.split(" "); |
| 176 | if (!tokens.every((token) => words.some((word) => word.startsWith(token)))) continue; | 205 | if (!tokens.every((token) => words.some((word) => word.startsWith(token)))) continue; |
| 177 | const stop = normalizeCatalogSearchText(place.stopLabel); | 206 | const stop = normalizeCatalogSearchText(place.stopLabel); |
| @@ -231,9 +260,11 @@ export function createCatalogClient({ | |||
| 231 | while (true) { | 260 | while (true) { |
| 232 | const body = await json(`${pinned.revision}/places/${placeId}/${page}.json`, signal, | 261 | const body = await json(`${pinned.revision}/places/${placeId}/${page}.json`, signal, |
| 233 | page === 0 ? "INVALID_SERVICE" : "BACKEND_UNAVAILABLE"); | 262 | page === 0 ? "INVALID_SERVICE" : "BACKEND_UNAVAILABLE"); |
| 234 | - if (!isPage(body, pinned.revision, page, "services", placeId)) throw new CatalogClientError("BACKEND_UNAVAILABLE"); | 263 | + if (!isCatalogServicesPage(body, pinned.revision, placeId, page)) { |
| 264 | + throw new CatalogClientError("BACKEND_UNAVAILABLE"); | ||
| 265 | + } | ||
| 235 | for (const service of body.services) { | 266 | for (const service of body.services) { |
| 236 | - if (!isServiceOption(service) || !SERVICE_ID.test(service.serviceId) || seen.has(service.serviceId)) { | 267 | + if (seen.has(service.serviceId)) { |
| 237 | throw new CatalogClientError("INVALID_SERVICE"); | 268 | throw new CatalogClientError("INVALID_SERVICE"); |
| 238 | } | 269 | } |
| 239 | seen.add(service.serviceId); | 270 | seen.add(service.serviceId); |
| @@ -250,9 +281,9 @@ export function createCatalogClient({ | |||
| 250 | const pinned = await pinnedManifest(signal); | 281 | const pinned = await pinnedManifest(signal); |
| 251 | const body = await json(`${pinned.revision}/services/${serviceId}.json`, signal, null); | 282 | const body = await json(`${pinned.revision}/services/${serviceId}.json`, signal, null); |
| 252 | if (body === null) return null; | 283 | if (body === null) return null; |
| 253 | - if (!exactFields(body, ["schemaVersion", "revision", "service"]) | 284 | + if (!isCatalogServiceDocument(body, pinned.revision, serviceId)) { |
| 254 | - || body.schemaVersion !== SCHEMA_VERSION || body.revision !== pinned.revision | 285 | + throw new CatalogClientError("INVALID_SERVICE"); |
| 255 | - || !isServiceOption(body.service) || body.service.serviceId !== serviceId) throw new CatalogClientError("INVALID_SERVICE"); | 286 | + } |
| 256 | return body.service; | 287 | return body.service; |
| 257 | } | 288 | } |
| 258 | 289 | ||
modified
packages/config-page/test/config-site-build.test.ts +51 -7 | @@ -58,6 +58,30 @@ test("configuration-site verification checks every local and served file on the | ||
| 58 | 58 | const paths = fixture(); |
| 59 | 59 | try { |
| 60 | 60 | const revision = "a".repeat(64); |
| 61 | + const placeId = `plc_${"a".repeat(43)}`; | |
| 62 | + const serviceId = `svc_${"b".repeat(43)}`; | |
| 63 | + const place = { | |
| 64 | + placeId, | |
| 65 | + stopLabel: "Gare du Nord", | |
| 66 | + localityLabel: "Paris", | |
| 67 | + mode: "BUS", | |
| 68 | + lines: [{ lineLabel: "42", lineColor: "#e86a10", lineTextColor: "#ffffff" }], | |
| 69 | + searchText: "gare du nord paris", | |
| 70 | + }; | |
| 71 | + const service = { | |
| 72 | + serviceId, | |
| 73 | + stopLabel: "Gare du Nord", | |
| 74 | + lineLabel: "42", | |
| 75 | + destinationLabel: "Hôpital Européen", | |
| 76 | + lineMode: "BUS", | |
| 77 | + lineColor: "#e86a10", | |
| 78 | + lineTextColor: "#ffffff", | |
| 79 | + routing: { | |
| 80 | + monitoringRef: "fixture-monitoring", | |
| 81 | + lineRef: "fixture-line", | |
| 82 | + destinationRef: "fixture-destination", | |
| 83 | + }, | |
| 84 | + }; | |
| 61 | 85 | const manifest = { |
| 62 | 86 | schemaVersion: 1, |
| 63 | 87 | revision, |
| @@ -74,13 +98,13 @@ test("configuration-site verification checks every local and served file on the | ||
| 74 | 98 | ["index.html", "<h1>Lapin Futé</h1>"], |
| 75 | 99 | ["catalog/manifest.json", JSON.stringify(manifest)], |
| 76 | 100 | [`catalog/${revision}/search/a/0.json`, JSON.stringify({ |
| 77 | - schemaVersion: 1, revision, page: 0, nextPage: null, places: [], | |
| 101 | + schemaVersion: 1, revision, page: 0, nextPage: null, places: [place], | |
| 78 | 102 | })], |
| 79 | - [`catalog/${revision}/places/plc_${"a".repeat(43)}/0.json`, JSON.stringify({ | |
| 80 | - schemaVersion: 1, revision, placeId: `plc_${"a".repeat(43)}`, page: 0, nextPage: null, services: [], | |
| 103 | + [`catalog/${revision}/places/${placeId}/0.json`, JSON.stringify({ | |
| 104 | + schemaVersion: 1, revision, placeId, page: 0, nextPage: null, services: [service], | |
| 81 | 105 | })], |
| 82 | - [`catalog/${revision}/services/svc_${"b".repeat(43)}.json`, JSON.stringify({ | |
| 83 | - schemaVersion: 1, revision, service: {}, | |
| 106 | + [`catalog/${revision}/services/${serviceId}.json`, JSON.stringify({ | |
| 107 | + schemaVersion: 1, revision, service, | |
| 84 | 108 | })], |
| 85 | 109 | ]); |
| 86 | 110 | for (const [relativePath, body] of files) { |
| @@ -110,7 +134,23 @@ test("configuration-site verification checks every local and served file on the | ||
| 110 | 134 | .map((path) => `https://config.example.test/lapin-fute/${path}`) |
| 111 | 135 | .sort()); |
| 112 | 136 | |
| 113 | - files.set(`catalog/${revision}/services/svc_${"b".repeat(43)}.json`, "{}"); | |
| 137 | + const servicePath = `catalog/${revision}/services/${serviceId}.json`; | |
| 138 | + writeFileSync(join(paths.outputPath, servicePath), JSON.stringify({ | |
| 139 | + schemaVersion: 1, | |
| 140 | + revision, | |
| 141 | + service: {}, | |
| 142 | + })); | |
| 143 | + await assert.rejects( | |
| 144 | + verifyConfigSite({ | |
| 145 | + sitePath: paths.outputPath, | |
| 146 | + origin: "https://config.example.test/lapin-fute/", | |
| 147 | + fetcher: async () => new Response(null, { status: 404 }), | |
| 148 | + }), | |
| 149 | + /does not satisfy the static catalog contract/u, | |
| 150 | + ); | |
| 151 | + writeFileSync(join(paths.outputPath, servicePath), files.get(servicePath)); | |
| 152 | + | |
| 153 | + files.set(servicePath, "{}"); | |
| 114 | 154 | await assert.rejects( |
| 115 | 155 | verifyConfigSite({ |
| 116 | 156 | sitePath: paths.outputPath, |
| @@ -139,6 +179,8 @@ test("Bunny releases deploy production tags while preview tags stop after qualit | ||
| 139 | 179 | assert.match(workflow, /tags:\s*\["v\*", "pre-v\*"\]/u); |
| 140 | 180 | assert.match(workflow, /startsWith\(github\.ref_name, 'v'\)/u); |
| 141 | 181 | assert.match(workflow, /package:\s+name: Build release PBW[\s\S]+needs: test/u); |
| 182 | + assert.match(workflow, /oven-sh\/setup-bun@[a-f0-9]{40} # v2\.2\.0/u); | |
| 183 | + assert.match(workflow, /astral-sh\/setup-uv@[a-f0-9]{40} # v6\.8\.0/u); | |
| 142 | 184 | assert.match(workflow, /pebble-tool==5\.0\.40[\s\S]+pebble sdk install 4\.33\.1/u); |
| 143 | 185 | assert.match(workflow, /LAPIN_FUTE_CONFIG_URL: \$\{\{ vars\.CONFIG_SITE_ORIGIN \}\}[\s\S]+npm run build/u); |
| 144 | 186 | assert.match(workflow, /sha256sum "lapin-fute-\$RELEASE_TAG\.pbw" > SHA256SUMS/u); |
| @@ -165,6 +207,8 @@ test("Bunny releases deploy production tags while preview tags stop after qualit | ||
| 165 | 207 | assert.match(workflow, /Restoring \$relative_path[\s\S]+Removing newly introduced mutable file/u); |
| 166 | 208 | assert.match(rollbackWorkflow, /deployment_run_id:[\s\S]+type: string/u); |
| 167 | 209 | assert.match(rollbackWorkflow, /gh run download "\$DEPLOYMENT_RUN_ID"[\s\S]+bunny-rollback-\$DEPLOYMENT_RUN_ID/u); |
| 168 | - assert.match(rollbackWorkflow, /conclusion="\$\(gh api[\s\S]+test "\$conclusion" = success/u); | |
| 210 | + assert.match(rollbackWorkflow, /test "\$status" = completed[\s\S]+workflow_path" = "\.github\/workflows\/deploy-bunny\.yml"/u); | |
| 211 | + assert.match(rollbackWorkflow, /head_repository" = "\$GH_REPO"[\s\S]+event" = workflow_dispatch/u); | |
| 169 | 212 | assert.match(rollbackWorkflow, /Restore previous mutable files[\s\S]+Purge Bunny CDN cache[\s\S]+Verify restored mutable files/u); |
| 213 | + assert.match(rollbackWorkflow, /CONFIG_SITE_ORIGIN must use HTTPS/u); | |
| 170 | 214 | }); |
| @@ -58,6 +58,30 @@ test("configuration-site verification checks every local and served file on the | |||
| 58 | const paths = fixture(); | 58 | const paths = fixture(); |
| 59 | try { | 59 | try { |
| 60 | const revision = "a".repeat(64); | 60 | const revision = "a".repeat(64); |
| 61 | + const placeId = `plc_${"a".repeat(43)}`; | ||
| 62 | + const serviceId = `svc_${"b".repeat(43)}`; | ||
| 63 | + const place = { | ||
| 64 | + placeId, | ||
| 65 | + stopLabel: "Gare du Nord", | ||
| 66 | + localityLabel: "Paris", | ||
| 67 | + mode: "BUS", | ||
| 68 | + lines: [{ lineLabel: "42", lineColor: "#e86a10", lineTextColor: "#ffffff" }], | ||
| 69 | + searchText: "gare du nord paris", | ||
| 70 | + }; | ||
| 71 | + const service = { | ||
| 72 | + serviceId, | ||
| 73 | + stopLabel: "Gare du Nord", | ||
| 74 | + lineLabel: "42", | ||
| 75 | + destinationLabel: "Hôpital Européen", | ||
| 76 | + lineMode: "BUS", | ||
| 77 | + lineColor: "#e86a10", | ||
| 78 | + lineTextColor: "#ffffff", | ||
| 79 | + routing: { | ||
| 80 | + monitoringRef: "fixture-monitoring", | ||
| 81 | + lineRef: "fixture-line", | ||
| 82 | + destinationRef: "fixture-destination", | ||
| 83 | + }, | ||
| 84 | + }; | ||
| 61 | const manifest = { | 85 | const manifest = { |
| 62 | schemaVersion: 1, | 86 | schemaVersion: 1, |
| 63 | revision, | 87 | revision, |
| @@ -74,13 +98,13 @@ test("configuration-site verification checks every local and served file on the | |||
| 74 | ["index.html", "<h1>Lapin Futé</h1>"], | 98 | ["index.html", "<h1>Lapin Futé</h1>"], |
| 75 | ["catalog/manifest.json", JSON.stringify(manifest)], | 99 | ["catalog/manifest.json", JSON.stringify(manifest)], |
| 76 | [`catalog/${revision}/search/a/0.json`, JSON.stringify({ | 100 | [`catalog/${revision}/search/a/0.json`, JSON.stringify({ |
| 77 | - schemaVersion: 1, revision, page: 0, nextPage: null, places: [], | 101 | + schemaVersion: 1, revision, page: 0, nextPage: null, places: [place], |
| 78 | })], | 102 | })], |
| 79 | - [`catalog/${revision}/places/plc_${"a".repeat(43)}/0.json`, JSON.stringify({ | 103 | + [`catalog/${revision}/places/${placeId}/0.json`, JSON.stringify({ |
| 80 | - schemaVersion: 1, revision, placeId: `plc_${"a".repeat(43)}`, page: 0, nextPage: null, services: [], | 104 | + schemaVersion: 1, revision, placeId, page: 0, nextPage: null, services: [service], |
| 81 | })], | 105 | })], |
| 82 | - [`catalog/${revision}/services/svc_${"b".repeat(43)}.json`, JSON.stringify({ | 106 | + [`catalog/${revision}/services/${serviceId}.json`, JSON.stringify({ |
| 83 | - schemaVersion: 1, revision, service: {}, | 107 | + schemaVersion: 1, revision, service, |
| 84 | })], | 108 | })], |
| 85 | ]); | 109 | ]); |
| 86 | for (const [relativePath, body] of files) { | 110 | for (const [relativePath, body] of files) { |
| @@ -110,7 +134,23 @@ test("configuration-site verification checks every local and served file on the | |||
| 110 | .map((path) => `https://config.example.test/lapin-fute/${path}`) | 134 | .map((path) => `https://config.example.test/lapin-fute/${path}`) |
| 111 | .sort()); | 135 | .sort()); |
| 112 | 136 | ||
| 113 | - files.set(`catalog/${revision}/services/svc_${"b".repeat(43)}.json`, "{}"); | 137 | + const servicePath = `catalog/${revision}/services/${serviceId}.json`; |
| 138 | + writeFileSync(join(paths.outputPath, servicePath), JSON.stringify({ | ||
| 139 | + schemaVersion: 1, | ||
| 140 | + revision, | ||
| 141 | + service: {}, | ||
| 142 | + })); | ||
| 143 | + await assert.rejects( | ||
| 144 | + verifyConfigSite({ | ||
| 145 | + sitePath: paths.outputPath, | ||
| 146 | + origin: "https://config.example.test/lapin-fute/", | ||
| 147 | + fetcher: async () => new Response(null, { status: 404 }), | ||
| 148 | + }), | ||
| 149 | + /does not satisfy the static catalog contract/u, | ||
| 150 | + ); | ||
| 151 | + writeFileSync(join(paths.outputPath, servicePath), files.get(servicePath)); | ||
| 152 | + | ||
| 153 | + files.set(servicePath, "{}"); | ||
| 114 | await assert.rejects( | 154 | await assert.rejects( |
| 115 | verifyConfigSite({ | 155 | verifyConfigSite({ |
| 116 | sitePath: paths.outputPath, | 156 | sitePath: paths.outputPath, |
| @@ -139,6 +179,8 @@ test("Bunny releases deploy production tags while preview tags stop after qualit | |||
| 139 | assert.match(workflow, /tags:\s*\["v\*", "pre-v\*"\]/u); | 179 | assert.match(workflow, /tags:\s*\["v\*", "pre-v\*"\]/u); |
| 140 | assert.match(workflow, /startsWith\(github\.ref_name, 'v'\)/u); | 180 | assert.match(workflow, /startsWith\(github\.ref_name, 'v'\)/u); |
| 141 | assert.match(workflow, /package:\s+name: Build release PBW[\s\S]+needs: test/u); | 181 | assert.match(workflow, /package:\s+name: Build release PBW[\s\S]+needs: test/u); |
| 182 | + assert.match(workflow, /oven-sh\/setup-bun@[a-f0-9]{40} # v2\.2\.0/u); | ||
| 183 | + assert.match(workflow, /astral-sh\/setup-uv@[a-f0-9]{40} # v6\.8\.0/u); | ||
| 142 | assert.match(workflow, /pebble-tool==5\.0\.40[\s\S]+pebble sdk install 4\.33\.1/u); | 184 | assert.match(workflow, /pebble-tool==5\.0\.40[\s\S]+pebble sdk install 4\.33\.1/u); |
| 143 | assert.match(workflow, /LAPIN_FUTE_CONFIG_URL: \$\{\{ vars\.CONFIG_SITE_ORIGIN \}\}[\s\S]+npm run build/u); | 185 | assert.match(workflow, /LAPIN_FUTE_CONFIG_URL: \$\{\{ vars\.CONFIG_SITE_ORIGIN \}\}[\s\S]+npm run build/u); |
| 144 | assert.match(workflow, /sha256sum "lapin-fute-\$RELEASE_TAG\.pbw" > SHA256SUMS/u); | 186 | assert.match(workflow, /sha256sum "lapin-fute-\$RELEASE_TAG\.pbw" > SHA256SUMS/u); |
| @@ -165,6 +207,8 @@ test("Bunny releases deploy production tags while preview tags stop after qualit | |||
| 165 | assert.match(workflow, /Restoring \$relative_path[\s\S]+Removing newly introduced mutable file/u); | 207 | assert.match(workflow, /Restoring \$relative_path[\s\S]+Removing newly introduced mutable file/u); |
| 166 | assert.match(rollbackWorkflow, /deployment_run_id:[\s\S]+type: string/u); | 208 | assert.match(rollbackWorkflow, /deployment_run_id:[\s\S]+type: string/u); |
| 167 | assert.match(rollbackWorkflow, /gh run download "\$DEPLOYMENT_RUN_ID"[\s\S]+bunny-rollback-\$DEPLOYMENT_RUN_ID/u); | 209 | assert.match(rollbackWorkflow, /gh run download "\$DEPLOYMENT_RUN_ID"[\s\S]+bunny-rollback-\$DEPLOYMENT_RUN_ID/u); |
| 168 | - assert.match(rollbackWorkflow, /conclusion="\$\(gh api[\s\S]+test "\$conclusion" = success/u); | 210 | + assert.match(rollbackWorkflow, /test "\$status" = completed[\s\S]+workflow_path" = "\.github\/workflows\/deploy-bunny\.yml"/u); |
| 211 | + assert.match(rollbackWorkflow, /head_repository" = "\$GH_REPO"[\s\S]+event" = workflow_dispatch/u); | ||
| 169 | assert.match(rollbackWorkflow, /Restore previous mutable files[\s\S]+Purge Bunny CDN cache[\s\S]+Verify restored mutable files/u); | 212 | assert.match(rollbackWorkflow, /Restore previous mutable files[\s\S]+Purge Bunny CDN cache[\s\S]+Verify restored mutable files/u); |
| 213 | + assert.match(rollbackWorkflow, /CONFIG_SITE_ORIGIN must use HTTPS/u); | ||
| 170 | }); | 214 | }); |
modified
scripts/verify-config-site.mjs +55 -12 | @@ -1,12 +1,18 @@ | ||
| 1 | 1 | import { readdirSync, readFileSync, statSync } from "node:fs"; |
| 2 | 2 | import { dirname, join, relative, resolve, sep } from "node:path"; |
| 3 | 3 | import { fileURLToPath } from "node:url"; |
| 4 | -import { isCatalogManifest } from "../packages/config-page/src/catalog-client.js"; | |
| 4 | +import { | |
| 5 | + isCatalogManifest, | |
| 6 | + isCatalogSearchPage, | |
| 7 | + isCatalogServiceDocument, | |
| 8 | + isCatalogServicesPage, | |
| 9 | +} from "../packages/config-page/src/catalog-client.js"; | |
| 5 | 10 | |
| 6 | 11 | const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); |
| 7 | 12 | const DEFAULT_SITE_PATH = join(PROJECT_ROOT, "var/config-site"); |
| 8 | 13 | const REQUIRED_CATALOG_GROUPS = ["search", "places", "services"]; |
| 9 | 14 | const REMOTE_CONCURRENCY = 16; |
| 15 | +const REMOTE_TIMEOUT_MS = 30_000; | |
| 10 | 16 | |
| 11 | 17 | function fail(message) { |
| 12 | 18 | throw new Error(`verify:config-site failed: ${message}`); |
| @@ -79,9 +85,21 @@ function verifyLocalSite(sitePath) { | ||
| 79 | 85 | if (groupFiles.length === 0) fail(`current catalog has no ${group} JSON`); |
| 80 | 86 | for (const path of groupFiles) { |
| 81 | 87 | const document = parseJson(path, relative(root, path)); |
| 82 | - if (document?.schemaVersion !== manifest.schemaVersion || document?.revision !== manifest.revision) { | |
| 83 | - fail(`${relative(root, path)} does not match the current schema and revision`); | |
| 84 | - } | |
| 88 | + const catalogPath = relative(revisionRoot, path).split(sep); | |
| 89 | + const page = catalogPath.at(-1)?.match(/^([0-9]+)\.json$/u); | |
| 90 | + const valid = group === "search" | |
| 91 | + ? catalogPath.length === 3 && page !== undefined && page !== null | |
| 92 | + && isCatalogSearchPage(document, manifest.revision, Number(page[1])) | |
| 93 | + : group === "places" | |
| 94 | + ? catalogPath.length === 3 && page !== undefined && page !== null | |
| 95 | + && isCatalogServicesPage(document, manifest.revision, catalogPath[1], Number(page[1])) | |
| 96 | + : catalogPath.length === 2 | |
| 97 | + && isCatalogServiceDocument( | |
| 98 | + document, | |
| 99 | + manifest.revision, | |
| 100 | + catalogPath[1].replace(/\.json$/u, ""), | |
| 101 | + ); | |
| 102 | + if (!valid) fail(`${relative(root, path)} does not satisfy the static catalog contract`); | |
| 85 | 103 | catalogJsonCount += 1; |
| 86 | 104 | } |
| 87 | 105 | } |
| @@ -109,19 +127,44 @@ async function verifyServedFiles(local, origin, fetcher) { | ||
| 109 | 127 | const path = local.files[next]; |
| 110 | 128 | next += 1; |
| 111 | 129 | const url = remoteUrl(origin, local.root, path); |
| 112 | - let response; | |
| 130 | + const expected = readFileSync(path); | |
| 113 | 131 | try { |
| 114 | - response = await fetcher(url, { | |
| 132 | + const response = await fetcher(url, { | |
| 115 | 133 | headers: { Accept: "*/*", "Cache-Control": "no-cache" }, |
| 116 | 134 | redirect: "error", |
| 135 | + signal: AbortSignal.timeout(REMOTE_TIMEOUT_MS), | |
| 117 | 136 | }); |
| 118 | - } catch { | |
| 119 | - fail(`could not fetch ${url.href}`); | |
| 137 | + if (!response.ok) fail(`${url.href} returned HTTP ${response.status}`); | |
| 138 | + const declaredLength = Number(response.headers.get("content-length")); | |
| 139 | + if (Number.isFinite(declaredLength) && declaredLength > expected.length) { | |
| 140 | + fail(`served response is too large for ${url.href}`); | |
| 141 | + } | |
| 142 | + if (response.body === null) fail(`served response has no body for ${url.href}`); | |
| 143 | + const reader = response.body.getReader(); | |
| 144 | + const chunks = []; | |
| 145 | + let bytes = 0; | |
| 146 | + let finished = false; | |
| 147 | + try { | |
| 148 | + while (true) { | |
| 149 | + const { done, value } = await reader.read(); | |
| 150 | + if (done) { | |
| 151 | + finished = true; | |
| 152 | + break; | |
| 153 | + } | |
| 154 | + bytes += value.byteLength; | |
| 155 | + if (bytes > expected.length) fail(`served response is too large for ${url.href}`); | |
| 156 | + chunks.push(value); | |
| 157 | + } | |
| 158 | + } finally { | |
| 159 | + if (!finished) await reader.cancel(); | |
| 160 | + reader.releaseLock(); | |
| 161 | + } | |
| 162 | + const actual = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), bytes); | |
| 163 | + if (!actual.equals(expected)) fail(`served bytes differ for ${url.href}`); | |
| 164 | + } catch (error) { | |
| 165 | + if (error instanceof Error && error.message.startsWith("verify:config-site failed:")) throw error; | |
| 166 | + fail(`could not fetch or validate ${url.href}`); | |
| 120 | 167 | } |
| 121 | - if (!response.ok) fail(`${url.href} returned HTTP ${response.status}`); | |
| 122 | - const expected = readFileSync(path); | |
| 123 | - const actual = Buffer.from(await response.arrayBuffer()); | |
| 124 | - if (!actual.equals(expected)) fail(`served bytes differ for ${url.href}`); | |
| 125 | 168 | } |
| 126 | 169 | } |
| 127 | 170 | await Promise.all(Array.from( |
| @@ -1,12 +1,18 @@ | |||
| 1 | import { readdirSync, readFileSync, statSync } from "node:fs"; | 1 | import { readdirSync, readFileSync, statSync } from "node:fs"; |
| 2 | import { dirname, join, relative, resolve, sep } from "node:path"; | 2 | import { dirname, join, relative, resolve, sep } from "node:path"; |
| 3 | import { fileURLToPath } from "node:url"; | 3 | import { fileURLToPath } from "node:url"; |
| 4 | -import { isCatalogManifest } from "../packages/config-page/src/catalog-client.js"; | 4 | +import { |
| 5 | + isCatalogManifest, | ||
| 6 | + isCatalogSearchPage, | ||
| 7 | + isCatalogServiceDocument, | ||
| 8 | + isCatalogServicesPage, | ||
| 9 | +} from "../packages/config-page/src/catalog-client.js"; | ||
| 5 | 10 | ||
| 6 | const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); | 11 | const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); |
| 7 | const DEFAULT_SITE_PATH = join(PROJECT_ROOT, "var/config-site"); | 12 | const DEFAULT_SITE_PATH = join(PROJECT_ROOT, "var/config-site"); |
| 8 | const REQUIRED_CATALOG_GROUPS = ["search", "places", "services"]; | 13 | const REQUIRED_CATALOG_GROUPS = ["search", "places", "services"]; |
| 9 | const REMOTE_CONCURRENCY = 16; | 14 | const REMOTE_CONCURRENCY = 16; |
| 15 | +const REMOTE_TIMEOUT_MS = 30_000; | ||
| 10 | 16 | ||
| 11 | function fail(message) { | 17 | function fail(message) { |
| 12 | throw new Error(`verify:config-site failed: ${message}`); | 18 | throw new Error(`verify:config-site failed: ${message}`); |
| @@ -79,9 +85,21 @@ function verifyLocalSite(sitePath) { | |||
| 79 | if (groupFiles.length === 0) fail(`current catalog has no ${group} JSON`); | 85 | if (groupFiles.length === 0) fail(`current catalog has no ${group} JSON`); |
| 80 | for (const path of groupFiles) { | 86 | for (const path of groupFiles) { |
| 81 | const document = parseJson(path, relative(root, path)); | 87 | const document = parseJson(path, relative(root, path)); |
| 82 | - if (document?.schemaVersion !== manifest.schemaVersion || document?.revision !== manifest.revision) { | 88 | + const catalogPath = relative(revisionRoot, path).split(sep); |
| 83 | - fail(`${relative(root, path)} does not match the current schema and revision`); | 89 | + const page = catalogPath.at(-1)?.match(/^([0-9]+)\.json$/u); |
| 84 | - } | 90 | + const valid = group === "search" |
| 91 | + ? catalogPath.length === 3 && page !== undefined && page !== null | ||
| 92 | + && isCatalogSearchPage(document, manifest.revision, Number(page[1])) | ||
| 93 | + : group === "places" | ||
| 94 | + ? catalogPath.length === 3 && page !== undefined && page !== null | ||
| 95 | + && isCatalogServicesPage(document, manifest.revision, catalogPath[1], Number(page[1])) | ||
| 96 | + : catalogPath.length === 2 | ||
| 97 | + && isCatalogServiceDocument( | ||
| 98 | + document, | ||
| 99 | + manifest.revision, | ||
| 100 | + catalogPath[1].replace(/\.json$/u, ""), | ||
| 101 | + ); | ||
| 102 | + if (!valid) fail(`${relative(root, path)} does not satisfy the static catalog contract`); | ||
| 85 | catalogJsonCount += 1; | 103 | catalogJsonCount += 1; |
| 86 | } | 104 | } |
| 87 | } | 105 | } |
| @@ -109,19 +127,44 @@ async function verifyServedFiles(local, origin, fetcher) { | |||
| 109 | const path = local.files[next]; | 127 | const path = local.files[next]; |
| 110 | next += 1; | 128 | next += 1; |
| 111 | const url = remoteUrl(origin, local.root, path); | 129 | const url = remoteUrl(origin, local.root, path); |
| 112 | - let response; | 130 | + const expected = readFileSync(path); |
| 113 | try { | 131 | try { |
| 114 | - response = await fetcher(url, { | 132 | + const response = await fetcher(url, { |
| 115 | headers: { Accept: "*/*", "Cache-Control": "no-cache" }, | 133 | headers: { Accept: "*/*", "Cache-Control": "no-cache" }, |
| 116 | redirect: "error", | 134 | redirect: "error", |
| 135 | + signal: AbortSignal.timeout(REMOTE_TIMEOUT_MS), | ||
| 117 | }); | 136 | }); |
| 118 | - } catch { | 137 | + if (!response.ok) fail(`${url.href} returned HTTP ${response.status}`); |
| 119 | - fail(`could not fetch ${url.href}`); | 138 | + const declaredLength = Number(response.headers.get("content-length")); |
| 139 | + if (Number.isFinite(declaredLength) && declaredLength > expected.length) { | ||
| 140 | + fail(`served response is too large for ${url.href}`); | ||
| 141 | + } | ||
| 142 | + if (response.body === null) fail(`served response has no body for ${url.href}`); | ||
| 143 | + const reader = response.body.getReader(); | ||
| 144 | + const chunks = []; | ||
| 145 | + let bytes = 0; | ||
| 146 | + let finished = false; | ||
| 147 | + try { | ||
| 148 | + while (true) { | ||
| 149 | + const { done, value } = await reader.read(); | ||
| 150 | + if (done) { | ||
| 151 | + finished = true; | ||
| 152 | + break; | ||
| 153 | + } | ||
| 154 | + bytes += value.byteLength; | ||
| 155 | + if (bytes > expected.length) fail(`served response is too large for ${url.href}`); | ||
| 156 | + chunks.push(value); | ||
| 157 | + } | ||
| 158 | + } finally { | ||
| 159 | + if (!finished) await reader.cancel(); | ||
| 160 | + reader.releaseLock(); | ||
| 161 | + } | ||
| 162 | + const actual = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), bytes); | ||
| 163 | + if (!actual.equals(expected)) fail(`served bytes differ for ${url.href}`); | ||
| 164 | + } catch (error) { | ||
| 165 | + if (error instanceof Error && error.message.startsWith("verify:config-site failed:")) throw error; | ||
| 166 | + fail(`could not fetch or validate ${url.href}`); | ||
| 120 | } | 167 | } |
| 121 | - if (!response.ok) fail(`${url.href} returned HTTP ${response.status}`); | ||
| 122 | - const expected = readFileSync(path); | ||
| 123 | - const actual = Buffer.from(await response.arrayBuffer()); | ||
| 124 | - if (!actual.equals(expected)) fail(`served bytes differ for ${url.href}`); | ||
| 125 | } | 168 | } |
| 126 | } | 169 | } |
| 127 | await Promise.all(Array.from( | 170 | await Promise.all(Array.from( |