mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
test(release): exercise exact published RC through Tailscale browser
Reuse the signed-install lifecycle assertions on a fresh public hosted VM without Docker identity overrides. Bind immutable release assets/control, observe one real UI apply/SSE/modal journey, retain sanitised terminal evidence and stop access refusals without reauthentication. Update the deployment contract and execute its recovery-access guard; release judgment and gates are unchanged. Change-source: pulse-maintainer
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
name: Qualify Published RC Browser Update
|
||||
run-name: Browser update ${{ inputs.tag }} at ${{ inputs.expected_workflow_sha }}
|
||||
|
||||
# Control runs only from reviewed public main. No source build, production
|
||||
# target, publishing credential, old stopped-target retry or arbitrary URL.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Exact published immutable vX.Y.Z-rc.N'
|
||||
required: true
|
||||
type: string
|
||||
expected_workflow_sha:
|
||||
description: 'Exact reviewed main control SHA'
|
||||
required: true
|
||||
type: string
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: browser-release-${{ inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
journey:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- name: Checkout reviewed control without credentials
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Bind control and exact published release pair
|
||||
id: pair
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
EXPECTED_CONTROL: ${{ inputs.expected_workflow_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$GITHUB_REPOSITORY" = rcourtman/Pulse
|
||||
test "$GITHUB_REF" = refs/heads/main
|
||||
test "$GITHUB_WORKFLOW_SHA" = "$EXPECTED_CONTROL"
|
||||
test "$GITHUB_SHA" = "$EXPECTED_CONTROL"
|
||||
test "$(git rev-parse HEAD)" = "$EXPECTED_CONTROL"
|
||||
[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[1-9][0-9]*$ ]]
|
||||
mkdir -m 0700 "$RUNNER_TEMP/browser-release"
|
||||
gh api repos/rcourtman/Pulse/releases/latest > "$RUNNER_TEMP/browser-release/baseline.json"
|
||||
gh api "repos/rcourtman/Pulse/releases/tags/$TAG" > "$RUNNER_TEMP/browser-release/candidate.json"
|
||||
from=$(jq -er '.tag_name | select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+$"))' "$RUNNER_TEMP/browser-release/baseline.json")
|
||||
peel() {
|
||||
local object type sha
|
||||
object=$(gh api "repos/rcourtman/Pulse/git/ref/tags/$1")
|
||||
type=$(jq -er '.object.type' <<<"$object")
|
||||
sha=$(jq -er '.object.sha' <<<"$object")
|
||||
if [[ "$type" == tag ]]; then
|
||||
object=$(gh api "repos/rcourtman/Pulse/git/tags/$sha")
|
||||
test "$(jq -er '.tag' <<<"$object")" = "$1"
|
||||
type=$(jq -er '.object.type' <<<"$object")
|
||||
sha=$(jq -er '.object.sha' <<<"$object")
|
||||
fi
|
||||
test "$type" = commit
|
||||
[[ "$sha" =~ ^[0-9a-f]{40}$ ]]
|
||||
printf '%s' "$sha"
|
||||
}
|
||||
from_commit=$(peel "$from")
|
||||
to_commit=$(peel "$TAG")
|
||||
python3 scripts/release_browser_packet.py \
|
||||
--baseline "$RUNNER_TEMP/browser-release/baseline.json" \
|
||||
--candidate "$RUNNER_TEMP/browser-release/candidate.json" \
|
||||
--from-commit "$from_commit" --to-commit "$to_commit" --tag "$TAG" \
|
||||
--output "$RUNNER_TEMP/browser-release/packet.json"
|
||||
python3 -c 'import json,os,pathlib; keys=("GITHUB_WORKFLOW_SHA","GITHUB_SHA","GITHUB_RUN_ID","GITHUB_RUN_ATTEMPT"); pathlib.Path(os.environ["RUNNER_TEMP"]+"/browser-release/control.json").write_text(json.dumps({k:os.environ[k] for k in keys},indent=2)+"\n")'
|
||||
echo "from=$from" >> "$GITHUB_OUTPUT"
|
||||
echo "from_commit=$from_commit" >> "$GITHUB_OUTPUT"
|
||||
echo "to_commit=$to_commit" >> "$GITHUB_OUTPUT"
|
||||
- name: Authenticate both release archives before execution
|
||||
env:
|
||||
FROM: ${{ steps.pair.outputs.from }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
FROM_COMMIT: ${{ steps.pair.outputs.from_commit }}
|
||||
TO_COMMIT: ${{ steps.pair.outputs.to_commit }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
root="$RUNNER_TEMP/browser-release"
|
||||
key=$(grep -oE 'ssh-ed25519 [A-Za-z0-9+/=]+ pulse-installer' README.md | head -1)
|
||||
test -n "$key"
|
||||
printf 'pulse-installer %s\n' "$key" > "$root/allowed_signers"
|
||||
for tag in "$FROM" "$TAG"; do
|
||||
mkdir -m 0700 "$root/$tag"
|
||||
name="pulse-$tag-linux-amd64.tar.gz"
|
||||
for file in "$name" "$name.sshsig"; do
|
||||
curl --fail --silent --show-error --location --retry 0 --max-time 120 \
|
||||
"https://github.com/rcourtman/Pulse/releases/download/$tag/$file" --output "$root/$tag/$file"
|
||||
done
|
||||
ssh-keygen -Y verify -f "$root/allowed_signers" -I pulse-installer -n pulse-install \
|
||||
-s "$root/$tag/$name.sshsig" < "$root/$tag/$name"
|
||||
done
|
||||
python3 scripts/release_browser_packet.py --baseline "$root/baseline.json" --candidate "$root/candidate.json" \
|
||||
--from-commit "$FROM_COMMIT" --to-commit "$TO_COMMIT" --tag "$TAG" \
|
||||
--archives "$root" --output "$root/packet.json"
|
||||
- name: Prepare pinned browser without lifecycle hooks
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '24.20.0'
|
||||
- name: Prepare Chromium and native tools
|
||||
env:
|
||||
PLAYWRIGHT_BROWSERS_PATH: ${{ runner.temp }}/browser-release/browsers
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd tests/integration
|
||||
npm ci --ignore-scripts
|
||||
npx --no-install playwright install --with-deps chromium
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends jq curl openssh-client
|
||||
- name: Register a fresh ephemeral project CI node
|
||||
uses: tailscale/github-action@780049a30b6ff5c378a9e7b389d15ece7a204888 # v4.1.3
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||
tags: tag:infra
|
||||
version: '1.94.2'
|
||||
- name: Serve only this disposable install
|
||||
id: serve
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Never enumerate peer/private targets or use a supplied hostname.
|
||||
origin=$(tailscale status --json | python3 -c 'import json,re,sys; n=json.load(sys.stdin)["Self"]["DNSName"].rstrip("."); assert re.fullmatch(r"[a-z0-9-]+\.tawny-powan\.ts\.net", n); print("https://"+n)')
|
||||
tailscale serve --bg --https=443 http://127.0.0.1:7655
|
||||
echo "origin=$origin" >> "$GITHUB_OUTPUT"
|
||||
- name: Exercise native browser update and documented recovery
|
||||
env:
|
||||
FROM: ${{ steps.pair.outputs.from }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
ORIGIN: ${{ steps.serve.outputs.origin }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo env -i PATH=/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LANG=C.UTF-8 \
|
||||
GITHUB_ACTIONS=true RUNNER_ENVIRONMENT=github-hosted GITHUB_REPOSITORY=rcourtman/Pulse GITHUB_REF=refs/heads/main \
|
||||
PULSE_REHEARSAL_WORKDIR="$RUNNER_TEMP/browser-release/journey" \
|
||||
PULSE_REHEARSAL_BROWSER_ORIGIN="$ORIGIN" PULSE_REHEARSAL_NODE="$(command -v node)" \
|
||||
PULSE_REHEARSAL_PACKET_MANIFEST="$RUNNER_TEMP/browser-release/packet.json" \
|
||||
PLAYWRIGHT_BROWSERS_PATH="$RUNNER_TEMP/browser-release/browsers" \
|
||||
bash scripts/release_lifecycle_rehearsal.sh --hosted-browser --from "$FROM" --to "$TAG"
|
||||
- name: Close ephemeral Serve route
|
||||
if: always() && steps.serve.outcome == 'success'
|
||||
run: tailscale serve reset
|
||||
- name: Retain bounded identity and terminal receipts, never credentials
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: browser-release-${{ github.run_id }}
|
||||
path: |
|
||||
${{ runner.temp }}/browser-release/control.json
|
||||
${{ runner.temp }}/browser-release/packet.json
|
||||
${{ runner.temp }}/browser-release/journey/state/browser-upgrade.json
|
||||
${{ runner.temp }}/browser-release/journey/state/browser-recovery.json
|
||||
${{ runner.temp }}/browser-release/journey/state/browser-cleanup.json
|
||||
${{ runner.temp }}/browser-release/journey/state/lifecycle-result.json
|
||||
${{ runner.temp }}/browser-release/journey/state/installer.json
|
||||
${{ runner.temp }}/browser-release/journey/state/browser-*.png
|
||||
${{ runner.temp }}/browser-release/journey/state/installed-*.json
|
||||
${{ runner.temp }}/browser-release/journey/state/settings.*.json
|
||||
${{ runner.temp }}/browser-release/journey/state/auto-updates.*.tsv
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
@@ -6360,7 +6360,7 @@ defaulted and nothing newer is published, the pair is the previous stable to
|
||||
the latest stable. An explicit pair must be published and move forward, so a
|
||||
same-version or missing-target run fails instead of passing.
|
||||
|
||||
`scripts/release_lifecycle_rehearsal.sh` runs every phase inside the same
|
||||
`scripts/release_lifecycle_rehearsal.sh` defaults to running every phase inside the same
|
||||
digest-pinned `jrei/systemd-debian:12` container as the install smoke.
|
||||
|
||||
1. Install FROM with that release's own `install.sh --version`, after the
|
||||
@@ -6419,6 +6419,67 @@ passed all four phases. An earlier run correctly failed phase 1 on a new failed
|
||||
unit (`systemd-hostnamed`), which led to the container-only list above. The
|
||||
hosted Docker run is not yet claimed.
|
||||
|
||||
### Exact published-RC Tailscale browser acceptance
|
||||
|
||||
`qualify-browser-update-release.yml` uses the lifecycle harness's separate
|
||||
`--hosted-browser` mode on a **fresh public hosted Ubuntu 24.04 VM**. The shadow
|
||||
container mode above remains unchanged in purpose. A real native install is
|
||||
required for browser updates: Docker detection stays truthful, with no marker
|
||||
removal, mock updater, TLS bypass or `PULSE_ALLOW_DOCKER_UPDATES` override.
|
||||
|
||||
Control is reviewed `main`, bound to `expected_workflow_sha`, its checkout and
|
||||
`GITHUB_WORKFLOW_SHA`. Product inputs are only the latest published immutable
|
||||
stable and the requested published immutable RC. Peeled commit, release/asset
|
||||
IDs, sizes and SHA-256 digests are checked by `release_browser_packet.py`; SSH
|
||||
signatures verify before execution. Each installed binary hash must match its
|
||||
signed archive, rather than just a reported version. No rebuilt main binary,
|
||||
frozen-product insertion, candidate re-cut or stable promotion is performed.
|
||||
|
||||
A pinned Tailscale action registers an ephemeral project CI node; Serve exposes
|
||||
only its own loopback Pulse install through its derived `tawny-powan.ts.net`
|
||||
HTTPS identity. No caller-selected endpoint, peer or previous stopped target is
|
||||
used. Registration alone receives the existing CI OAuth credential; acceptance
|
||||
commands receive a scrubbed environment with no provider, production or
|
||||
publishing credential. State is synthetic, disabled resources and throwaway
|
||||
admin/API authentication, with the existing telemetry opt-out.
|
||||
|
||||
The real Chromium UI signs in once, chooses Preview, checks the actual feed's
|
||||
exact RC/archive, and confirms one Install Update. The observer requires real
|
||||
EventSource open/progress, exact served version, healthy status, authenticated
|
||||
readiness and progress-modal completion; wrong selection, silent progress,
|
||||
rejected apply or a stuck modal fails. API interception and fabricated version
|
||||
or progress are not acceptance. HTTP401/403 stops browser access, including
|
||||
skipping a recovery login after recorded refusal; no alternate identity or apply
|
||||
replay is used.
|
||||
|
||||
The same harness checks systemd, the seeded settings/encrypted stores and the
|
||||
original auto-update/timer intent after forward update and documented CLI
|
||||
`/bin/update --version FROM` recovery. Browser authentication/health readback
|
||||
then checks the restored baseline. This is CLI recovery plus browser readback,
|
||||
**not** the separate history-based UI rollback action. Cleanup stops the
|
||||
throwaway service/timer, records its result and fails the process on failure;
|
||||
the workflow resets the new Serve route and its hosted VM is disposed of.
|
||||
|
||||
Bounded control, signed packet, installer/binary, phase, browser, cleanup and
|
||||
redacted settings receipts are retained for seven days. Successful screenshots
|
||||
contain only synthetic state. Auth files, bootstrap/API/node tokens, encrypted
|
||||
file manifests and raw installer/updater/service logs are not artifacts.
|
||||
Delivery must reconcile terminal logs and all receipts with the exact RC;
|
||||
a workflow conclusion alone is insufficient. Existing qualification, soak,
|
||||
release-steward judgment and publication boundaries remain unchanged. The
|
||||
previous target's undiagnosed HTTP401 is not diagnosed or cleared by this mode.
|
||||
|
||||
Verification: `scripts/tests/test_release_browser_packet.py` executes immutable
|
||||
release/asset/binary identity rejection, worker-mode exclusion, scrubbed command
|
||||
environment and cleanup exit controls. `test_release_lifecycle_rehearsal.py`
|
||||
executes the recovery-access stop before another browser process can start.
|
||||
`tests/integration/scripts/release-browser-journey.test.cjs` exercises the real
|
||||
observer against offline Chromium success/recovery and login401, wrong-target,
|
||||
silent-SSE, stuck-modal and authenticated-read401 fixtures. These are observer
|
||||
and tooling proofs, **not native Tailscale/update/recovery acceptance**. That
|
||||
requires reviewed publication, infrastructure activation and a terminal hosted
|
||||
collection through the fixed Delivery dispatcher.
|
||||
|
||||
### Release-line dependency advisory watch (1 October 2026)
|
||||
|
||||
On 30 September and 1 October 2026 `GHSA-q2hr-2g5m-vwhr` (`brace-expansion`)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bind a hosted browser journey to two exact published, signed release assets.
|
||||
|
||||
Reads local metadata and archives; performs no network, extraction or execution.
|
||||
The workflow separately verifies SSH signatures before --archives is supplied.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tarfile
|
||||
|
||||
|
||||
def release_identity(release, *, tag, commit, prerelease):
|
||||
pattern = r'v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)'
|
||||
if prerelease:
|
||||
pattern += r'-rc\.[1-9][0-9]*'
|
||||
if (not re.fullmatch(pattern, tag) or not re.fullmatch(r'[0-9a-f]{40}', commit)
|
||||
or release.get('tag_name') != tag or release.get('target_commitish') != commit
|
||||
or release.get('draft') is not False or release.get('prerelease') is not prerelease
|
||||
or release.get('immutable') is not True or not release.get('published_at')
|
||||
or type(release.get('id')) is not int or release['id'] <= 0):
|
||||
raise ValueError('Exact published immutable release/tag identity required')
|
||||
asset_name = f'pulse-{tag}-linux-amd64.tar.gz'
|
||||
assets = []
|
||||
for name in (asset_name, asset_name + '.sshsig'):
|
||||
matches = [a for a in release.get('assets', []) if a.get('name') == name]
|
||||
if len(matches) != 1:
|
||||
raise ValueError('Missing or ambiguous release asset')
|
||||
asset = matches[0]
|
||||
if (asset.get('browser_download_url') != f'https://github.com/rcourtman/Pulse/releases/download/{tag}/{name}'
|
||||
or type(asset.get('id')) is not int or asset['id'] <= 0
|
||||
or type(asset.get('size')) is not int or not 0 < asset['size'] <= 256 * 1024 * 1024
|
||||
or not re.fullmatch(r'sha256:[0-9a-f]{64}', str(asset.get('digest')))):
|
||||
raise ValueError('Unsafe or unbound release asset')
|
||||
assets.append({k: asset[k] for k in ['name', 'id', 'size', 'digest']})
|
||||
return {'tag': tag, 'release_id': release['id'], 'commit': commit, 'assets': assets}
|
||||
|
||||
|
||||
def file_sha256(path):
|
||||
with path.open('rb') as stream:
|
||||
return hashlib.file_digest(stream, 'sha256').hexdigest()
|
||||
|
||||
|
||||
def archive_identity(path):
|
||||
# Inspect only one bounded regular binary; do not extract the package.
|
||||
with tarfile.open(path, 'r:gz') as archive:
|
||||
matches = []
|
||||
total_size = 0
|
||||
for i, member in enumerate(archive):
|
||||
total_size += member.size
|
||||
if total_size > 1024 * 1024 * 1024:
|
||||
raise ValueError('Oversized expanded release archive')
|
||||
if i >= 4096:
|
||||
raise ValueError('Oversized release archive inventory')
|
||||
if member.name in ('./bin/pulse', 'bin/pulse'):
|
||||
matches.append(member)
|
||||
if len(matches) != 1 or not matches[0].isfile() or not 0 < matches[0].size <= 256 * 1024 * 1024:
|
||||
raise ValueError('Missing or ambiguous regular server binary')
|
||||
stream = archive.extractfile(matches[0])
|
||||
return hashlib.file_digest(stream, 'sha256').hexdigest()
|
||||
|
||||
|
||||
def packet(baseline, candidate, from_commit, to_commit, tag, archives=None):
|
||||
stable_tag = baseline.get('tag_name', '')
|
||||
releases = [release_identity(baseline, tag=stable_tag, commit=from_commit, prerelease=False),
|
||||
release_identity(candidate, tag=tag, commit=to_commit, prerelease=True)]
|
||||
if tuple(map(int, stable_tag[1:].split('.'))) >= tuple(map(int, tag[1:].split('-')[0].split('.'))):
|
||||
raise ValueError('The candidate must be newer than its published stable baseline')
|
||||
if archives is not None:
|
||||
for release in releases:
|
||||
for asset in release['assets']:
|
||||
p = archives / release['tag'] / asset['name']
|
||||
if p.is_symlink() or not p.is_file() or p.stat().st_size != asset['size']:
|
||||
raise ValueError('Release asset size/type differs from live identity')
|
||||
if file_sha256(p) != asset['digest'].split(':')[1]:
|
||||
raise ValueError('Release asset digest differs from live identity')
|
||||
release['binary_sha256'] = archive_identity(archives / release['tag'] / release['assets'][0]['name'])
|
||||
return {'schema_version': 1, 'repository': 'rcourtman/Pulse', 'releases': releases}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--baseline', required=True, type=Path)
|
||||
parser.add_argument('--candidate', required=True, type=Path)
|
||||
parser.add_argument('--from-commit', required=True)
|
||||
parser.add_argument('--to-commit', required=True)
|
||||
parser.add_argument('--tag', required=True)
|
||||
parser.add_argument('--archives', type=Path)
|
||||
parser.add_argument('--output', required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
result = packet(json.loads(args.baseline.read_text()), json.loads(args.candidate.read_text()),
|
||||
args.from_commit, args.to_commit, args.tag, args.archives)
|
||||
args.output.write_text(json.dumps(result, indent=2) + '\n')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,7 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Release lifecycle rehearsal: install, upgrade and roll back Pulse between two
|
||||
# PUBLISHED releases on a real systemd host (a privileged systemd container).
|
||||
# PUBLISHED releases on a real systemd host. Default: shadow systemd container.
|
||||
# --hosted-browser: fresh public GitHub VM, authenticated Tailscale Serve UI
|
||||
# update/SSE, documented CLI recovery and browser/session readback.
|
||||
#
|
||||
# 1. install FROM with that release's signature-verified install.sh --version
|
||||
# 2. assert FROM identity, health and unit state
|
||||
@@ -49,10 +51,12 @@ SEED_ADMIN_USER="rehearsal-admin"
|
||||
|
||||
FROM_TAG=""
|
||||
TO_TAG=""
|
||||
HOSTED_BROWSER=false
|
||||
CHECK_SNAPSHOT=""
|
||||
COMPARE_DATADIR=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--hosted-browser) HOSTED_BROWSER=true; shift ;;
|
||||
--from) FROM_TAG="${2:-}"; shift 2 ;;
|
||||
--to) TO_TAG="${2:-}"; shift 2 ;;
|
||||
# Offline self-tests used by the contract tests: apply the fixed
|
||||
@@ -78,6 +82,30 @@ if [[ -z "$CHECK_SNAPSHOT" && ${#COMPARE_DATADIR[@]} -eq 0 ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# The browser route is a real first-level public CI host, not a container
|
||||
# overriding Docker detection. It must never install onto a maintainer host.
|
||||
if [[ "$HOSTED_BROWSER" == true ]]; then
|
||||
if [[ "$EUID" != 0 || "${GITHUB_ACTIONS:-}" != true \
|
||||
|| "${RUNNER_ENVIRONMENT:-}" != github-hosted \
|
||||
|| "${GITHUB_REPOSITORY:-}" != rcourtman/Pulse \
|
||||
|| "${GITHUB_REF:-}" != refs/heads/main \
|
||||
|| -e /opt/pulse || -e /etc/pulse \
|
||||
|| -e /etc/systemd/system/pulse.service || -e /rehearsal ]]; then
|
||||
echo "::error::Browser lifecycle requires a fresh disposable public hosted runner."
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -f "${PULSE_REHEARSAL_PACKET_MANIFEST:-}" ]]; then
|
||||
echo "::error::A verified immutable release asset manifest is required."
|
||||
exit 2
|
||||
fi
|
||||
# The workflow derives this from the new runner's own Tailscale identity.
|
||||
# No caller endpoint, old stopped target or TLS-verification bypass exists.
|
||||
if [[ ! "${PULSE_REHEARSAL_BROWSER_ORIGIN:-}" =~ ^https://[a-z0-9-]+\.tawny-powan\.ts\.net$ ]]; then
|
||||
echo "::error::Browser lifecycle requires the ephemeral runner's HTTPS Serve origin."
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
REPO="${PULSE_REHEARSAL_REPO:-rcourtman/Pulse}"
|
||||
if [[ ! "$REPO" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
|
||||
echo "::error::invalid PULSE_REHEARSAL_REPO '${REPO}'" >&2
|
||||
@@ -126,11 +154,22 @@ cexec() {
|
||||
for pair in "$@"; do
|
||||
env_args+=(-e "$pair")
|
||||
done
|
||||
"$ENGINE" exec "${env_args[@]}" "$CONTAINER" bash -c "$program"
|
||||
if [[ "$HOSTED_BROWSER" == true ]]; then
|
||||
# Never pass workflow, GitHub or tailnet-registration credentials to
|
||||
# the installed application or an acceptance command.
|
||||
env -i PATH=/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LANG=C.UTF-8 \
|
||||
"${@}" bash -c "$program"
|
||||
else
|
||||
"$ENGINE" exec "${env_args[@]}" "$CONTAINER" bash -c "$program"
|
||||
fi
|
||||
}
|
||||
|
||||
print_diagnostics() {
|
||||
[[ "$CONTAINER_STARTED" == "true" ]] || return 0
|
||||
if [[ "$HOSTED_BROWSER" == true ]]; then
|
||||
echo "::error::Hosted lifecycle failed; inspect bounded browser/identity receipts. No raw credentials or journal retained."
|
||||
return
|
||||
fi
|
||||
echo "::group::Diagnostics: pulse journal (tail)"
|
||||
cexec 'journalctl -u pulse --no-pager | tail -n 150' || true
|
||||
echo "::endgroup::"
|
||||
@@ -155,8 +194,28 @@ cleanup() {
|
||||
print_diagnostics
|
||||
fi
|
||||
if [[ "$CONTAINER_STARTED" == "true" ]]; then
|
||||
"$ENGINE" rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
||||
if [[ "$HOSTED_BROWSER" == true ]]; then
|
||||
local cleanup_status=0
|
||||
systemctl stop pulse.service || cleanup_status=1
|
||||
if [[ "$(systemctl show pulse-update.timer --property=LoadState --value)" != not-found ]]; then
|
||||
systemctl stop pulse-update.timer || cleanup_status=1
|
||||
fi
|
||||
if systemctl is-active --quiet pulse.service; then cleanup_status=1; fi
|
||||
if systemctl is-active --quiet pulse-update.timer; then cleanup_status=1; fi
|
||||
printf '{"cleanup_exit":%s,"host_disposal":"github-hosted-job"}\n' "$cleanup_status" > "${WORK_DIR}/state/browser-cleanup.json"
|
||||
[[ "$cleanup_status" == 0 ]] || status=1
|
||||
else
|
||||
"$ENGINE" rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
if [[ "$HOSTED_BROWSER" == true ]]; then
|
||||
# Ephemeral application credentials are never retained as evidence.
|
||||
if [[ -f "${WORK_DIR}/state/browser-auth.json" ]]; then
|
||||
: > "${WORK_DIR}/state/browser-auth.json"
|
||||
fi
|
||||
fi
|
||||
trap - EXIT
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
@@ -165,7 +224,11 @@ write_summary() {
|
||||
table=$(
|
||||
echo "## Release lifecycle rehearsal"
|
||||
echo
|
||||
echo "\`${FROM_TAG}\` -> \`${TO_TAG}\` -> \`${FROM_TAG}\` on a systemd host (${SYSTEMD_IMAGE%%@*}). Shadow run, not a release gate."
|
||||
if [[ "$HOSTED_BROWSER" == true ]]; then
|
||||
echo "\`${FROM_TAG}\` -> \`${TO_TAG}\` -> \`${FROM_TAG}\` on a fresh public hosted VM with HTTPS Tailscale Serve. Browser acceptance evidence, not a stable promotion decision."
|
||||
else
|
||||
echo "\`${FROM_TAG}\` -> \`${TO_TAG}\` -> \`${FROM_TAG}\` on a systemd host (${SYSTEMD_IMAGE%%@*}). Shadow run, not a release gate."
|
||||
fi
|
||||
echo
|
||||
echo "| Phase | Expected | Reported version | Health | Settings | Data dir | Units | Result |"
|
||||
echo "| --- | --- | --- | --- | --- | --- | --- | --- |"
|
||||
@@ -182,6 +245,12 @@ write_summary() {
|
||||
done
|
||||
fi
|
||||
)
|
||||
if [[ "$HOSTED_BROWSER" == true ]]; then
|
||||
jq -cn --argjson overall_status "$OVERALL_STATUS" \
|
||||
--argjson phases "$(printf '%s\n' "${PHASE_ROWS[@]}" | jq -Rsc 'split("\n") | map(select(length > 0))')" \
|
||||
'{schema_version:1, overall_status:$overall_status, phases:$phases}' \
|
||||
> "${WORK_DIR}/state/lifecycle-result.json"
|
||||
fi
|
||||
echo
|
||||
echo "$table"
|
||||
if [[ -n "$SUMMARY_FILE" ]]; then
|
||||
@@ -216,8 +285,10 @@ fetch_and_verify_installer() {
|
||||
local dest="${WORK_DIR}/assets"
|
||||
local base="https://github.com/${REPO}/releases/download/${tag}"
|
||||
local asset
|
||||
local -a retry_args=(--retry 5 --retry-delay 5 --retry-all-errors)
|
||||
if [[ "$HOSTED_BROWSER" == true ]]; then retry_args=(--retry 0 --max-time 120); fi
|
||||
for asset in install.sh install.sh.sshsig; do
|
||||
if ! curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors \
|
||||
if ! curl -fsSL "${retry_args[@]}" \
|
||||
-o "${dest}/${asset}" "${base}/${asset}"; then
|
||||
echo "::error::could not download ${base}/${asset}"
|
||||
return 1
|
||||
@@ -246,6 +317,13 @@ fetch_and_verify_installer() {
|
||||
echo "::error::${tag} install.sh is not the Pulse server installer with --version support"
|
||||
return 1
|
||||
fi
|
||||
if [[ "$HOSTED_BROWSER" == true ]]; then
|
||||
jq -cn --arg tag "$tag" \
|
||||
--arg installer_sha256 "$(sha256sum "${dest}/install.sh" | cut -d' ' -f1)" \
|
||||
--arg signature_sha256 "$(sha256sum "${dest}/install.sh.sshsig" | cut -d' ' -f1)" \
|
||||
'{tag:$tag,installer_sha256:$installer_sha256,signature_sha256:$signature_sha256,signature_verified:true}' \
|
||||
> "${WORK_DIR}/state/installer.json"
|
||||
fi
|
||||
log "${tag} install.sh signature verifies against the README-pinned key"
|
||||
}
|
||||
|
||||
@@ -254,6 +332,14 @@ fetch_and_verify_installer() {
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
start_container() {
|
||||
if [[ "$HOSTED_BROWSER" == true ]]; then
|
||||
CONTAINER_STARTED=true
|
||||
BASELINE_FAILED_UNITS=$(cexec 'systemctl list-units --state=failed --no-legend --plain | awk "{print \$1}" | sort')
|
||||
# Keep the same telemetry opt-out, without changing deployment identity,
|
||||
# enabling Docker updates, mocking feeds or weakening authentication.
|
||||
cexec 'mkdir -p /etc/systemd/system/pulse.service.d && printf "[Service]\nEnvironment=PULSE_TELEMETRY=false\n" > /etc/systemd/system/pulse.service.d/50-rehearsal-no-telemetry.conf'
|
||||
return
|
||||
fi
|
||||
local -a run_args=(run -d --name "$CONTAINER" --privileged
|
||||
-v "${WORK_DIR}/assets:/rehearsal:ro")
|
||||
if [[ "$ENGINE" == "docker" ]]; then
|
||||
@@ -349,6 +435,15 @@ assert_runtime() {
|
||||
note_failure "/opt/pulse/bin/pulse --version reported '${binary_version:-none}', expected ${expected_tag}"
|
||||
fi
|
||||
|
||||
if [[ "$HOSTED_BROWSER" == true ]]; then
|
||||
local observed_digest expected_digest
|
||||
observed_digest=$(cexec 'sha256sum /opt/pulse/bin/pulse | cut -d" " -f1')
|
||||
expected_digest=$(jq -er --arg tag "$expected_tag" '.releases[] | select(.tag == $tag) | .binary_sha256' "$PULSE_REHEARSAL_PACKET_MANIFEST")
|
||||
printf '{"tag":"%s","binary_sha256":"%s","expected_binary_sha256":"%s"}\n' \
|
||||
"$expected_tag" "$observed_digest" "$expected_digest" > "${WORK_DIR}/state/installed-${expected_tag}.json"
|
||||
[[ "$observed_digest" == "$expected_digest" ]] || note_failure "Installed binary differs from the verified published archive"
|
||||
fi
|
||||
|
||||
local enabled failed_now new_failed
|
||||
enabled=$(cexec 'systemctl is-enabled pulse' 2>/dev/null || true)
|
||||
failed_now=$(cexec 'systemctl list-units --state=failed --no-legend --plain | awk "{print \$1}" | sort' 2>/dev/null || true)
|
||||
@@ -605,12 +700,27 @@ check_settings() {
|
||||
# Phases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
install_baseline() {
|
||||
if [[ "$HOSTED_BROWSER" == true ]]; then
|
||||
cexec 'bash /rehearsal/install.sh --version "$FROM_TAG"' "FROM_TAG=${FROM_TAG}" \
|
||||
> "${WORK_DIR}/state/private-install.log" 2>&1
|
||||
else
|
||||
cexec 'bash /rehearsal/install.sh --version "$FROM_TAG"' "FROM_TAG=${FROM_TAG}" \
|
||||
2>&1 | tee "${WORK_DIR}/state/install-${FROM_TAG}.log"
|
||||
fi
|
||||
}
|
||||
|
||||
phase_install() {
|
||||
reset_phase_cells
|
||||
log "Phase 1: install ${FROM_TAG} with its published install.sh --version"
|
||||
# No TTY: install.sh's prompts take their defaults, as for curl | bash.
|
||||
if ! cexec 'bash /rehearsal/install.sh --version "$FROM_TAG"' "FROM_TAG=${FROM_TAG}" \
|
||||
2>&1 | tee "${WORK_DIR}/state/install-${FROM_TAG}.log"; then
|
||||
if [[ "$HOSTED_BROWSER" == true ]]; then
|
||||
# /rehearsal is the fixed mount spelling used by the existing harness.
|
||||
# It is absent on a fresh hosted runner; never overwrite an existing path.
|
||||
[[ ! -e /rehearsal ]] || return 1
|
||||
ln -s "${WORK_DIR}/assets" /rehearsal
|
||||
fi
|
||||
if ! install_baseline; then
|
||||
note_failure "install.sh --version ${FROM_TAG} exited non-zero"
|
||||
fi
|
||||
assert_runtime "$FROM_TAG"
|
||||
@@ -635,7 +745,7 @@ phase_seed() {
|
||||
if ! setup_response=$(cexec 'curl -fsS -X POST -H "Content-Type: application/json" -H "X-Setup-Token: $SETUP_TOKEN" --data "$BODY" "$API/api/security/quick-setup"' \
|
||||
"API=${API}" "SETUP_TOKEN=${setup_token}" "BODY=${setup_body}") \
|
||||
|| [[ "$(jq -r '.success // false' <<<"$setup_response" 2>/dev/null)" != "true" ]]; then
|
||||
note_failure "first-run security setup failed: ${setup_response:-no response}"
|
||||
note_failure "first-run security setup did not return success"
|
||||
fi
|
||||
|
||||
local webhook_body node_body
|
||||
@@ -695,28 +805,61 @@ run_updater() {
|
||||
fi
|
||||
# /bin/update downloads the latest published install.sh, verifies it with
|
||||
# its embedded signer key and runs it with the given arguments.
|
||||
if ! cexec '/bin/update --version "$TARGET"' "TARGET=${target}" \
|
||||
if [[ "$HOSTED_BROWSER" == true ]]; then
|
||||
if ! cexec '/bin/update --version "$TARGET"' "TARGET=${target}" \
|
||||
> "${WORK_DIR}/state/private-updater.log" 2>&1; then
|
||||
note_failure "/bin/update --version ${target} exited non-zero"
|
||||
fi
|
||||
elif ! cexec '/bin/update --version "$TARGET"' "TARGET=${target}" \
|
||||
2>&1 | tee "${WORK_DIR}/state/${label}-${target}.log"; then
|
||||
note_failure "/bin/update --version ${target} exited non-zero"
|
||||
fi
|
||||
}
|
||||
|
||||
run_browser_journey() {
|
||||
local mode="$1" expected="$2"
|
||||
if [[ "$mode" == recovery && -f "${WORK_DIR}/state/browser-upgrade.json" ]] \
|
||||
&& jq -e '.access_refused == true' "${WORK_DIR}/state/browser-upgrade.json" >/dev/null; then
|
||||
printf '{"status":"not-executed","reason":"stopped-access-no-reauthentication"}\n' \
|
||||
> "${WORK_DIR}/state/browser-recovery.json"
|
||||
return 1
|
||||
fi
|
||||
(umask 077; jq -cn --arg username "$SEED_ADMIN_USER" --arg password "$ADMIN_PASSWORD" \
|
||||
'{username:$username,password:$password}' > "${WORK_DIR}/state/browser-auth.json")
|
||||
env -i PATH=/usr/local/bin:/usr/bin:/bin HOME="${WORK_DIR}" \
|
||||
PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-/opt/pulse-browser}" \
|
||||
"${PULSE_REHEARSAL_NODE:?}" "${ROOT_DIR}/tests/integration/scripts/release-browser-journey.cjs" \
|
||||
--origin "$PULSE_REHEARSAL_BROWSER_ORIGIN" --mode "$mode" \
|
||||
--from "$FROM_TAG" --to "$TO_TAG" --expected "$expected" \
|
||||
--auth-file "${WORK_DIR}/state/browser-auth.json" --output-dir "${WORK_DIR}/state"
|
||||
}
|
||||
|
||||
phase_upgrade() {
|
||||
reset_phase_cells
|
||||
log "Phase 3: upgrade with /bin/update --version ${TO_TAG}"
|
||||
run_updater "$TO_TAG" upgrade || true
|
||||
if [[ "$HOSTED_BROWSER" == true ]]; then
|
||||
log "Phase 3: upgrade to ${TO_TAG} through the authenticated Tailscale Serve browser"
|
||||
run_browser_journey upgrade "$TO_TAG" || note_failure "Tailscale browser upgrade failed; no apply replay"
|
||||
else
|
||||
log "Phase 3: upgrade with /bin/update --version ${TO_TAG}"
|
||||
run_updater "$TO_TAG" upgrade || true
|
||||
fi
|
||||
assert_runtime "$TO_TAG"
|
||||
check_auto_update_intent upgrade || true
|
||||
check_settings upgrade || true
|
||||
check_datadir upgrade || true
|
||||
record_phase "3. upgrade (/bin/update)" "$TO_TAG" "$PHASE_VERSION" "$PHASE_HEALTH" "$PHASE_SETTINGS" "$PHASE_DATADIR" "$PHASE_UNITS"
|
||||
record_phase "3. upgrade ($([[ "$HOSTED_BROWSER" == true ]] && printf browser || printf /bin/update))" "$TO_TAG" "$PHASE_VERSION" "$PHASE_HEALTH" "$PHASE_SETTINGS" "$PHASE_DATADIR" "$PHASE_UNITS"
|
||||
}
|
||||
|
||||
phase_rollback() {
|
||||
reset_phase_cells
|
||||
log "Phase 4: roll back with /bin/update --version ${FROM_TAG}"
|
||||
# The documented recovery command is exercised even after an adverse
|
||||
# browser update. It is not a second attempt to apply the candidate.
|
||||
run_updater "$FROM_TAG" rollback || true
|
||||
assert_runtime "$FROM_TAG"
|
||||
if [[ "$HOSTED_BROWSER" == true ]]; then
|
||||
run_browser_journey recovery "$FROM_TAG" || note_failure "Tailscale browser recovery/readback failed"
|
||||
fi
|
||||
check_auto_update_intent rollback || true
|
||||
check_settings rollback || true
|
||||
check_datadir rollback || true
|
||||
@@ -724,13 +867,13 @@ phase_rollback() {
|
||||
}
|
||||
|
||||
main() {
|
||||
log "Rehearsing ${FROM_TAG} -> ${TO_TAG} -> ${FROM_TAG} from ${REPO} with ${ENGINE}"
|
||||
log "Rehearsing ${FROM_TAG} -> ${TO_TAG} -> ${FROM_TAG} from ${REPO}; hosted browser=${HOSTED_BROWSER}"
|
||||
if ! fetch_and_verify_installer "$FROM_TAG"; then
|
||||
CURRENT_FAILURE="published install.sh for ${FROM_TAG} failed verification"
|
||||
record_phase "0. verify installer" "$FROM_TAG" "-" "-" "-" "-" "-" || abort_run
|
||||
fi
|
||||
if ! start_container; then
|
||||
CURRENT_FAILURE="systemd container did not start"
|
||||
CURRENT_FAILURE="disposable systemd host did not start"
|
||||
record_phase "0. systemd host" "-" "-" "-" "-" "-" "-" || abort_run
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Exact release identity and fail-closed hosted browser control tests, offline."""
|
||||
import copy
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
spec = importlib.util.spec_from_file_location('browser_packet', ROOT / 'scripts/release_browser_packet.py')
|
||||
packet = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(packet)
|
||||
|
||||
|
||||
def release(tag, commit):
|
||||
return {'id': 123, 'tag_name': tag, 'target_commitish': commit, 'draft': False,
|
||||
'prerelease': '-rc.' in tag, 'immutable': True, 'published_at': '2026-10-02T19:21:28Z',
|
||||
'assets': [{'id': i + 1, 'name': name, 'size': 5, 'digest': 'sha256:' + 'b' * 64,
|
||||
'browser_download_url': f'https://github.com/rcourtman/Pulse/releases/download/{tag}/{name}'}
|
||||
for i, name in enumerate((f'pulse-{tag}-linux-amd64.tar.gz', f'pulse-{tag}-linux-amd64.tar.gz.sshsig'))]}
|
||||
|
||||
|
||||
class BrowserPacketTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.baseline = release('v6.4.5', 'a' * 40)
|
||||
self.candidate = release('v6.4.6-rc.1', 'c' * 40)
|
||||
|
||||
def bind(self, archives=None):
|
||||
return packet.packet(self.baseline, self.candidate, 'a' * 40, 'c' * 40, 'v6.4.6-rc.1', archives)
|
||||
|
||||
def test_binds_two_immutable_exact_commits_not_control_head(self):
|
||||
result = self.bind()
|
||||
self.assertEqual(['a' * 40, 'c' * 40], [r['commit'] for r in result['releases']])
|
||||
self.assertEqual('rcourtman/Pulse', result['repository'])
|
||||
self.assertNotIn('assets', result) # Only bounded constituent identities are retained.
|
||||
|
||||
def test_rejects_unpublished_mutable_wrong_maturity_or_ref(self):
|
||||
for field, value in [('draft', True), ('immutable', False), ('prerelease', False),
|
||||
('target_commitish', 'main'), ('tag_name', 'v6.4.6-rc.2'),
|
||||
('published_at', None), ('id', True)]:
|
||||
with self.subTest(field=field), self.assertRaises(ValueError):
|
||||
candidate = self.candidate | {field: value}
|
||||
packet.packet(self.baseline, candidate, 'a' * 40, 'c' * 40, 'v6.4.6-rc.1')
|
||||
with self.assertRaises(ValueError):
|
||||
packet.packet(self.baseline, release('v6.4.5-rc.9', 'c' * 40), 'a' * 40, 'c' * 40, 'v6.4.5-rc.9')
|
||||
|
||||
def test_rejects_redirect_asset_duplicate_oversize_or_unbound_digest(self):
|
||||
for field, value in [('browser_download_url', 'https://example.com/archive'), ('size', 0),
|
||||
('size', 257 * 1024 * 1024), ('digest', None), ('id', True)]:
|
||||
with self.subTest(field=field), self.assertRaises(ValueError):
|
||||
candidate = copy.deepcopy(self.candidate)
|
||||
candidate['assets'][0][field] = value
|
||||
packet.packet(self.baseline, candidate, 'a' * 40, 'c' * 40, 'v6.4.6-rc.1')
|
||||
self.candidate['assets'].append(self.candidate['assets'][0])
|
||||
with self.assertRaises(ValueError):
|
||||
self.bind()
|
||||
|
||||
def make_assets(self, root):
|
||||
for r in (self.baseline, self.candidate):
|
||||
folder = root / r['tag_name']; folder.mkdir()
|
||||
for asset in r['assets']:
|
||||
path = folder / asset['name']
|
||||
if asset['name'].endswith('.sshsig'):
|
||||
path.write_bytes(b'fixture signature - cryptographic verification belongs to SSH workflow')
|
||||
else:
|
||||
data = ('fixture-' + r['tag_name']).encode()
|
||||
with tarfile.open(path, 'w:gz') as archive:
|
||||
member = tarfile.TarInfo('./bin/pulse'); member.size = len(data)
|
||||
archive.addfile(member, io.BytesIO(data))
|
||||
asset['size'] = path.stat().st_size
|
||||
asset['digest'] = 'sha256:' + hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
def test_asset_digest_and_binary_digest_both_checked_without_extraction(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp); self.make_assets(root)
|
||||
result = self.bind(root)
|
||||
self.assertEqual(hashlib.sha256(b'fixture-v6.4.6-rc.1').hexdigest(), result['releases'][1]['binary_sha256'])
|
||||
target = root / self.candidate['tag_name'] / self.candidate['assets'][1]['name']
|
||||
target.write_bytes(b'X' * target.stat().st_size)
|
||||
with self.assertRaisesRegex(ValueError, 'digest'):
|
||||
self.bind(root)
|
||||
|
||||
def test_symlink_and_duplicate_binary_are_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp); self.make_assets(root)
|
||||
target = root / self.candidate['tag_name'] / self.candidate['assets'][1]['name']
|
||||
real = target.with_suffix('.real'); target.rename(real); target.symlink_to(real)
|
||||
with self.assertRaisesRegex(ValueError, 'size/type'):
|
||||
self.bind(root)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / 'bad.tar.gz'
|
||||
for names in (['./bin/pulse', 'bin/pulse'], ['./bin/pulse']):
|
||||
with self.subTest(names=names):
|
||||
with tarfile.open(path, 'w:gz') as archive:
|
||||
for name in names:
|
||||
member = tarfile.TarInfo(name); member.type = tarfile.SYMTYPE; member.linkname = '/etc/passwd'
|
||||
archive.addfile(member)
|
||||
with self.assertRaises(ValueError):
|
||||
packet.archive_identity(path)
|
||||
|
||||
def test_native_mode_refuses_worker_before_install_or_network(self):
|
||||
result = subprocess.run(['bash', ROOT / 'scripts/release_lifecycle_rehearsal.sh', '--hosted-browser',
|
||||
'--from', 'v6.4.5', '--to', 'v6.4.6-rc.1'],
|
||||
env={'PATH': '/usr/bin:/bin'}, capture_output=True, text=True)
|
||||
self.assertEqual(2, result.returncode)
|
||||
self.assertIn('fresh disposable public hosted runner', result.stdout)
|
||||
|
||||
def test_cleanup_failure_sets_process_exit_and_scrubs_only_ephemeral_auth(self):
|
||||
source = (ROOT / 'scripts/release_lifecycle_rehearsal.sh').read_text()
|
||||
cleanup = source.split('cleanup() {', 1)[1].split('\ntrap cleanup EXIT', 1)[0]
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp); (root / 'state').mkdir(); (root / 'state/browser-auth.json').write_text('throwaway')
|
||||
# Execute the real cleanup function with fixture systemctl: not a system/installation proof.
|
||||
script = 'set -u\nOVERALL_STATUS=0; CONTAINER_STARTED=true; HOSTED_BROWSER=true\n'
|
||||
script += f'WORK_DIR={json.dumps(tmp)}\nprint_diagnostics() {{ :; }}\nsystemctl() {{ return 1; }}\n'
|
||||
script += 'cleanup() {' + cleanup + '\ntrap cleanup EXIT\nexit 0\n'
|
||||
result = subprocess.run(['bash', '-c', script], capture_output=True, text=True)
|
||||
self.assertEqual(1, result.returncode, result.stderr)
|
||||
self.assertEqual('', (root / 'state/browser-auth.json').read_text())
|
||||
self.assertEqual(1, json.loads((root / 'state/browser-cleanup.json').read_text())['cleanup_exit'])
|
||||
|
||||
def test_cexec_native_environment_drops_provider_and_registration_secrets(self):
|
||||
source = (ROOT / 'scripts/release_lifecycle_rehearsal.sh').read_text()
|
||||
function = source.split('cexec() {', 1)[1].split('\nprint_diagnostics()', 1)[0]
|
||||
script = 'HOSTED_BROWSER=true\ncexec() {' + function + '\ncexec env EXTRA=fixture\n'
|
||||
result = subprocess.run(['bash', '-c', script], env={'PATH': '/usr/bin:/bin',
|
||||
'GH_TOKEN': 'synthetic-secret', 'TS_OAUTH_SECRET': 'synthetic-secret'},
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertNotIn('synthetic-secret', result.stdout)
|
||||
self.assertNotIn('PULSE_INSTALL_ALLOW_DOCKER', result.stdout)
|
||||
self.assertIn('EXTRA=fixture', result.stdout)
|
||||
|
||||
def test_workflow_has_no_arbitrary_target_build_publish_or_credential_in_acceptance(self):
|
||||
source = (ROOT / '.github/workflows/qualify-browser-update-release.yml').read_text()
|
||||
self.assertNotIn('pull_request:', source)
|
||||
self.assertNotIn('contents: write', source)
|
||||
self.assertNotIn('ignoreHTTPSErrors', (ROOT / 'tests/integration/scripts/release-browser-journey.cjs').read_text())
|
||||
self.assertIn('test "$GITHUB_WORKFLOW_SHA" = "$EXPECTED_CONTROL"', source)
|
||||
self.assertIn('runs-on: ubuntu-24.04', source)
|
||||
self.assertIn('"Self"]["DNSName"]', source)
|
||||
self.assertIn('ssh-keygen -Y verify', source)
|
||||
acceptance = source.split('- name: Exercise native browser update', 1)[1].split('- name: Close', 1)[0]
|
||||
self.assertIn('sudo env -i', acceptance)
|
||||
for name in ('GH_TOKEN', 'TS_OAUTH_SECRET', 'PULSE_ALLOW_DOCKER_UPDATES', 'ignoreHTTPSErrors'):
|
||||
self.assertNotIn(name, acceptance)
|
||||
artifact = source.split('- name: Retain bounded', 1)[1]
|
||||
for private in ('browser-auth.json', 'private-install.log', 'private-updater.log', 'datadir.'):
|
||||
self.assertNotIn(private, artifact)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -177,6 +177,27 @@ class HarnessContractTest(unittest.TestCase):
|
||||
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
|
||||
|
||||
|
||||
class BrowserRecoveryAccessStopTest(unittest.TestCase):
|
||||
def test_refused_upgrade_does_not_launch_another_login(self) -> None:
|
||||
source = HARNESS_PATH.read_text(encoding="utf-8")
|
||||
function = "run_browser_journey() {" + source.split("run_browser_journey() {", 1)[1].split("\n}\n", 1)[0] + "\n}"
|
||||
for status in (401, 403):
|
||||
with self.subTest(status=status), tempfile.TemporaryDirectory() as tmp:
|
||||
work = Path(tmp)
|
||||
(work / "state").mkdir()
|
||||
(work / "state/browser-upgrade.json").write_text(json.dumps({"access_refused": True, "login_status": status}))
|
||||
script = "set -euo pipefail\nWORK_DIR=" + json.dumps(tmp) + "\n"
|
||||
script += function + "\nrun_browser_journey recovery v6.4.5\n"
|
||||
# NODE/auth/origin variables are intentionally absent. Reaching
|
||||
# process/auth preparation would fail this exact guard test.
|
||||
result = subprocess.run(["bash", "-c", script], capture_output=True, text=True)
|
||||
self.assertEqual(1, result.returncode, result.stderr)
|
||||
self.assertEqual({"status": "not-executed", "reason": "stopped-access-no-reauthentication"},
|
||||
json.loads((work / "state/browser-recovery.json").read_text()))
|
||||
self.assertFalse((work / "state/browser-auth.json").exists())
|
||||
self.assertEqual("", result.stderr)
|
||||
|
||||
|
||||
class AutoUpdateIntentTest(unittest.TestCase):
|
||||
"""Execute the real observer/comparator, with guest systemctl observations."""
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/* Real, bounded browser observation of the disposable release install.
|
||||
* No intercepted API responses, mocked versions, TLS bypass or apply replay.
|
||||
* The CLI accepts only the new runner's HTTPS Tailscale Serve origin. Exported
|
||||
* functions allow offline adversarial fixtures to test the observer itself.
|
||||
*/
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const assert = require('node:assert/strict');
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
function validate(options) {
|
||||
assert.match(options.origin, /^https:\/\/[a-z0-9-]+\.tawny-powan\.ts\.net$/);
|
||||
assert.match(options.from, /^v\d+\.\d+\.\d+$/);
|
||||
assert.match(options.to, /^v\d+\.\d+\.\d+-rc\.[1-9]\d*$/);
|
||||
assert.ok(['upgrade', 'recovery'].includes(options.mode));
|
||||
assert.equal(options.expected, options.mode === 'upgrade' ? options.to : options.from);
|
||||
}
|
||||
|
||||
function judge(record) {
|
||||
assert.equal(record.login_status, 200, 'authenticated UI login must succeed');
|
||||
assert.equal(record.observed_version, record.expected.replace(/^v/, ''), 'wrong served version');
|
||||
assert.equal(record.health, 'healthy', 'installed server is not healthy');
|
||||
assert.equal(record.protected_read_status, 200, 'session did not survive/recover');
|
||||
if (record.mode === 'upgrade') {
|
||||
assert.equal(record.apply_requests, 1, 'exactly one UI apply is required');
|
||||
assert.equal(record.apply_status, 200, 'apply was not accepted');
|
||||
assert.equal(record.selected_version, record.to.replace(/^v/, ''), 'wrong release selected');
|
||||
assert.ok(record.sse_opens > 0, 'no real EventSource connection opened');
|
||||
assert.ok(record.sse_progress.some(p => p.status !== 'idle' && p.progress > 0),
|
||||
'no live SSE progress received');
|
||||
assert.equal(record.progress_dialog_visible, false, 'progress modal remained stuck after readiness');
|
||||
}
|
||||
}
|
||||
|
||||
async function runJourney(browser, options) {
|
||||
const record = { schema_version: 1, mode: options.mode, from: options.from, to: options.to,
|
||||
expected: options.expected, browser_version: browser.version(),
|
||||
client_version: require('playwright/package.json').version, active_step: 'start', login_status: null, apply_requests: 0, apply_status: null,
|
||||
sse_opens: 0, sse_progress: [], access_refused: false, status: 'incomplete' };
|
||||
assert.equal(record.client_version, '1.56.1', 'browser client must match the reviewed lock');
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(options.timeout || 30000);
|
||||
// Retain only closed status/progress fields, not messages, headers, cookies,
|
||||
// request bodies, full response JSON or private resource data.
|
||||
await page.exposeFunction('__observeReleaseStream', item => {
|
||||
if (item.open === true) record.sse_opens++;
|
||||
if (['idle', 'downloading', 'verifying', 'extracting', 'installing', 'restarting',
|
||||
'completed', 'error', 'failed', 'checking', 'backing_up'].includes(item.status)
|
||||
&& Number.isFinite(item.progress) && item.progress >= 0 && item.progress <= 100
|
||||
&& record.sse_progress.length < 1000) {
|
||||
record.sse_progress.push({ status: item.status, progress: item.progress });
|
||||
}
|
||||
});
|
||||
await page.addInitScript(() => {
|
||||
const NativeEventSource = window.EventSource;
|
||||
window.EventSource = class extends NativeEventSource {
|
||||
constructor(url, config) {
|
||||
super(url, config);
|
||||
if (new URL(url, location.href).pathname === '/api/updates/stream') {
|
||||
this.addEventListener('open', () => window.__observeReleaseStream({ open: true }));
|
||||
this.addEventListener('message', event => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
void window.__observeReleaseStream({ status: data.status, progress: data.progress });
|
||||
} catch (_) { /* malformed data is not progress evidence */ }
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
page.on('request', request => {
|
||||
if (new URL(request.url()).pathname === '/api/updates/apply' && request.method() === 'POST') {
|
||||
record.apply_requests++;
|
||||
}
|
||||
});
|
||||
page.on('response', response => {
|
||||
const endpoint = new URL(response.url()).pathname;
|
||||
if (endpoint === '/api/login' && response.request().method() === 'POST') {
|
||||
record.login_status = response.status();
|
||||
if ([401, 403].includes(response.status())) record.access_refused = true;
|
||||
}
|
||||
if (endpoint === '/api/updates/apply' && response.request().method() === 'POST') {
|
||||
record.apply_status = response.status();
|
||||
if ([401, 403].includes(response.status())) record.access_refused = true;
|
||||
}
|
||||
});
|
||||
try {
|
||||
record.active_step = 'login';
|
||||
await page.goto(`${options.origin}/settings/system-updates`, { waitUntil: 'domcontentloaded' });
|
||||
await page.getByPlaceholder('Username', { exact: true }).fill(options.auth.username);
|
||||
await page.getByPlaceholder('Password', { exact: true }).fill(options.auth.password);
|
||||
const login = page.waitForResponse(r => new URL(r.url()).pathname === '/api/login'
|
||||
&& r.request().method() === 'POST');
|
||||
await page.getByRole('button', { name: 'Sign in to Pulse', exact: true }).click();
|
||||
assert.equal((await login).status(), 200, 'login rejected; do not retry or change identity');
|
||||
await page.getByPlaceholder('Username', { exact: true }).waitFor({ state: 'hidden' });
|
||||
await page.goto(`${options.origin}/settings/system-updates`, { waitUntil: 'domcontentloaded' });
|
||||
if (options.mode === 'upgrade') {
|
||||
record.active_step = 'select_exact_release';
|
||||
const initial = await page.evaluate(async () => (await fetch('/api/version')).json());
|
||||
assert.equal(initial.version.replace(/^v/, ''), options.from.replace(/^v/, ''));
|
||||
assert.equal(initial.isDocker, false, 'a Docker override is not native acceptance');
|
||||
assert.equal(initial.isSourceBuild, false, 'must run the published binary');
|
||||
await page.getByRole('button', { name: /^Preview\b/ }).click();
|
||||
await page.getByRole('button', { name: 'Save Changes', exact: true }).click();
|
||||
const check = page.waitForResponse(r => new URL(r.url()).pathname === '/api/updates/check'
|
||||
&& new URL(r.url()).searchParams.get('channel') === 'rc');
|
||||
await page.getByRole('button', { name: /Check for Updates/i }).click();
|
||||
const checked = await check;
|
||||
if ([401, 403].includes(checked.status())) record.access_refused = true;
|
||||
assert.equal(checked.status(), 200, 'update check rejected; no alternate route');
|
||||
const selected = await checked.json();
|
||||
record.selected_version = selected.latestVersion?.replace(/^v/, '');
|
||||
assert.equal(record.selected_version, options.to.replace(/^v/, ''), 'refuse a different preview');
|
||||
assert.equal(selected.downloadUrl,
|
||||
`https://github.com/rcourtman/Pulse/releases/download/${options.to}/pulse-${options.to}-linux-amd64.tar.gz`);
|
||||
await page.getByRole('button', { name: 'Install Update', exact: true }).first().click();
|
||||
const confirmation = page.getByRole('dialog').filter({ has: page.getByRole('button', { name: 'Start Update' }) });
|
||||
record.active_step = 'confirm_apply_once';
|
||||
await confirmation.getByRole('checkbox').check();
|
||||
// No command is replayed after a timeout, rejected POST or lost response.
|
||||
await confirmation.getByRole('button', { name: 'Start Update', exact: true }).click();
|
||||
await page.getByRole('dialog').filter({ hasText: /Downloading update|Updating Pulse|Update Progress/i })
|
||||
.waitFor({ state: 'visible' });
|
||||
}
|
||||
record.active_step = 'version_health_session_readiness';
|
||||
const deadline = Date.now() + (options.readinessTimeout || 600000);
|
||||
let ready = false;
|
||||
while (Date.now() < deadline) {
|
||||
// Read-only observations during a known restart may temporarily have no
|
||||
// listener. An HTTP401 is not such a transport gap and stops this journey.
|
||||
const observed = await page.evaluate(async () => {
|
||||
try {
|
||||
const version = await fetch('/api/version');
|
||||
const health = await fetch('/api/health');
|
||||
const protectedRead = await fetch('/api/config/nodes');
|
||||
const result = { versionStatus: version.status, healthStatus: health.status,
|
||||
protectedStatus: protectedRead.status };
|
||||
try { result.version = await version.json(); result.health = await health.json(); }
|
||||
catch (_) { result.body_unavailable = true; }
|
||||
return result;
|
||||
} catch (_) { return { transport_unavailable: true }; }
|
||||
}).catch(() => ({ transport_unavailable: true })); // page navigation during automatic reload
|
||||
record.protected_read_status = observed.protectedStatus ?? null;
|
||||
if ([401, 403].includes(observed.protectedStatus)) record.access_refused = true;
|
||||
assert.ok(![401, 403].includes(observed.protectedStatus), 'session read rejected; stop without reauthentication');
|
||||
if (observed.versionStatus === 200 && observed.healthStatus === 200
|
||||
&& observed.protectedStatus === 200 && typeof observed.version?.version === 'string'
|
||||
&& observed.version.version.replace(/^v/, '') === options.expected.replace(/^v/, '')
|
||||
&& observed.health?.status === 'healthy') {
|
||||
record.observed_version = observed.version.version.replace(/^v/, '');
|
||||
record.health = observed.health.status;
|
||||
record.protected_read_status = observed.protectedStatus;
|
||||
ready = true;
|
||||
break;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
assert.ok(ready, 'readiness deadline expired');
|
||||
// The UI gets its own bounded window to dismiss/reload after version readiness.
|
||||
record.active_step = 'progress_modal_completion';
|
||||
const progress = page.getByRole('dialog').filter({ hasText: /Downloading update|Updating Pulse|Update Progress/i });
|
||||
await progress.waitFor({ state: 'hidden', timeout: options.timeout || 60000 });
|
||||
record.progress_dialog_visible = await progress.isVisible();
|
||||
if (options.outputDir) await page.screenshot({ path: path.join(options.outputDir, `browser-${options.mode}.png`), fullPage: true });
|
||||
judge(record);
|
||||
record.active_step = 'complete';
|
||||
record.status = 'passed';
|
||||
} catch (error) {
|
||||
record.status = 'failed';
|
||||
// Playwright errors can contain URLs, page text or values. Preserve a fixed
|
||||
// type, not arbitrary error text or credentials, in the public receipt.
|
||||
record.failure_type = error.name === 'TimeoutError' ? 'TimeoutError' : 'AssertionOrOperationError';
|
||||
throw error;
|
||||
} finally {
|
||||
if (options.outputDir) fs.writeFileSync(path.join(options.outputDir, `browser-${options.mode}.json`), JSON.stringify(record, null, 2) + '\n');
|
||||
await context.close();
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const fields = { '--origin': 'origin', '--mode': 'mode', '--from': 'from', '--to': 'to',
|
||||
'--expected': 'expected', '--auth-file': 'authFile', '--output-dir': 'outputDir' };
|
||||
// Seven fixed options; no endpoint, credential, browser or command override.
|
||||
assert.equal(args.length, 14);
|
||||
const options = {};
|
||||
for (let i = 0; i < args.length; i += 2) {
|
||||
assert.ok(fields[args[i]] && options[fields[args[i]]] === undefined);
|
||||
options[fields[args[i]]] = args[i + 1];
|
||||
}
|
||||
validate(options);
|
||||
options.auth = JSON.parse(fs.readFileSync(options.authFile, 'utf8'));
|
||||
fs.mkdirSync(options.outputDir, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true, channel: 'chromium', args: ['--no-sandbox'] });
|
||||
try { await runJourney(browser, options); } finally { await browser.close(); }
|
||||
}
|
||||
|
||||
module.exports = { validate, judge, runJourney };
|
||||
if (require.main === module) main().catch(() => { console.error('Release browser journey failed; inspect bounded receipt. No replay.'); process.exitCode = 1; });
|
||||
@@ -0,0 +1,127 @@
|
||||
/* Offline adversarial observer controls. These fixtures are not Pulse release,
|
||||
* systemd, installed update, Tailscale or qualification evidence. */
|
||||
const assert = require('node:assert/strict');
|
||||
const http = require('node:http');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { chromium } = require('playwright');
|
||||
const { validate, judge, runJourney } = require('./release-browser-journey.cjs');
|
||||
const from = 'v6.4.5', to = 'v6.4.6-rc.1';
|
||||
const downloadUrl = `https://github.com/rcourtman/Pulse/releases/download/${to}/pulse-${to}-linux-amd64.tar.gz`;
|
||||
|
||||
function pureControls() {
|
||||
const valid = { origin: 'https://ephemeral.tawny-powan.ts.net', mode: 'upgrade', from, to, expected: to };
|
||||
validate(valid);
|
||||
for (const origin of ['http://ephemeral.tawny-powan.ts.net', 'https://private.example.com',
|
||||
'https://ephemeral.other.ts.net', 'https://ephemeral.tawny-powan.ts.net:443', 'https://ephemeral.tawny-powan.ts.net/path']) {
|
||||
assert.throws(() => validate({ ...valid, origin }));
|
||||
}
|
||||
for (const delta of [{ mode: 'apply' }, { to: 'v6.4.6' }, { expected: from }]) assert.throws(() => validate({ ...valid, ...delta }));
|
||||
const receipt = { ...valid, login_status: 200, observed_version: to.slice(1), health: 'healthy',
|
||||
protected_read_status: 200, apply_requests: 1, apply_status: 200, selected_version: to.slice(1),
|
||||
sse_opens: 1, sse_progress: [{ status: 'downloading', progress: 10 }], progress_dialog_visible: false };
|
||||
judge(receipt);
|
||||
for (const delta of [{ login_status: 401 }, { observed_version: from.slice(1) }, { health: 'unhealthy' },
|
||||
{ protected_read_status: 403 }, { apply_requests: 0 }, { apply_requests: 2 }, { apply_status: 500 },
|
||||
{ selected_version: '6.5.0-rc.1' }, { sse_opens: 0 }, { sse_progress: [] }, { progress_dialog_visible: true }]) {
|
||||
assert.throws(() => judge({ ...receipt, ...delta }));
|
||||
}
|
||||
}
|
||||
|
||||
function fixture(kind, mode) {
|
||||
let version = (mode === 'recovery' ? from : from).slice(1), applies = 0, logins = 0;
|
||||
let applied = false;
|
||||
const sockets = new Set();
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const json = (code, data) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(data)); };
|
||||
if (url.pathname === '/api/login') {
|
||||
logins++;
|
||||
if (kind === 'login-401') return json(401, { error: 'fixture denial' });
|
||||
res.setHeader('Set-Cookie', 'fixture-session=true; Path=/; HttpOnly; SameSite=Lax');
|
||||
return json(200, { success: true });
|
||||
}
|
||||
if (url.pathname === '/api/version') return json(200, { version, isDocker: false, isSourceBuild: false });
|
||||
if (url.pathname === '/api/health') return json(200, { status: 'healthy' });
|
||||
if (url.pathname === '/api/config/nodes') return json(applied && kind === 'read-401' ? 401 : 200, []);
|
||||
if (url.pathname === '/api/updates/check') return json(200, {
|
||||
latestVersion: kind === 'wrong-preview' ? '6.5.0-rc.1' : to.slice(1), downloadUrl });
|
||||
if (url.pathname === '/api/updates/apply') {
|
||||
applies++; applied = true;
|
||||
setTimeout(() => { version = to.slice(1); }, 350);
|
||||
return json(200, { status: 'started' });
|
||||
}
|
||||
if (url.pathname === '/api/updates/stream') {
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
|
||||
res.write(': real loopback fixture stream\n\n');
|
||||
if (kind !== 'silent-stream') res.write(`data: ${JSON.stringify({ status: 'downloading', progress: 10, message: 'DO NOT RETAIN FIXTURE PRIVATE MESSAGE' })}\n\n`);
|
||||
req.on('close', () => res.end()); return;
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'text/html' });
|
||||
const signedIn = /fixture-session=true/.test(req.headers.cookie || '');
|
||||
res.end(`<!doctype html><html><head><title>Offline release observer control</title></head>
|
||||
<body><h1>Observer fixture — not Pulse/native qualification</h1>${signedIn ? `
|
||||
<h2>System updates</h2><button id="preview">Preview beta builds</button><button id="save">Save Changes</button>
|
||||
<button id="check">Check for Updates</button><button id="install" hidden>Install Update</button>
|
||||
<main id="dialogs"></main><p id="state">Published fixture ${version}</p>
|
||||
<script>
|
||||
document.querySelector('#check').onclick = async () => { await fetch('/api/updates/check?channel=rc'); document.querySelector('#install').hidden=false; };
|
||||
document.querySelector('#install').onclick = () => {
|
||||
document.querySelector('#dialogs').innerHTML='<section role="dialog" aria-label="Confirm update"><h2>Confirm Update</h2><label><input type="checkbox"> Acknowledge</label><button id="start">Start Update</button></section>';
|
||||
document.querySelector('#start').onclick = async () => {
|
||||
await fetch('/api/updates/apply', {method:'POST'});
|
||||
document.querySelector('#dialogs').innerHTML='<section role="dialog" aria-label="Updating Pulse"><h2>Updating Pulse</h2><p>Downloading update 10%</p></section>';
|
||||
const stream=new EventSource('/api/updates/stream');
|
||||
setTimeout(() => { ${kind === 'stuck-modal' ? '' : "document.querySelector('#dialogs').innerHTML='';"}
|
||||
document.querySelector('#state').textContent='Fixture ready'; stream.close(); }, 700);
|
||||
};
|
||||
};
|
||||
</script>` : `
|
||||
<input placeholder="Username"><input placeholder="Password" type="password"><button id="login">Sign in to Pulse</button>
|
||||
<script>document.querySelector('#login').onclick = async () => { const response=await fetch('/api/login', {method:'POST'}); if(response.ok)location.reload(); };</script>`}</body></html>`);
|
||||
});
|
||||
server.on('connection', socket => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)); });
|
||||
return { server, counts: () => ({ applies, logins }), close: async () => {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
} };
|
||||
}
|
||||
|
||||
async function runControls(outputDir) {
|
||||
pureControls();
|
||||
const browser = await chromium.launch({ headless: true, channel: 'chromium', args: ['--no-sandbox'] });
|
||||
const results = [];
|
||||
try {
|
||||
for (const [kind, mode, expectedPass, expectedApplies] of [
|
||||
['success', 'upgrade', true, 1], ['success', 'recovery', true, 0],
|
||||
['login-401', 'upgrade', false, 0], ['wrong-preview', 'upgrade', false, 0],
|
||||
['silent-stream', 'upgrade', false, 1], ['stuck-modal', 'upgrade', false, 1],
|
||||
['read-401', 'upgrade', false, 1],
|
||||
]) {
|
||||
const target = fixture(kind, mode);
|
||||
await new Promise(resolve => target.server.listen(0, '127.0.0.1', resolve));
|
||||
const folder = path.join(outputDir, `${kind}-${mode}`); fs.mkdirSync(folder, { recursive: true });
|
||||
let passed = false;
|
||||
try {
|
||||
await runJourney(browser, { origin: `http://127.0.0.1:${target.server.address().port}`, mode, from, to,
|
||||
expected: mode === 'upgrade' ? to : from, auth: { username: 'synthetic', password: 'synthetic' },
|
||||
timeout: 1500, readinessTimeout: 3000, outputDir: folder });
|
||||
passed = true;
|
||||
} catch (_) { /* observer must reject each deliberately adverse fixture */ }
|
||||
finally { await target.close(); }
|
||||
assert.equal(passed, expectedPass, `${kind}/${mode} observer verdict`);
|
||||
assert.deepEqual(target.counts(), { applies: expectedApplies, logins: 1 }, 'one login, no apply replay');
|
||||
const receipt = JSON.parse(fs.readFileSync(path.join(folder, `browser-${mode}.json`)));
|
||||
assert.ok(!JSON.stringify(receipt).includes('PRIVATE MESSAGE'));
|
||||
assert.ok(!JSON.stringify(receipt).includes('synthetic'));
|
||||
if (kind === 'read-401') assert.equal(receipt.protected_read_status, 401);
|
||||
results.push({ fixture: kind, mode, expected_rejection: !expectedPass, observed_status: receipt.status,
|
||||
active_step: receipt.active_step, ...target.counts() });
|
||||
}
|
||||
fs.writeFileSync(path.join(outputDir, 'observer-controls.json'), JSON.stringify({ scope: 'offline adversarial observer controls only',
|
||||
browser_version: browser.version(), client_version: require('playwright/package.json').version, results }, null, 2) + '\n');
|
||||
console.log(JSON.stringify({ observer_controls: results.length, scope: 'fixtures, not native qualification', results }));
|
||||
} finally { await browser.close(); }
|
||||
}
|
||||
module.exports = { runControls, pureControls };
|
||||
if (require.main === module) runControls(path.resolve(process.argv[2] || 'tmp/browser-journey-proof')).catch(error => { console.error(error); process.exitCode = 1; });
|
||||
Reference in New Issue
Block a user