v0.184.0 — E-2b + Part 5: wire the drive-absent alarm that was never called

NotifyStorageDisconnected and NotifyStorageReconnected were defined and called
from NOWHERE. Registered in allowedEventTypes, in DefaultEnabledEvents, and given
a Hungarian message on the hub -- and never invoked. A drive going absent produced
apps stopped, a WARN log and a UI badge, then silence on every channel. Verified
against the gitignored-cmd/ trap with a positive control. Fifth instance of this
class, found by E-2 Phase 0 rather than by a failure.

A drive that is ONLY a backup target has no apps to stop, so it was silent twice.

ReconcileDriveGates now calls both halves. When the absent drive is the whole-guest
backup target it raises the more specific backup_target_absent (error) instead --
never both, since two mails for one event trains people to ignore the channel --
and recovers as backup_target_restored (info, the existing pairing-gated pattern;
severityNotifies NOT widened). The recovery mirrors the alarm's choice or the
operator cannot match them.

Which drive is the target comes from the AGENT (/disks backup_target, >= 0.112.0),
not from our StoragePath.BackupTarget: that is customer INTENT, and on the two
boxes migrated by hand in E-1 the intent was never recorded while the drive really
is the target. An older agent omits the field -> false -> generic alarm, never a
wrong one.

Before this an absent backup target had NO prompt signal: the tier stays DUE
(targetStoragePresent checks name presence, never reachability), so the only
evidence was its own failure at the next due cycle, up to ~24h away. The R-100
shape.

Tests observe the WIRE, not a mock, because the failure class is "nothing
arrives": a real Notifier posts to an httptest hub and the test asserts the event
type and severity that actually went out.

MinAgent: 0.112.0
Green gate: build + vet + test rc=0 (27 packages), run separately from this commit.
This commit is contained in:
2026-07-29 08:21:25 +02:00
parent ff058a4f10
commit c1a63de1c7
5 changed files with 228 additions and 0 deletions
@@ -0,0 +1,103 @@
package notify
import (
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// capturedEvent is one POST /api/v1/event body the hub would have received.
type capturedEvent struct {
EventType string `json:"event_type"`
Severity string `json:"severity"`
Message string `json:"message"`
}
// notifierAgainstHub wires a REAL Notifier at an httptest hub and returns a channel of what actually
// went over the wire. The whole failure class E-2b addresses is "the method exists and nobody calls
// it / nothing arrives", so the test has to observe the WIRE, not a mock's call count.
func notifierAgainstHub(t *testing.T) (*Notifier, <-chan capturedEvent) {
t.Helper()
got := make(chan capturedEvent, 8)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var ev capturedEvent
_ = json.Unmarshal(body, &ev)
got <- ev
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatalf("settings: %v", err)
}
n := New(srv.URL, "test-key", "demo-hp", sett, log.New(os.Stderr, "", 0), false)
return n, got
}
func awaitEvent(t *testing.T, ch <-chan capturedEvent) capturedEvent {
t.Helper()
select {
case ev := <-ch:
return ev
case <-time.After(3 * time.Second):
t.Fatal("no event reached the hub within 3s — the notification never went out")
return capturedEvent{}
}
}
// E-2 Part 5 — the absent backup target must emit its OWN event type, at error severity.
//
// It has to be `backup_target_absent` specifically: the hub allowlists that string, and an event type
// the hub does not allowlist is answered 400 and VANISHES (R-97a). A typo here is not a cosmetic bug,
// it is total silence — the exact condition E-2 exists to end.
func TestBackupTargetAbsentEmitsItsOwnEventType(t *testing.T) {
n, got := notifierAgainstHub(t)
n.NotifyBackupTargetAbsent("Külső HDD 1TB", "/mnt/hdd_1")
ev := awaitEvent(t, got)
if ev.EventType != "backup_target_absent" {
t.Errorf("event_type = %q, want backup_target_absent (any other string is 400'd by the hub and lost)", ev.EventType)
}
if ev.Severity != "error" {
t.Errorf("severity = %q, want error — a lost whole-system backup is not a warning", ev.Severity)
}
if ev.Message == "" {
t.Error("message is empty — the operator mail would name no drive")
}
}
// The recovery half. `info` severity is deliberate and load-bearing: the recovery-mail pairing is
// gated on it, and widening severityNotifies to carry recoveries at warning/error would page the
// operator for good news across every event family, not just this one.
func TestBackupTargetRestoredIsPairedAndInfo(t *testing.T) {
n, got := notifierAgainstHub(t)
n.NotifyBackupTargetRestored("Külső HDD 1TB", "/mnt/hdd_1")
ev := awaitEvent(t, got)
if ev.EventType != "backup_target_restored" {
t.Errorf("event_type = %q, want backup_target_restored", ev.EventType)
}
if ev.Severity != "info" {
t.Errorf("severity = %q, want info — recovery mails are pairing-gated on info", ev.Severity)
}
}
// E-2b — the seam that was defined and never called. This pins that NotifyStorageDisconnected
// actually reaches the hub, which it never did before E-2: it was registered in allowedEventTypes,
// DefaultEnabledEvents and the hub's Hungarian customerMessages, and called from nowhere in the repo.
func TestStorageDisconnectedActuallyReachesTheHub(t *testing.T) {
n, got := notifierAgainstHub(t)
n.NotifyStorageDisconnected("Külső HDD 1TB", []string{"paperless"})
ev := awaitEvent(t, got)
if ev.EventType != "storage_disconnected" {
t.Errorf("event_type = %q, want storage_disconnected", ev.EventType)
}
}