Files
admin added9d226 v0.81.0: NAS verify-before-commit — retry=0, journal classifier, detached verify job + auto-rollback
Agent half of the verify-before-commit task (SPIKE-nas-verify-2026-07-11, b57f6c1):
retry=0 in the production NFS options (Q4-vi); ClassifyNetVerifyFailure on the live
Q4 strings (nfs_export merges not-found/not-permitted); add = sync fast-fail (2s TCP
pre-probe, nothing installed) + detached in-memory verify job judging /proc/mounts
only, auto-rollback on failure; GET /netstorage/verify-status (phase none = the
controller's Scenario-F rollback signal); unprivileged journalctl (systemd-journal
group, NO new sudoers grants).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-11 09:44:06 +02:00

209 lines
7.5 KiB
Go

package localapi
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
)
// fakeNetOps records the network-mount surface calls (never touches a real host).
type fakeNetOps struct {
ensured []storage.NetworkMountSpec
removed []string
list []storage.NetworkMountStatus
ensErr error
}
func (f *fakeNetOps) EnsureNetworkMount(_ context.Context, s storage.NetworkMountSpec) error {
f.ensured = append(f.ensured, s)
return f.ensErr
}
func (f *fakeNetOps) RemoveNetworkMount(_ context.Context, n string) error {
f.removed = append(f.removed, n)
return nil
}
func (f *fakeNetOps) ListNetworkMounts(_ context.Context) ([]storage.NetworkMountStatus, error) {
return f.list, nil
}
func newNetServer(t *testing.T, n NetworkStorageOps, credsDir string) *Server {
t.Helper()
srv, err := NewServer(Options{
ListenAddr: "127.0.0.1:0",
Guests: &fakeGuests{},
Backups: &fakeBackups{},
Store: &fakeStore{},
Storage: fakeStorage{},
Tokens: staticTokens{"A": 8200, "B": 9300},
NetStorage: n,
SmbCredsDir: credsDir,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
t.Fatalf("new server: %v", err)
}
// Hermetic verify seams: no TCP dial, no /proc/mounts, no journalctl. Verify-specific tests
// (netverifyjob_test.go) override the rows they exercise.
srv.netReachable = func(storage.NetworkProtocol, string) bool { return true }
srv.netTrigger = func(string) error { return nil }
srv.netMounted = func(string) bool { return true }
srv.netJournal = func(context.Context, string) (string, error) { return "", nil }
return srv
}
func TestNetStorage_AddNFS_HappyPath(t *testing.T) {
n := &fakeNetOps{}
h := newNetServer(t, n, t.TempDir()).Handler()
body := `{"name":"media","protocol":"nfs","server":"10.0.0.5","export":"/srv/media","mapped_uid":1000,"mapped_gid":1000}`
w := do(t, h, "POST", "/netstorage/add", "A", body)
if w.Code != http.StatusOK {
t.Fatalf("add NFS: got %d want 200 (%s)", w.Code, w.Body.String())
}
if len(n.ensured) != 1 {
t.Fatalf("EnsureNetworkMount not called once: %v", n.ensured)
}
got := n.ensured[0]
if got.Name != "media" || got.Protocol != storage.ProtocolNFS || got.Server != "10.0.0.5" || got.Export != "/srv/media" {
t.Fatalf("spec mismatch: %+v", got)
}
if got.HostUID() != 101000 {
t.Errorf("host uid = %d want 101000 (the +100000 recipe)", got.HostUID())
}
}
func TestNetStorage_AddSMB_WritesCreds0600(t *testing.T) {
credsDir := t.TempDir()
n := &fakeNetOps{}
h := newNetServer(t, n, credsDir).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 SMB: got %d want 200 (%s)", w.Code, w.Body.String())
}
if len(n.ensured) != 1 || n.ensured[0].CredsRef == "" {
t.Fatalf("SMB spec must carry a creds ref: %+v", n.ensured)
}
credsPath := filepath.Join(credsDir, "vids.cred")
info, err := os.Stat(credsPath)
if err != nil {
t.Fatalf("creds file not written: %v", err)
}
// 0600 is enforced on Linux (the production OS); Windows does not honor Unix perms, so the perm
// assertion runs on the Linux build server where it matters.
if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
t.Errorf("creds file is group/other-readable (mode %v) — must be 0600", info.Mode().Perm())
}
data, _ := os.ReadFile(credsPath)
if !strings.Contains(string(data), "username=u") || !strings.Contains(string(data), "password=p") {
t.Errorf("creds file content wrong: %q", data)
}
// The secret must NOT appear in the response body.
if strings.Contains(w.Body.String(), "\"p\"") || strings.Contains(w.Body.String(), "password") {
t.Errorf("response leaked credentials: %s", w.Body.String())
}
}
func TestNetStorage_AddSMB_MissingCreds_Refused(t *testing.T) {
n := &fakeNetOps{}
h := newNetServer(t, n, t.TempDir()).Handler()
body := `{"name":"vids","protocol":"smb","server":"nas","export":"vids","mapped_uid":1000,"mapped_gid":1000}`
w := do(t, h, "POST", "/netstorage/add", "A", body)
if w.Code != http.StatusBadRequest {
t.Fatalf("SMB without creds: got %d want 400", w.Code)
}
if len(n.ensured) != 0 {
t.Fatal("EnsureNetworkMount must not be called when creds are missing")
}
}
// TestNetStorage_RoleGate_NonUserDataRefused: a mount root outside the user-data namespace is refused
// (403) and the mount surface is never touched — network storage is bulk-userdata ONLY.
func TestNetStorage_RoleGate_NonUserDataRefused(t *testing.T) {
n := &fakeNetOps{}
srv := newNetServer(t, n, t.TempDir())
srv.netMountRoot = "/srv/system" // a non-user-data path
h := srv.Handler()
body := `{"name":"media","protocol":"nfs","server":"10.0.0.5","export":"/srv/media","mapped_uid":1000,"mapped_gid":1000}`
w := do(t, h, "POST", "/netstorage/add", "A", body)
if w.Code != http.StatusForbidden {
t.Fatalf("non-user-data path: got %d want 403 (%s)", w.Code, w.Body.String())
}
if len(n.ensured) != 0 {
t.Fatal("a role-gated request must not reach EnsureNetworkMount")
}
}
func TestNetStorage_List(t *testing.T) {
n := &fakeNetOps{list: []storage.NetworkMountStatus{
{Name: "media", Protocol: "nfs", Server: "10.0.0.5", Where: "/mnt/felhom-drives/media", Configured: true, Mounted: true, Reachable: true, Health: storage.NetHealthOK},
}}
h := newNetServer(t, n, t.TempDir()).Handler()
w := do(t, h, "GET", "/netstorage", "A", "")
if w.Code != http.StatusOK {
t.Fatalf("list: got %d want 200 (%s)", w.Code, w.Body.String())
}
var resp struct {
Data struct {
NetworkMounts []storage.NetworkMountStatus `json:"network_mounts"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Data.NetworkMounts) != 1 || resp.Data.NetworkMounts[0].Health != storage.NetHealthOK {
t.Fatalf("list payload wrong: %+v", resp.Data.NetworkMounts)
}
}
func TestNetStorage_Remove(t *testing.T) {
credsDir := t.TempDir()
// Pre-stage a creds file to prove removal cleans it up.
credsPath := filepath.Join(credsDir, "media.cred")
if err := os.WriteFile(credsPath, []byte("username=u\npassword=p\n"), 0o600); err != nil {
t.Fatal(err)
}
n := &fakeNetOps{}
h := newNetServer(t, n, credsDir).Handler()
w := do(t, h, "POST", "/netstorage/remove", "A", `{"name":"media"}`)
if w.Code != http.StatusOK {
t.Fatalf("remove: got %d want 200 (%s)", w.Code, w.Body.String())
}
if len(n.removed) != 1 || n.removed[0] != "media" {
t.Fatalf("RemoveNetworkMount not called: %v", n.removed)
}
if _, err := os.Stat(credsPath); !os.IsNotExist(err) {
t.Errorf("creds file should be removed, stat err = %v", err)
}
}
func TestNetStorage_NotConfigured(t *testing.T) {
h := newNetServer(t, nil, "").Handler() // NetStorage nil
for _, tc := range []struct{ method, path, body string }{
{"POST", "/netstorage/add", `{"name":"media","protocol":"nfs","server":"x","export":"/y","mapped_uid":1000,"mapped_gid":1000}`},
{"GET", "/netstorage", ""},
{"POST", "/netstorage/remove", `{"name":"media"}`},
} {
w := do(t, h, tc.method, tc.path, "A", tc.body)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("%s %s with no NetStorage: got %d want 503", tc.method, tc.path, w.Code)
}
}
}
func TestNetStorage_RequiresAuth(t *testing.T) {
h := newNetServer(t, &fakeNetOps{}, t.TempDir()).Handler()
w := do(t, h, "GET", "/netstorage", "", "")
if w.Code != http.StatusUnauthorized {
t.Fatalf("no token: got %d want 401", w.Code)
}
}