Port RC3 maintenance fixes from v5

Refs #1440, #1444, #1451
This commit is contained in:
rcourtman 2026-05-01 15:30:20 +01:00
parent ef59055264
commit af8a5f0740
14 changed files with 388 additions and 64 deletions

View file

@ -0,0 +1,71 @@
# Known RC Issue Closure For GA Blocked Record
- Date: `2026-05-01`
- Gate: `known-rc-issue-closure-for-ga`
- Result: `blocked`
## Context
The RC3 maintenance audit compared the active v5 maintenance line against
`pulse/v6-release` and reviewed recently updated GitHub issues, comments, and
discussion signals before cutting another v6 release candidate.
## Fixed In The Current RC3 Patch
1. v5 `#1444`: alert cooldown set to disabled no longer means "notify every
metric tick." The v6 alert cooldown gate now sends the first notification for
an alert occurrence and suppresses subsequent re-notifications while
cooldown remains disabled.
2. v5 `#1440`: unsaved SMTP test-send settings no longer mutate or inherit the
shared production email manager when the SMTP/auth transport differs. This
prevents relay-mode tests from leaking stale saved authentication.
3. v5 DOMPurify security bump: the v6 frontend lockfile now resolves
`dompurify` to `3.4.1`.
4. `#1451` bootstrap-token confusion: the root installer no longer prints the
encrypted `.bootstrap_token` file contents as the user-facing token. It uses
the canonical `pulse bootstrap-token` command with the install data directory
instead.
Proof run during the audit:
- `go test ./internal/alerts ./internal/notifications`
- `go test ./scripts/installtests`
- `bash -n install.sh`
- `npm --prefix frontend-modern ls dompurify --package-lock-only`
## v5 Fixes Already Carried By v6
The v5 maintenance audit found that several recent v5 fixes already have v6
equivalents on `pulse/v6-release`, including QNAP update/boot continuity,
unified host/docker row merging, mdstat operation gating for RAID rebuild
alerts, stable agent identity files, linked guest filesystem display,
unreachable-guest snapshot carry-forward, update progress modal closability,
Ollama `keep_alive=30s`, and SSE EOF tool-call finalization.
## Remaining RC3 Triage Candidates
1. `#1441` affects `6.0.0-rc.2`: the reporter's screenshot shows the Proxmox
settings row reporting `Online` while the cluster popover reports Proxmox
offline for the same nodes. This is a v6 RC2 user-visible trust issue and
needs a root status-model fix, invalidation proof, or explicit supersession
before this gate can be called clear again.
2. `#1452` affects `5.1.28`: screenshots show graph tooltip content covering
the hovered chart point and surrounding graph detail. v6 shares the history
chart tooltip model, so RC3 should either fix the shared tooltip placement or
prove the v6 surface is not affected with browser evidence.
3. `#1448` discussion: PBS alert thresholds were reported as firing despite the
threshold being off. The alert cooldown fix may reduce notification spam, but
this still needs threshold-specific triage if reproducible on v6.
4. `#1435` release sequencing: the stable LXC installer branch now filters
prereleases correctly, and GitHub latest points at `v5.1.28`, but the
affected release asset is not repaired for stable users until the next v5
stable asset is published or backfilled. This is not a v6 code blocker, but
it remains part of the RC3/v5 sequencing checklist.
## Outcome
The audit removed several high-confidence v5-to-v6 regressions from the RC3
candidate, but later RC issue intake is not fully dispositioned. The
`known-rc-issue-closure-for-ga` gate must remain blocked until the remaining
RC3 triage candidates above are fixed, proven invalid, or conservatively
superseded.

View file

@ -4340,7 +4340,7 @@
"owner": "project-owner",
"blocking_level": "release-ready",
"minimum_evidence_tier": "managed-runtime-exercise",
"status": "passed",
"status": "blocked",
"verification_doc": "docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md",
"lane_ids": [
"L1",
@ -4358,6 +4358,12 @@
"path": "docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-2026-04-21.md",
"kind": "file",
"evidence_tier": "managed-runtime-exercise"
},
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-blocked-2026-05-01.md",
"kind": "file",
"evidence_tier": "managed-runtime-exercise"
}
]
},

View file

@ -305,6 +305,12 @@ reset behavior, quiet-hours day/category toggles, cooldown/grouping/escalation
update policy, and the canonical defaults handoff. Future schedule control-flow
work should extend that hook instead of rebuilding those mutations inline in
the tab shell.
The backend cooldown gate is part of that same schedule contract. A disabled
cooldown (`0` or negative) means "do not send periodic re-notifications for
the same active alert"; it still allows the first notification for a new alert
occurrence, while level-escalation delivery remains owned by the separate
escalation path. Runtime evaluation must not treat disabled cooldown as
"always notify" because the alert loop runs every metric tick.
Incident-event filter chip and filter-action styling now routes through
`frontend-modern/src/utils/alertIncidentPresentation.ts` for both

View file

@ -658,6 +658,11 @@ developer/runtime bootstrap. Changes to `package.json`, `package-lock.json`,
`frontend-modern/vite.config.ts`, `go.mod`, and `go.sum` must remain governed
with that entrypoint boundary rather than floating as unowned dependency or
build-runtime drift.
Security-driven lockfile bumps for packages shipped in the release frontend
are part of the same governed bootstrap input even when the package manifest
range already permits the newer version; the lockfile must identify the
resolved package version and integrity that the release build will actually
consume.
When the managed launcher reports runtime status, it must tell operators which
browser URL to use and whether the frontend shell, proxied API path, and
direct backend health endpoint all agree, instead of leaving `5173` versus
@ -989,6 +994,11 @@ setup-token-authenticated endpoint or depend on scraping a plaintext
`.bootstrap_token` file just to call it. The supported operator retrieval path
for first-session bootstrap is `pulse bootstrap-token`, and runtime bootstrap
token persistence must stay encrypted at rest.
Root installer completion output, LXC post-install guidance, and copied
first-session setup instructions must also route operators through
`pulse bootstrap-token` with the correct runtime data directory instead of
printing or instructing users to `cat` `.bootstrap_token`, because the file is
an encrypted persistence artifact rather than the raw setup token.
That same bootstrap artifact contract must now be backend-owned as one
canonical install artifact model rather than a handler-local bootstrap struct
plus a second response envelope. Shell downloads, setup-script-url responses,

View file

@ -96,6 +96,14 @@ Email single-alert, grouped, resolved, and HTML send paths must follow that
same ownership rule: they may expose different calling surfaces, but they must
all route through one canonical enhanced email executor instead of rebuilding
separate manager/config setup paths.
That enhanced email executor owns the production-manager reuse boundary as
well as the transport send itself. A test or ad hoc send whose SMTP host, port,
username, password, TLS, STARTTLS, or provider differs from the shared manager
must build an isolated delivery manager and leave the production manager
untouched, so unsaved relay-mode tests cannot inherit stale saved SMTP auth.
When the transport identity matches, the shared manager may still update
From/To and rate-limit presentation state so grouped and resolved sends keep
their persistent limiter continuity.
Notification test APIs must follow that same truthfulness rule: test email and
webhook paths may keep dedicated top-level entry points, but they must route
through canonical error-returning single-delivery executors instead of

View file

@ -1866,7 +1866,9 @@ create_lxc_container() {
fi
echo
echo " First-time setup:"
echo " pct exec $CTID -- cat $CONFIG_DIR/.bootstrap_token # Get bootstrap token"
local QUOTED_CONFIG_DIR
printf -v QUOTED_CONFIG_DIR '%q' "$CONFIG_DIR"
echo " pct exec $CTID -- env PULSE_DATA_DIR=$QUOTED_CONFIG_DIR pulse bootstrap-token"
echo
echo " Common commands:"
echo " pct enter $CTID # Enter container"
@ -3786,17 +3788,36 @@ print_completion() {
local TOKEN_DATA_DIR="${CONFIG_DIR:-/etc/pulse}"
local TOKEN_FILE="$TOKEN_DATA_DIR/.bootstrap_token"
if [[ -f "$TOKEN_FILE" ]]; then
BOOTSTRAP_TOKEN=$(cat "$TOKEN_FILE" 2>/dev/null | tr -d '\n')
if [[ -n "$BOOTSTRAP_TOKEN" ]]; then
echo
local -a BOOTSTRAP_TOKEN_COMMAND=()
if [[ -x "$BINARY_LINK_PATH" ]]; then
BOOTSTRAP_TOKEN_COMMAND=("$BINARY_LINK_PATH" "bootstrap-token")
elif [[ -x "$INSTALL_DIR/bin/pulse" ]]; then
BOOTSTRAP_TOKEN_COMMAND=("$INSTALL_DIR/bin/pulse" "bootstrap-token")
elif command -v pulse >/dev/null 2>&1; then
BOOTSTRAP_TOKEN_COMMAND=("$(command -v pulse)" "bootstrap-token")
fi
echo
if [[ ${#BOOTSTRAP_TOKEN_COMMAND[@]} -gt 0 ]]; then
if ! PULSE_DATA_DIR="$TOKEN_DATA_DIR" "${BOOTSTRAP_TOKEN_COMMAND[@]}"; then
local QUOTED_TOKEN_DATA_DIR
local TOKEN_COMMAND_DISPLAY
printf -v QUOTED_TOKEN_DATA_DIR '%q' "$TOKEN_DATA_DIR"
printf -v TOKEN_COMMAND_DISPLAY '%q ' "${BOOTSTRAP_TOKEN_COMMAND[@]}"
TOKEN_COMMAND_DISPLAY="${TOKEN_COMMAND_DISPLAY% }"
print_warn "Bootstrap token exists but could not be displayed automatically."
print_info "Run: PULSE_DATA_DIR=$QUOTED_TOKEN_DATA_DIR $TOKEN_COMMAND_DISPLAY"
fi
else
echo -e "${YELLOW}╔═══════════════════════════════════════════════════════════════════════╗${NC}"
echo -e "${YELLOW}║ BOOTSTRAP TOKEN REQUIRED FOR FIRST-TIME SETUP ║${NC}"
echo -e "${YELLOW}╠═══════════════════════════════════════════════════════════════════════╣${NC}"
printf "${YELLOW}${NC} Token: ${GREEN}%-61s${YELLOW}${NC}\n" "$BOOTSTRAP_TOKEN"
printf "${YELLOW}${NC} File: %-61s${YELLOW}${NC}\n" "$TOKEN_FILE"
echo -e "${YELLOW}${NC} Run this command on the Pulse host to reveal the setup token: ${YELLOW}${NC}"
printf "${YELLOW}${NC} PULSE_DATA_DIR=%-47s${YELLOW}${NC}\n" "$TOKEN_DATA_DIR"
printf "${YELLOW}${NC} %s/bin/pulse bootstrap-token%-37s${YELLOW}${NC}\n" "$INSTALL_DIR" ""
echo -e "${YELLOW}╠═══════════════════════════════════════════════════════════════════════╣${NC}"
echo -e "${YELLOW}${NC} Copy this token and paste it into the unlock screen in your browser. ${YELLOW}${NC}"
echo -e "${YELLOW}${NC} This token will be automatically deleted after successful setup. ${YELLOW}${NC}"
echo -e "${YELLOW}${NC} Paste the token into the unlock screen in your browser. ${YELLOW}${NC}"
echo -e "${YELLOW}${NC} This token will be automatically deleted after successful setup. ${YELLOW}${NC}"
echo -e "${YELLOW}╚═══════════════════════════════════════════════════════════════════════╝${NC}"
fi
fi

View file

@ -2658,20 +2658,28 @@ func (m *Manager) ShouldSuppressResolvedNotification(alert *Alert) bool {
return suppressed
}
// shouldNotifyAfterCooldown checks if enough time has passed since the last notification
// Returns true if notification should be sent, false if still in cooldown period
// shouldNotifyAfterCooldown decides whether a re-notification can be sent for
// an existing alert based on the configured cooldown.
//
// Cooldown semantics:
// - cooldown > 0: re-notify after that many minutes have passed since the
// last notification.
// - cooldown <= 0: cooldown is disabled, so only the first notification for
// an alert occurrence is allowed. The alert evaluation loop runs every
// metric tick, so treating disabled cooldown as "always allow" causes
// repeated notifications for the same active alert.
//
// Level-escalation re-notifications are handled separately at the call site.
// Returns true if a notification should be sent, false otherwise.
func (m *Manager) shouldNotifyAfterCooldown(alert *Alert) bool {
// If cooldown is 0 or negative, always allow notifications
if m.config.Schedule.Cooldown <= 0 {
return true
}
// If this is the first notification, allow it
if alert.LastNotified == nil {
return true
}
// Check if enough time has passed
if m.config.Schedule.Cooldown <= 0 {
return false
}
cooldownDuration := time.Duration(m.config.Schedule.Cooldown) * time.Minute
timeSinceLastNotification := time.Since(*alert.LastNotified)

View file

@ -4776,8 +4776,7 @@ func TestApplyRelaxedGuestThresholds(t *testing.T) {
func TestShouldNotifyAfterCooldown(t *testing.T) {
// t.Parallel()
t.Run("cooldown disabled allows notification", func(t *testing.T) {
// t.Parallel()
t.Run("cooldown disabled allows first-time notification", func(t *testing.T) {
m := newTestManager(t)
m.mu.Lock()
m.config.Schedule.Cooldown = 0
@ -4789,12 +4788,28 @@ func TestShouldNotifyAfterCooldown(t *testing.T) {
}
if !m.shouldNotifyAfterCooldown(alert) {
t.Error("expected true when cooldown is 0")
t.Error("expected true for first-time alert when cooldown is 0")
}
})
t.Run("negative cooldown allows notification", func(t *testing.T) {
// t.Parallel()
t.Run("cooldown disabled suppresses re-notification", func(t *testing.T) {
m := newTestManager(t)
m.mu.Lock()
m.config.Schedule.Cooldown = 0
m.mu.Unlock()
now := time.Now()
alert := &Alert{
ID: "test-alert",
LastNotified: &now,
}
if m.shouldNotifyAfterCooldown(alert) {
t.Error("expected false: cooldown=0 must not allow re-notification spam (#1444)")
}
})
t.Run("negative cooldown suppresses re-notification", func(t *testing.T) {
m := newTestManager(t)
m.mu.Lock()
m.config.Schedule.Cooldown = -5
@ -4806,8 +4821,8 @@ func TestShouldNotifyAfterCooldown(t *testing.T) {
LastNotified: &now,
}
if !m.shouldNotifyAfterCooldown(alert) {
t.Error("expected true when cooldown is negative")
if m.shouldNotifyAfterCooldown(alert) {
t.Error("expected false: negative cooldown is treated the same as disabled cooldown")
}
})

View file

@ -324,8 +324,10 @@ func TestSendGroupedEmail_UsesFromAsRecipient(t *testing.T) {
}
config := EmailConfig{
From: "sender@example.com",
To: nil,
From: "sender@example.com",
To: nil,
SMTPHost: "smtp.example.com",
SMTPPort: 25,
}
alertList := []*alerts.Alert{
@ -360,7 +362,7 @@ func TestSendEmail_UsesSharedDeliveryExecutor(t *testing.T) {
EmailConfig: EmailConfig{
From: "old@example.com",
To: []string{"old-recipient@example.com"},
SMTPHost: "old.localhost.test",
SMTPHost: "smtp.example.com",
SMTPPort: 25,
},
})

View file

@ -1660,6 +1660,49 @@ func effectiveEmailRecipients(config EmailConfig) []string {
return recipients
}
func newEmailDeliveryManager(config EmailConfig, recipients []string) *EnhancedEmailManager {
rl := effectiveEmailRateLimit(config.RateLimit)
return NewEnhancedEmailManager(EmailProviderConfig{
EmailConfig: EmailConfig{
Provider: config.Provider,
From: config.From,
To: recipients,
SMTPHost: config.SMTPHost,
SMTPPort: config.SMTPPort,
Username: config.Username,
Password: config.Password,
TLS: config.TLS,
StartTLS: config.StartTLS,
RateLimit: config.RateLimit,
},
Provider: config.Provider,
StartTLS: config.StartTLS,
MaxRetries: 2,
RetryDelay: 3,
RateLimit: rl,
SkipTLSVerify: false,
AuthRequired: config.Username != "" && config.Password != "",
})
}
func emailDeliveryTransportMatches(manager *EnhancedEmailManager, config EmailConfig) bool {
if manager == nil {
return false
}
persisted := manager.config.EmailConfig
persistedProvider := manager.config.Provider
if persistedProvider == "" {
persistedProvider = persisted.Provider
}
return persisted.SMTPHost == config.SMTPHost &&
persisted.SMTPPort == config.SMTPPort &&
persisted.Username == config.Username &&
persisted.Password == config.Password &&
persisted.TLS == config.TLS &&
persisted.StartTLS == config.StartTLS &&
persistedProvider == config.Provider
}
func (n *NotificationManager) emailDeliveryManager(config EmailConfig) (*EnhancedEmailManager, []string) {
recipients := effectiveEmailRecipients(config)
@ -1668,38 +1711,24 @@ func (n *NotificationManager) emailDeliveryManager(config EmailConfig) (*Enhance
n.mu.RUnlock()
if manager == nil {
rl := effectiveEmailRateLimit(config.RateLimit)
manager = NewEnhancedEmailManager(EmailProviderConfig{
EmailConfig: EmailConfig{
From: config.From,
To: recipients,
SMTPHost: config.SMTPHost,
SMTPPort: config.SMTPPort,
Username: config.Username,
Password: config.Password,
TLS: config.TLS,
StartTLS: config.StartTLS,
},
Provider: config.Provider,
StartTLS: config.StartTLS,
MaxRetries: 2,
RetryDelay: 3,
RateLimit: rl,
SkipTLSVerify: false,
AuthRequired: config.Username != "" && config.Password != "",
})
return manager, recipients
return newEmailDeliveryManager(config, recipients), recipients
}
if !emailDeliveryTransportMatches(manager, config) {
return newEmailDeliveryManager(config, recipients), recipients
}
manager.config.EmailConfig = EmailConfig{
From: config.From,
To: recipients,
SMTPHost: config.SMTPHost,
SMTPPort: config.SMTPPort,
Username: config.Username,
Password: config.Password,
TLS: config.TLS,
StartTLS: config.StartTLS,
Provider: config.Provider,
From: config.From,
To: recipients,
SMTPHost: config.SMTPHost,
SMTPPort: config.SMTPPort,
Username: config.Username,
Password: config.Password,
TLS: config.TLS,
StartTLS: config.StartTLS,
RateLimit: config.RateLimit,
}
manager.config.Provider = config.Provider
manager.config.StartTLS = config.StartTLS

View file

@ -483,9 +483,11 @@ func TestSendResolvedNotificationsDirectDispatchesEnabledTargets(t *testing.T) {
manager.sendResolvedNotificationsDirect(
EmailConfig{
Enabled: true,
From: "new@example.com",
To: []string{"new@example.com"},
Enabled: true,
From: "new@example.com",
To: []string{"new@example.com"},
SMTPHost: "invalid.localhost.test",
SMTPPort: 25,
},
[]WebhookConfig{
{Name: "enabled", URL: server.URL + "/ok", Enabled: true, Service: "ntfy"},

View file

@ -2800,7 +2800,7 @@ func TestSendHTMLEmailWithError_ExistingEmailManager(t *testing.T) {
EmailConfig: EmailConfig{
From: "old@example.com",
To: []string{"old-recipient@example.com"},
SMTPHost: "old.localhost.test",
SMTPHost: "invalid.localhost.test",
SMTPPort: 25,
},
})
@ -2813,21 +2813,65 @@ func TestSendHTMLEmailWithError_ExistingEmailManager(t *testing.T) {
From: "new@example.com",
To: []string{"new-recipient@example.com"},
SMTPHost: "invalid.localhost.test",
SMTPPort: 587,
SMTPPort: 25,
}
// Will fail but exercises the "update existing manager config" path
// Will fail but exercises the "update existing manager config" path when the
// passed config matches the persisted SMTP target.
err := nm.sendHTMLEmailWithError("Test Subject", "<p>test</p>", "test", config)
if err == nil {
t.Error("expected error for invalid SMTP host")
}
// Verify the manager's config was updated
// Verify the manager's From/To were updated on the shared delivery path.
if existingManager.config.EmailConfig.From != "new@example.com" {
t.Errorf("expected From to be updated to 'new@example.com', got %q", existingManager.config.EmailConfig.From)
}
}
func TestSendHTMLEmailWithError_DifferingConfigDoesNotMutateManager(t *testing.T) {
existingManager := NewEnhancedEmailManager(EmailProviderConfig{
EmailConfig: EmailConfig{
From: "saved@example.com",
To: []string{"saved-recipient@example.com"},
SMTPHost: "saved.localhost.test",
SMTPPort: 587,
Username: "saved-user",
Password: "saved-pass",
},
AuthRequired: true,
})
nm := &NotificationManager{
emailManager: existingManager,
}
config := EmailConfig{
From: "test@example.com",
To: []string{"test-recipient@example.com"},
SMTPHost: "invalid.localhost.test",
SMTPPort: 25,
}
err := nm.sendHTMLEmailWithError("Test Subject", "<p>test</p>", "test", config)
if err == nil {
t.Error("expected error for unreachable SMTP host")
}
if got := existingManager.config.EmailConfig.SMTPHost; got != "saved.localhost.test" {
t.Errorf("shared manager SMTPHost was mutated: got %q, want %q", got, "saved.localhost.test")
}
if got := existingManager.config.EmailConfig.Username; got != "saved-user" {
t.Errorf("shared manager Username was mutated: got %q, want %q", got, "saved-user")
}
if got := existingManager.config.EmailConfig.From; got != "saved@example.com" {
t.Errorf("shared manager From was mutated: got %q, want %q", got, "saved@example.com")
}
if !existingManager.config.AuthRequired {
t.Error("shared manager AuthRequired was cleared by a test send")
}
}
func TestSendNotificationsDirect_MultipleWebhooks(t *testing.T) {
origSpawn := spawnAsync
spawnAsync = func(func()) {}

View file

@ -1954,6 +1954,79 @@ func TestBuildPrintedManagementCommandPreservesRCChannel(t *testing.T) {
}
}
func TestRootPrintCompletionRevealsBootstrapTokenThroughCLI(t *testing.T) {
tmpDir := t.TempDir()
configDir := filepath.Join(tmpDir, "config")
installDir := filepath.Join(tmpDir, "install")
pulseBin := filepath.Join(tmpDir, "bin", "pulse")
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}
if err := os.MkdirAll(filepath.Dir(pulseBin), 0755); err != nil {
t.Fatalf("mkdir pulse bin dir: %v", err)
}
if err := os.WriteFile(filepath.Join(configDir, ".bootstrap_token"), []byte(`{"version":2,"token_ciphertext":"encrypted-token","token_hash":"hash"}`), 0600); err != nil {
t.Fatalf("write encrypted bootstrap token marker: %v", err)
}
if err := os.WriteFile(pulseBin, []byte(`#!/usr/bin/env bash
if [[ "$1" != "bootstrap-token" ]]; then
exit 2
fi
printf 'Token: raw-bootstrap-token\n'
printf 'Data: %s\n' "$PULSE_DATA_DIR"
`), 0755); err != nil {
t.Fatalf("write fake pulse binary: %v", err)
}
script := `
RED=''
GREEN=''
YELLOW=''
BLUE=''
NC=''
CONFIG_DIR="$TEST_CONFIG_DIR"
INSTALL_DIR="$TEST_INSTALL_DIR"
BINARY_LINK_PATH="$TEST_PULSE_BIN"
UPDATE_HELPER_PATH="/bin/update"
SERVICE_NAME="pulse"
UPDATE_TIMER_UNIT="pulse-update.timer"
hostname() { if [[ "${1:-}" == "-I" ]]; then printf '127.0.0.1\n'; else command hostname "$@"; fi; }
current_frontend_port() { printf '7655\n'; }
print_header() { :; }
print_success() { printf '%s\n' "$1"; }
print_warn() { printf 'WARN: %s\n' "$1"; }
print_info() { printf 'INFO: %s\n' "$1"; }
update_timer_exists() { return 1; }
update_timer_enabled() { return 1; }
build_printed_management_command() { printf '/bin/update\n'; }
` + extractRootInstallShellFunction(t, "print_completion") + `
print_completion
`
cmd := exec.Command("bash", "-c", script)
cmd.Env = append(os.Environ(),
"TEST_CONFIG_DIR="+configDir,
"TEST_INSTALL_DIR="+installDir,
"TEST_PULSE_BIN="+pulseBin,
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("bash: %v\n%s", err, out)
}
got := string(out)
if !strings.Contains(got, "raw-bootstrap-token") {
t.Fatalf("completion output did not include revealed token:\n%s", got)
}
if !strings.Contains(got, "Data: "+configDir) {
t.Fatalf("completion output did not pass PULSE_DATA_DIR to bootstrap-token:\n%s", got)
}
if strings.Contains(got, "encrypted-token") || strings.Contains(got, "token_ciphertext") {
t.Fatalf("completion output leaked encrypted bootstrap file contents:\n%s", got)
}
}
func TestBuildPrintedManagementCommandPreservesForcedVersion(t *testing.T) {
script := `
GITHUB_REPO="rcourtman/Pulse"

View file

@ -172,6 +172,35 @@ func TestRootInstallScriptAutoRegisterUsesSecureContractShape(t *testing.T) {
}
}
func TestRootInstallShowsBootstrapTokenCommandInsteadOfEncryptedFile(t *testing.T) {
content, err := os.ReadFile(filepath.Join("..", "..", "install.sh"))
if err != nil {
t.Fatalf("read root install.sh: %v", err)
}
script := string(content)
required := []string{
`pulse bootstrap-token`,
`PULSE_DATA_DIR=`,
}
for _, needle := range required {
if !strings.Contains(script, needle) {
t.Fatalf("root install.sh missing bootstrap-token display fragment: %s", needle)
}
}
forbidden := []string{
`cat $CONFIG_DIR/.bootstrap_token`,
`cat "$TOKEN_FILE"`,
`Token: ${GREEN}`,
}
for _, needle := range forbidden {
if strings.Contains(script, needle) {
t.Fatalf("root install.sh still exposes encrypted bootstrap file contents: %s", needle)
}
}
}
func TestPrereleaseUpdateCopyUsesPreviewFraming(t *testing.T) {
rootInstall, err := os.ReadFile(filepath.Join("..", "..", "install.sh"))
if err != nil {