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) } }