fix(acceptance): 自举线上隔离验收身份

线上验收此前依赖人工准备管理员 Token、专属用户、API Key、参考图片和数据库连接,导致跳过本地验收后仍无法安全、可重复执行。

本提交增加短期 Manager 登录、隔离用户/钱包/API Key 幂等创建、Pod 内脱敏快照导出和合成图片真实上传;敏感材料仅保留在进程或 0600 临时文件中。

验证:bash -n、ShellCheck、验收报告测试、参考图片生成测试、git diff --check。
This commit is contained in:
2026-07-31 23:25:43 +08:00
parent 0b681275ed
commit 709434a256
4 changed files with 343 additions and 21 deletions
+8 -4
View File
@@ -116,10 +116,14 @@ easyai-ai-gateway-cluster-release capacity
## 执行 ## 执行
先把主验收用户、钱包API Key、管理员 Token、两个 Gateway 地址和三张真实小流量参考图 主验收用户、钱包API Key 可以由脚本在隔离验收组中幂等创建;管理员 Token 未提供时,
写入本地私有环境。脚本会以主身份为模板幂等准备其余隔离身份;模型变量可省略,脚本会从 脚本使用本地私有环境中的 `AI_GATEWAY_ONLINE_ACCOUNT/PASSWORD` 登录获取短期 Manager Token。
当前启用候选中选择 Gemini 图片编辑模型,以及 `omni_video.max_images >= 9` 的视频模型 两个 Gateway 地址默认从宁波、香港节点推导,三张真实小流量参考图默认生成 512×512 合成
并优先 Seedance 2.0/fast。 PNG 并通过验收 Key 上传到 Gateway。生产快照默认在已部署的 API Pod 内只读导出,无需把
数据库连接串传到本机。所有自动生成的 Key、Token 和连接信息仅存在于进程或 `0600` 临时
文件中,不写入报告或日志。上述值仍可用 `AI_GATEWAY_ACCEPTANCE_*` 显式覆盖。脚本会以主
身份为模板幂等准备其余隔离身份;模型变量可省略,脚本会从当前启用候选中选择 Gemini 图片
编辑模型,以及 `omni_video.max_images >= 9` 的视频模型并优先 Seedance 2.0/fast。
为保证请求严格各有一半命中宁波和香港,可以把两个 Gateway URL 配成两个节点的 为保证请求严格各有一半命中宁波和香港,可以把两个 Gateway URL 配成两个节点的
`https://<节点IP>`,并设置 `https://<节点IP>`,并设置
@@ -0,0 +1,62 @@
#!/usr/bin/env node
import { lstatSync, writeFileSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { deflateSync } from 'node:zlib';
function crc32(buffer) {
let value = 0xffffffff;
for (const byte of buffer) {
value ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
value = (value >>> 1) ^ (value & 1 ? 0xedb88320 : 0);
}
}
return (value ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const typeBuffer = Buffer.from(type);
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length);
const checksum = Buffer.alloc(4);
checksum.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])));
return Buffer.concat([length, typeBuffer, data, checksum]);
}
function fixture(seed, width = 512, height = 512) {
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header[8] = 8;
header[9] = 2;
const rows = [];
for (let y = 0; y < height; y += 1) {
const row = Buffer.alloc(1 + width * 3);
for (let x = 0; x < width; x += 1) {
row[1 + x * 3] = (x + seed * 53) % 256;
row[2 + x * 3] = (y + seed * 97) % 256;
row[3 + x * 3] = (x + y + seed * 131) % 256;
}
rows.push(row);
}
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk('IHDR', header),
chunk('IDAT', deflateSync(Buffer.concat(rows))),
chunk('IEND', Buffer.alloc(0))
]);
}
const outputDirectory = resolve(process.argv[2] ?? '');
const info = lstatSync(outputDirectory);
if (!info.isDirectory() || info.isSymbolicLink()) {
throw new Error('output directory must be a real directory');
}
for (let index = 1; index <= 3; index += 1) {
writeFileSync(
join(outputDirectory, `production-acceptance-reference-${index}.png`),
fixture(index),
{ mode: 0o600, flag: 'wx' }
);
}
process.stdout.write('acceptance_reference_images=PASS count=3 dimensions=512x512\n');
+251 -17
View File
@@ -30,12 +30,11 @@ with a user-directed waiver. It never records them as passed and preserves all
online simulation, real canary, resource, consistency, and release CAS gates. online simulation, real canary, resource, consistency, and release CAS gates.
Required private environment values: Required private environment values:
AI_GATEWAY_ACCEPTANCE_ADMIN_TOKEN AI_GATEWAY_ACCEPTANCE_ADMIN_TOKEN, or AI_GATEWAY_ONLINE_ACCOUNT/PASSWORD
AI_GATEWAY_ACCEPTANCE_API_KEY
AI_GATEWAY_ACCEPTANCE_API_KEY_ID The dedicated acceptance identity, API Key, two node gateways, synthetic real
AI_GATEWAY_ACCEPTANCE_USER_ID canary images, and production snapshot transport are bootstrapped when their
AI_GATEWAY_ACCEPTANCE_GATEWAYS AI_GATEWAY_ACCEPTANCE_* overrides are omitted.
AI_GATEWAY_ACCEPTANCE_REAL_IMAGE_URLS
Optional model overrides (otherwise selected from current production candidates): Optional model overrides (otherwise selected from current production candidates):
AI_GATEWAY_ACCEPTANCE_GEMINI_MODEL AI_GATEWAY_ACCEPTANCE_GEMINI_MODEL
@@ -76,10 +75,10 @@ fi
load_cluster_env load_cluster_env
require_commands curl git go jq node openssl sed require_commands curl git go jq node openssl sed
: "${AI_GATEWAY_ACCEPTANCE_ADMIN_TOKEN:?}" : "${AI_GATEWAY_ACCEPTANCE_ADMIN_TOKEN:=}"
: "${AI_GATEWAY_ACCEPTANCE_API_KEY:?}" : "${AI_GATEWAY_ACCEPTANCE_API_KEY:=}"
: "${AI_GATEWAY_ACCEPTANCE_API_KEY_ID:?}" : "${AI_GATEWAY_ACCEPTANCE_API_KEY_ID:=}"
: "${AI_GATEWAY_ACCEPTANCE_USER_ID:?}" : "${AI_GATEWAY_ACCEPTANCE_USER_ID:=}"
: "${AI_GATEWAY_ACCEPTANCE_GEMINI_MODEL:=}" : "${AI_GATEWAY_ACCEPTANCE_GEMINI_MODEL:=}"
: "${AI_GATEWAY_ACCEPTANCE_VIDEO_MODEL:=}" : "${AI_GATEWAY_ACCEPTANCE_VIDEO_MODEL:=}"
: "${AI_GATEWAY_ACCEPTANCE_API_DATABASE_MAX_CONNS:=31}" : "${AI_GATEWAY_ACCEPTANCE_API_DATABASE_MAX_CONNS:=31}"
@@ -94,9 +93,17 @@ require_commands curl git go jq node openssl sed
: "${AI_GATEWAY_ACCEPTANCE_SOAK_DURATION:=2h}" : "${AI_GATEWAY_ACCEPTANCE_SOAK_DURATION:=2h}"
: "${AI_GATEWAY_ACCEPTANCE_OVERLOAD_DURATION:=10m}" : "${AI_GATEWAY_ACCEPTANCE_OVERLOAD_DURATION:=10m}"
: "${AI_GATEWAY_ACCEPTANCE_GATEWAY_TLS_SERVER_NAME:=}" : "${AI_GATEWAY_ACCEPTANCE_GATEWAY_TLS_SERVER_NAME:=}"
: "${AI_GATEWAY_ACCEPTANCE_GATEWAYS:?}" : "${AI_GATEWAY_ACCEPTANCE_GATEWAYS:=}"
: "${AI_GATEWAY_ACCEPTANCE_REAL_IMAGE_URLS:?}" : "${AI_GATEWAY_ACCEPTANCE_REAL_IMAGE_URLS:=}"
: "${AI_GATEWAY_ACCEPTANCE_SNAPSHOT_DATABASE_URL:?}" : "${AI_GATEWAY_ACCEPTANCE_SNAPSHOT_DATABASE_URL:=}"
: "${AI_GATEWAY_ONLINE_ACCOUNT:=}"
: "${AI_GATEWAY_ONLINE_PASSWORD:=}"
: "${AI_GATEWAY_ONLINE_BASE_URL:=}"
if [[ -z $AI_GATEWAY_ACCEPTANCE_GATEWAYS ]]; then
AI_GATEWAY_ACCEPTANCE_GATEWAYS="https://${CLUSTER_NINGBO_HOST#root@},https://${CLUSTER_HONGKONG_HOST#root@}"
: "${AI_GATEWAY_DEPLOY_DOMAIN:?}"
AI_GATEWAY_ACCEPTANCE_GATEWAY_TLS_SERVER_NAME=${AI_GATEWAY_ACCEPTANCE_GATEWAY_TLS_SERVER_NAME:-$AI_GATEWAY_DEPLOY_DOMAIN}
fi
[[ $AI_GATEWAY_ACCEPTANCE_API_DATABASE_MAX_CONNS =~ ^[1-9][0-9]*$ && [[ $AI_GATEWAY_ACCEPTANCE_API_DATABASE_MAX_CONNS =~ ^[1-9][0-9]*$ &&
$AI_GATEWAY_ACCEPTANCE_API_DATABASE_MAX_CONNS -le 256 ]] || { $AI_GATEWAY_ACCEPTANCE_API_DATABASE_MAX_CONNS -le 256 ]] || {
echo 'AI_GATEWAY_ACCEPTANCE_API_DATABASE_MAX_CONNS must be between 1 and 256' >&2 echo 'AI_GATEWAY_ACCEPTANCE_API_DATABASE_MAX_CONNS must be between 1 and 256' >&2
@@ -240,6 +247,42 @@ cleanup() {
trap cleanup EXIT trap cleanup EXIT
trap 'failure_gate_id=workflow_interrupted; failure_reason="production acceptance interrupted by signal"; exit 130' HUP INT TERM trap 'failure_gate_id=workflow_interrupted; failure_reason="production acceptance interrupted by signal"; exit 130' HUP INT TERM
bootstrap_acceptance_admin_token() {
if [[ -n $AI_GATEWAY_ACCEPTANCE_ADMIN_TOKEN ]]; then
echo 'acceptance_admin_identity=PASS source=explicit_token'
return 0
fi
[[ -n $AI_GATEWAY_ONLINE_ACCOUNT && -n $AI_GATEWAY_ONLINE_PASSWORD &&
$AI_GATEWAY_ONLINE_BASE_URL == https://* ]] || {
echo 'acceptance admin token or HTTPS online account credentials are required' >&2
return 1
}
local response=$temporary_root/admin-login.json
local body status
body=$(jq -cn \
--arg account "$AI_GATEWAY_ONLINE_ACCOUNT" \
--arg password "$AI_GATEWAY_ONLINE_PASSWORD" \
'{account:$account,password:$password}')
status=$(curl -sS --max-time 15 -o "$response" -w '%{http_code}' \
-H 'Content-Type: application/json' --data-binary "$body" \
"${AI_GATEWAY_ONLINE_BASE_URL%/}/api/v1/auth/login")
chmod 0600 "$response"
[[ $status == 200 &&
$(jq '(.user.role // []) as $roles | if ($roles|type)=="array" then ($roles|index("manager")) != null else $roles == "manager" end' "$response") == true ]] || {
echo "acceptance Manager login failed: status=$status" >&2
return 1
}
AI_GATEWAY_ACCEPTANCE_ADMIN_TOKEN=$(jq -r '.accessToken // empty' "$response")
[[ -n $AI_GATEWAY_ACCEPTANCE_ADMIN_TOKEN ]] || {
echo 'acceptance Manager login did not return an access token' >&2
return 1
}
export AI_GATEWAY_ACCEPTANCE_ADMIN_TOKEN
echo 'acceptance_admin_identity=PASS source=short_lived_manager_login'
}
bootstrap_acceptance_admin_token
( (
cd "$cluster_root/apps/api" cd "$cluster_root/apps/api"
env -u AI_GATEWAY_TEST_DATABASE_URL go build \ env -u AI_GATEWAY_TEST_DATABASE_URL go build \
@@ -247,10 +290,41 @@ trap 'failure_gate_id=workflow_interrupted; failure_reason="production acceptanc
env -u AI_GATEWAY_TEST_DATABASE_URL go build \ env -u AI_GATEWAY_TEST_DATABASE_URL go build \
-trimpath -o "$acceptance_snapshot_binary" ./cmd/acceptance-snapshot -trimpath -o "$acceptance_snapshot_binary" ./cmd/acceptance-snapshot
) )
AI_GATEWAY_DATABASE_URL="$AI_GATEWAY_ACCEPTANCE_SNAPSHOT_DATABASE_URL" \ export_production_snapshot() {
"$acceptance_snapshot_binary" export \ local output=$1
--release-sha "$release_sha" \ if [[ -n $AI_GATEWAY_ACCEPTANCE_SNAPSHOT_DATABASE_URL ]]; then
--output "$current_production_snapshot" >/dev/null AI_GATEWAY_DATABASE_URL="$AI_GATEWAY_ACCEPTANCE_SNAPSHOT_DATABASE_URL" \
"$acceptance_snapshot_binary" export \
--release-sha "$release_sha" \
--output "$output" >/dev/null
else
cluster_ssh "$CLUSTER_NINGBO_HOST" bash -s -- "$release_sha" >"$output" <<'REMOTE'
set -euo pipefail
release_sha=$1
namespace=easyai
pod=$(k3s kubectl get pods -n "$namespace" \
-l 'app.kubernetes.io/name=easyai-api,easyai.io/site=ningbo' \
--field-selector status.phase=Running \
-o 'jsonpath={.items[0].metadata.name}')
[[ $pod == easyai-api-ningbo-* ]]
[[ $(k3s kubectl get pod "$pod" -n "$namespace" \
-o 'jsonpath={.status.containerStatuses[0].ready}') == true ]]
snapshot=/tmp/easyai-production-acceptance-snapshot-$release_sha.json
cleanup() {
k3s kubectl exec -n "$namespace" "$pod" -- rm -f -- "$snapshot" >/dev/null 2>&1 || true
}
trap cleanup EXIT
k3s kubectl exec -n "$namespace" "$pod" -- \
/app/easyai-ai-gateway-acceptance-snapshot export \
--release-sha "$release_sha" --output "$snapshot" >/dev/null
k3s kubectl exec -n "$namespace" "$pod" -- cat "$snapshot"
REMOTE
fi
chmod 0600 "$output"
"$acceptance_snapshot_binary" validate --input "$output" >/dev/null
}
export_production_snapshot "$current_production_snapshot"
if [[ $skip_local_acceptance == true ]]; then if [[ $skip_local_acceptance == true ]]; then
local_acceptance_report=$temporary_root/online-waiver-base.json local_acceptance_report=$temporary_root/online-waiver-base.json
node "$cluster_root/scripts/acceptance/report.mjs" build-online-waiver \ node "$cluster_root/scripts/acceptance/report.mjs" build-online-waiver \
@@ -283,6 +357,164 @@ database_query() {
psql -X -v ON_ERROR_STOP=1 -U postgres -d easyai_ai_gateway -At -c "$sql" psql -X -v ON_ERROR_STOP=1 -U postgres -d easyai_ai_gateway -At -c "$sql"
} }
bootstrap_acceptance_primary_identity() {
if [[ -n $AI_GATEWAY_ACCEPTANCE_API_KEY || -n $AI_GATEWAY_ACCEPTANCE_API_KEY_ID ||
-n $AI_GATEWAY_ACCEPTANCE_USER_ID ]]; then
[[ -n $AI_GATEWAY_ACCEPTANCE_API_KEY &&
$AI_GATEWAY_ACCEPTANCE_API_KEY_ID =~ ^[0-9a-f-]{36}$ &&
$AI_GATEWAY_ACCEPTANCE_USER_ID =~ ^[0-9a-f-]{36}$ ]] || {
echo 'explicit acceptance primary identity variables must be provided together' >&2
return 1
}
echo 'acceptance_primary_identity=PASS source=explicit'
return 0
fi
local material
material=$(database_query "
WITH primary_user AS (
INSERT INTO gateway_users (
user_key, source, username, display_name, roles, auth_profile, metadata, status
)
VALUES (
'production-acceptance-primary', 'gateway', 'production-acceptance-primary',
'Production Acceptance Primary', '[\"user\"]'::jsonb, '{}'::jsonb,
'{\"purpose\":\"production_acceptance\",\"isolated\":true}'::jsonb, 'active'
)
ON CONFLICT (user_key) DO UPDATE
SET source=EXCLUDED.source,
username=EXCLUDED.username,
display_name=EXCLUDED.display_name,
roles=EXCLUDED.roles,
auth_profile=EXCLUDED.auth_profile,
metadata=EXCLUDED.metadata,
status='active',
deleted_at=NULL,
updated_at=now()
RETURNING id
), existing_key AS (
SELECT key.id, key.gateway_user_id, key.key_secret
FROM gateway_api_keys key
JOIN primary_user ON primary_user.id=key.gateway_user_id
WHERE key.name='Production Acceptance Primary'
AND key.status='active'
AND key.deleted_at IS NULL
AND COALESCE(key.key_secret, '') <> ''
ORDER BY key.created_at
LIMIT 1
), new_key_material AS (
SELECT primary_user.id AS gateway_user_id,
'sk-gw-' || encode(gen_random_bytes(32), 'hex') AS secret
FROM primary_user
WHERE NOT EXISTS (SELECT 1 FROM existing_key)
), inserted_key AS (
INSERT INTO gateway_api_keys (
gateway_user_id, user_id, key_prefix, key_secret, key_hash, name, scopes,
rate_limit_policy, quota_policy, metadata, status
)
SELECT material.gateway_user_id,
material.gateway_user_id::text,
left(material.secret, 18),
material.secret,
crypt(material.secret, gen_salt('bf', 10)),
'Production Acceptance Primary',
'[\"chat\",\"embedding\",\"rerank\",\"image\",\"image_vectorize\",\"video\",\"video_enhance\",\"music\",\"audio\",\"voice_clone\"]'::jsonb,
'{}'::jsonb,
'{}'::jsonb,
'{\"purpose\":\"production_acceptance\",\"isolated\":true}'::jsonb,
'active'
FROM new_key_material material
RETURNING id, gateway_user_id, key_secret
), selected_key AS (
SELECT * FROM existing_key
UNION ALL
SELECT * FROM inserted_key
), wallet AS (
INSERT INTO gateway_wallet_accounts (
gateway_user_id, user_id, currency, balance, total_recharged, metadata, status
)
SELECT primary_user.id,
primary_user.id::text,
'resource',
1000000000::numeric,
1000000000::numeric,
'{\"purpose\":\"production_acceptance\",\"isolated\":true}'::jsonb,
'active'
FROM primary_user
ON CONFLICT (gateway_user_id, currency) DO UPDATE
SET balance=GREATEST(gateway_wallet_accounts.balance, EXCLUDED.balance),
total_recharged=GREATEST(gateway_wallet_accounts.total_recharged, EXCLUDED.total_recharged),
metadata=EXCLUDED.metadata,
status='active',
updated_at=now()
RETURNING gateway_user_id
)
SELECT primary_user.id::text || E'\\t' || selected_key.id::text || E'\\t' || selected_key.key_secret
FROM primary_user
CROSS JOIN selected_key
CROSS JOIN (SELECT count(*) FROM wallet) wallet_write_barrier;")
[[ $material == *$'\t'* ]] || {
echo 'acceptance primary identity bootstrap returned an invalid payload' >&2
return 1
}
IFS=$'\t' read -r AI_GATEWAY_ACCEPTANCE_USER_ID \
AI_GATEWAY_ACCEPTANCE_API_KEY_ID AI_GATEWAY_ACCEPTANCE_API_KEY <<<"$material"
[[ $AI_GATEWAY_ACCEPTANCE_USER_ID =~ ^[0-9a-f-]{36}$ &&
$AI_GATEWAY_ACCEPTANCE_API_KEY_ID =~ ^[0-9a-f-]{36}$ &&
$AI_GATEWAY_ACCEPTANCE_API_KEY =~ ^sk-gw-[0-9a-f]{64}$ ]] || {
echo 'acceptance primary identity bootstrap failed validation' >&2
return 1
}
AI_GATEWAY_ACCEPTANCE_API_KEYS=$AI_GATEWAY_ACCEPTANCE_API_KEY
export AI_GATEWAY_ACCEPTANCE_USER_ID AI_GATEWAY_ACCEPTANCE_API_KEY_ID
export AI_GATEWAY_ACCEPTANCE_API_KEY AI_GATEWAY_ACCEPTANCE_API_KEYS
echo 'acceptance_primary_identity=PASS source=idempotent_database_bootstrap isolated=true'
}
ensure_acceptance_real_images() {
if [[ -n $AI_GATEWAY_ACCEPTANCE_REAL_IMAGE_URLS ]]; then
[[ $(awk -F',' '{print NF}' <<<"$AI_GATEWAY_ACCEPTANCE_REAL_IMAGE_URLS") -ge 3 ]] || {
echo 'explicit acceptance real image URLs must contain at least three entries' >&2
return 1
}
echo 'acceptance_real_images=PASS source=explicit count=3_or_more'
return 0
fi
[[ $AI_GATEWAY_ONLINE_BASE_URL == https://* ]] || {
echo 'HTTPS online base URL is required to upload synthetic real canary images' >&2
return 1
}
local fixture_root=$temporary_root/real-canary-images
local response url index
local -a urls=()
install -d -m 0700 "$fixture_root"
node "$cluster_root/scripts/acceptance/generate-reference-images.mjs" "$fixture_root" >/dev/null
for index in 1 2 3; do
response=$fixture_root/upload-$index.json
chmod 0600 "$fixture_root/production-acceptance-reference-$index.png"
{
printf 'silent\nshow-error\nfail-with-body\nmax-time = 120\n'
printf 'header = "Authorization: Bearer %s"\n' "$AI_GATEWAY_ACCEPTANCE_API_KEY"
printf 'form = "source=production-acceptance"\n'
printf 'form = "file=@%s;type=image/png"\n' \
"$fixture_root/production-acceptance-reference-$index.png"
printf 'url = "%s/api/v1/files/upload"\n' "${AI_GATEWAY_ONLINE_BASE_URL%/}"
} | curl --config - >"$response"
chmod 0600 "$response"
url=$(jq -r '.url // .data.url // empty' "$response")
if [[ $url == /* ]]; then
url="${AI_GATEWAY_ONLINE_BASE_URL%/}$url"
fi
[[ $url == https://* ]] || {
echo "synthetic acceptance image upload $index did not return an HTTPS URL" >&2
return 1
}
urls+=("$url")
done
AI_GATEWAY_ACCEPTANCE_REAL_IMAGE_URLS=$(IFS=,; printf '%s' "${urls[*]}")
export AI_GATEWAY_ACCEPTANCE_REAL_IMAGE_URLS
echo 'acceptance_real_images=PASS source=synthetic_gateway_upload count=3 dimensions=512x512'
}
admin_request() { admin_request() {
local method=$1 local method=$1
local path=$2 local path=$2
@@ -2646,8 +2878,10 @@ WHERE task.acceptance_run_id='$run_id'::uuid
} }
verify_release_cas verify_release_cas
bootstrap_acceptance_primary_identity
ensure_acceptance_user_group ensure_acceptance_user_group
ensure_acceptance_identity_shards ensure_acceptance_identity_shards
ensure_acceptance_real_images
select_acceptance_models select_acceptance_models
ensure_acceptance_model_access ensure_acceptance_model_access
create_and_activate_run create_and_activate_run
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
const root = resolve(import.meta.dirname, '../..');
const output = mkdtempSync(join(tmpdir(), 'easyai-reference-images-'));
execFileSync('node', [join(root, 'scripts/acceptance/generate-reference-images.mjs'), output]);
const hashes = new Set();
for (let index = 1; index <= 3; index += 1) {
const path = join(output, `production-acceptance-reference-${index}.png`);
const payload = readFileSync(path);
assert.deepEqual([...payload.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]);
assert.equal(payload.readUInt32BE(16), 512);
assert.equal(payload.readUInt32BE(20), 512);
assert.equal(statSync(path).mode & 0o777, 0o600);
hashes.add(createHash('sha256').update(payload).digest('hex'));
}
assert.equal(hashes.size, 3);