Files
admin 88b3cf03dd gofmt: normalize internal/localapi (whitespace only)
Swept up by gofmt -w on the package while adding the guest-power observable. No
semantic change; 3 of 5 files are tests.
2026-07-28 11:15:45 +02:00

273 lines
11 KiB
Go

package localapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
)
// Verify-job tests (SPIKE-nas-verify): the detached pipeline runs entirely through seams — no
// systemctl, no journalctl, no /proc/mounts, no network. fakeNetOps (netstorage_test.go) records
// the mount-surface effects the assertions check.
// verifySeams configures a Server's verify pipeline for tests.
type verifySeams struct {
trigger func(where string) error
mounted func(where string) bool
journal func(ctx context.Context, unit string) (string, error)
reachable func(proto storage.NetworkProtocol, server string) bool
}
func newVerifyServer(t *testing.T, n NetworkStorageOps, credsDir string, seams verifySeams) *Server {
t.Helper()
srv := newNetServer(t, n, credsDir)
if seams.trigger != nil {
srv.netTrigger = seams.trigger
} else {
srv.netTrigger = func(string) error { return nil } // default: instant, successful read
}
if seams.mounted != nil {
srv.netMounted = seams.mounted
}
if seams.journal != nil {
srv.netJournal = seams.journal
} else {
srv.netJournal = func(context.Context, string) (string, error) { return "", nil }
}
if seams.reachable != nil {
srv.netReachable = seams.reachable
} else {
srv.netReachable = func(storage.NetworkProtocol, string) bool { return true }
}
return srv
}
// pollVerify polls GET /netstorage/verify-status until the job leaves `running` (or the deadline).
func pollVerify(t *testing.T, h http.Handler) map[string]any {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
w := do(t, h, "GET", "/netstorage/verify-status", "A", "")
if w.Code != http.StatusOK {
t.Fatalf("verify-status: got %d (%s)", w.Code, w.Body.String())
}
var resp struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode status: %v", err)
}
if p, _ := resp.Data["phase"].(string); p != netVerifyPhaseRunning {
return resp.Data
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("verify job did not reach a terminal phase in time")
return nil
}
// --- A3: mount-FAILED ⇒ auto-rollback (units + creds) + failed{code} ---------------------------------
// Companion red-proof: drop the s.rollbackNetMount call from runNetVerify → the removed/creds
// assertions fail (the failed install would linger — today's bug shape).
func TestNetVerify_MountFailed_RollsBackAndClassifies(t *testing.T) {
credsDir := t.TempDir()
n := &fakeNetOps{}
srv := newVerifyServer(t, n, credsDir, verifySeams{
mounted: func(string) bool { return false }, // §8: not in /proc/mounts ⇒ FAILED
journal: func(context.Context, string) (string, error) {
return "mount error(13): Permission denied", nil // Q4 iv — smb_auth
},
})
h := srv.Handler()
body := `{"name":"vids","protocol":"smb","server":"nas","export":"vids","mapped_uid":1000,"mapped_gid":1000,"username":"u","password":"p"}`
w := do(t, h, "POST", "/netstorage/add", "A", body)
if w.Code != http.StatusOK {
t.Fatalf("add: got %d (%s)", w.Code, w.Body.String())
}
if len(n.ensured) != 1 {
t.Fatalf("EnsureNetworkMount calls = %d, want 1", len(n.ensured))
}
final := pollVerify(t, h)
if final["phase"] != netVerifyPhaseFailed {
t.Fatalf("phase = %v, want failed (%v)", final["phase"], final)
}
if final["code"] != storage.NetVerifySMBAuth {
t.Errorf("code = %v, want %q", final["code"], storage.NetVerifySMBAuth)
}
// The auto-rollback effects: the unit removal was called AND the creds file is gone.
if len(n.removed) != 1 || n.removed[0] != "vids" {
t.Errorf("RemoveNetworkMount not called for the failed install: removed=%v", n.removed)
}
if _, err := os.Stat(filepath.Join(credsDir, "vids.cred")); !os.IsNotExist(err) {
t.Errorf("creds file must be removed on verify failure (stat err = %v)", err)
}
}
// TestNetVerify_JournalUnavailable_DegradesButStillRollsBack: journal read error ⇒ generic
// mount_failed + the systemd-journal hint, and the rollback still runs (Scenario B degradation).
func TestNetVerify_JournalUnavailable_DegradesButStillRollsBack(t *testing.T) {
n := &fakeNetOps{}
srv := newVerifyServer(t, n, t.TempDir(), verifySeams{
mounted: func(string) bool { return false },
journal: func(context.Context, string) (string, error) {
return "", fmt.Errorf("journalctl: permission denied")
},
})
h := srv.Handler()
w := do(t, h, "POST", "/netstorage/add", "A",
`{"name":"media","protocol":"nfs","server":"10.0.0.5","export":"/srv/media","mapped_uid":1000,"mapped_gid":1000}`)
if w.Code != http.StatusOK {
t.Fatalf("add: got %d (%s)", w.Code, w.Body.String())
}
final := pollVerify(t, h)
if final["phase"] != netVerifyPhaseFailed || final["code"] != storage.NetVerifyMountFailed {
t.Fatalf("degraded classification: phase=%v code=%v, want failed/%q", final["phase"], final["code"], storage.NetVerifyMountFailed)
}
detail, _ := final["detail"].(string)
if want := "systemd-journal"; !containsStr(detail, want) {
t.Errorf("degraded detail must carry the group hint %q: %q", want, detail)
}
if len(n.removed) != 1 {
t.Errorf("rollback must still run when the journal is unavailable: removed=%v", n.removed)
}
}
// --- A4: the §8 truth table — /proc/mounts is the ONLY judge -----------------------------------------
// Companion red-proof: a readability-based verdict (trigger err == nil ⇒ OK) fails BOTH rows.
func TestNetVerify_TruthTable(t *testing.T) {
t.Run("ReadDir ok but NOT mounted = FAILED (empty-dir false positive guard)", func(t *testing.T) {
n := &fakeNetOps{}
srv := newVerifyServer(t, n, t.TempDir(), verifySeams{
trigger: func(string) error { return nil }, // the read "worked" (empty dir)
mounted: func(string) bool { return false }, // …but no nfs4/cifs in /proc/mounts
journal: func(context.Context, string) (string, error) { return "", nil },
})
h := srv.Handler()
w := do(t, h, "POST", "/netstorage/add", "A",
`{"name":"m1","protocol":"nfs","server":"10.0.0.5","export":"/srv/m1","mapped_uid":1000,"mapped_gid":1000}`)
if w.Code != http.StatusOK {
t.Fatalf("add: %d (%s)", w.Code, w.Body.String())
}
final := pollVerify(t, h)
if final["phase"] != netVerifyPhaseFailed {
t.Fatalf("a readable-but-unmounted path must FAIL verify, got %v", final["phase"])
}
if len(n.removed) != 1 {
t.Errorf("failed verify must roll back: removed=%v", n.removed)
}
})
t.Run("ReadDir EACCES but mounted = OK (0700 export on a good mount)", func(t *testing.T) {
n := &fakeNetOps{}
srv := newVerifyServer(t, n, t.TempDir(), verifySeams{
trigger: func(string) error { return os.ErrPermission }, // agent user can't read it — fine
mounted: func(string) bool { return true }, // the mount is REAL
})
h := srv.Handler()
w := do(t, h, "POST", "/netstorage/add", "A",
`{"name":"m2","protocol":"nfs","server":"10.0.0.5","export":"/srv/m2","mapped_uid":1000,"mapped_gid":1000}`)
if w.Code != http.StatusOK {
t.Fatalf("add: %d (%s)", w.Code, w.Body.String())
}
final := pollVerify(t, h)
if final["phase"] != netVerifyPhaseDone {
t.Fatalf("an unreadable-but-mounted share must PASS agent verify (writability is the controller probe's job), got %v (%v)", final["phase"], final)
}
if len(n.removed) != 0 {
t.Errorf("a passing verify must NOT roll back: removed=%v", n.removed)
}
})
}
// --- A5: the sync TCP pre-probe refuses BEFORE any install (Scenario E) ------------------------------
// Companion red-proof: move the pre-probe after EnsureNetworkMount → the zero-install assertion fails.
func TestNetVerify_UnreachablePreProbe_InstallsNothing(t *testing.T) {
credsDir := t.TempDir()
n := &fakeNetOps{}
srv := newVerifyServer(t, n, credsDir, verifySeams{
reachable: func(storage.NetworkProtocol, string) bool { return false },
})
h := srv.Handler()
w := do(t, h, "POST", "/netstorage/add", "A",
`{"name":"vids","protocol":"smb","server":"192.168.0.199","export":"vids","mapped_uid":1000,"mapped_gid":1000,"username":"u","password":"p"}`)
if w.Code != http.StatusBadGateway {
t.Fatalf("unreachable add: got %d want 502 (%s)", w.Code, w.Body.String())
}
var resp struct {
OK bool `json:"ok"`
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.OK || resp.Data["code"] != storage.NetVerifyUnreachable {
t.Errorf("refusal must carry code=unreachable: %s", w.Body.String())
}
// NOTHING installed: no Ensure call, no creds file, and the verify slot is free again.
if len(n.ensured) != 0 {
t.Errorf("EnsureNetworkMount must not run for an unreachable server: %v", n.ensured)
}
if _, err := os.Stat(filepath.Join(credsDir, "vids.cred")); !os.IsNotExist(err) {
t.Errorf("no creds file may be written for an unreachable server (stat err = %v)", err)
}
if got := srv.netVerifySnapshot(); got != nil {
t.Errorf("the verify slot must be released after a sync refusal: %+v", got)
}
}
// --- A6: single-flight + the "no job" shape ----------------------------------------------------------
// Companion red-proof: drop the tryStartNetVerify running-check → the 409 assertion fails.
func TestNetVerify_SingleFlight_AndNoJobShape(t *testing.T) {
// No job yet: the status endpoint must serve the Scenario-F "none" shape.
nIdle := &fakeNetOps{}
hIdle := newVerifyServer(t, nIdle, t.TempDir(), verifySeams{}).Handler()
w := do(t, hIdle, "GET", "/netstorage/verify-status", "A", "")
var idle struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &idle); err != nil {
t.Fatal(err)
}
if idle.Data["phase"] != netVerifyPhaseNone {
t.Fatalf("empty slot must report phase %q, got %v", netVerifyPhaseNone, idle.Data["phase"])
}
// Single-flight: hold the first verify open via a blocking trigger, then submit a second add.
release := make(chan struct{})
n := &fakeNetOps{}
srv := newVerifyServer(t, n, t.TempDir(), verifySeams{
trigger: func(string) error { <-release; return nil },
mounted: func(string) bool { return true },
})
h := srv.Handler()
w1 := do(t, h, "POST", "/netstorage/add", "A",
`{"name":"m1","protocol":"nfs","server":"10.0.0.5","export":"/srv/m1","mapped_uid":1000,"mapped_gid":1000}`)
if w1.Code != http.StatusOK {
t.Fatalf("first add: %d (%s)", w1.Code, w1.Body.String())
}
w2 := do(t, h, "POST", "/netstorage/add", "A",
`{"name":"m2","protocol":"nfs","server":"10.0.0.6","export":"/srv/m2","mapped_uid":1000,"mapped_gid":1000}`)
if w2.Code != http.StatusConflict {
t.Fatalf("second add while verifying: got %d want 409 (%s)", w2.Code, w2.Body.String())
}
if len(n.ensured) != 1 {
t.Errorf("the refused second add must not reach EnsureNetworkMount: %v", n.ensured)
}
close(release)
final := pollVerify(t, h)
if final["phase"] != netVerifyPhaseDone || final["name"] != "m1" {
t.Errorf("first job must finish unaffected: %v", final)
}
}
func containsStr(s, sub string) bool { return strings.Contains(s, sub) }