mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-19 07:54:10 +00:00
parent
ef59055264
commit
af8a5f0740
14 changed files with 388 additions and 64 deletions
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
37
install.sh
37
install.sh
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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()) {}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue