feat(instance): public profile endpoint GET /api/instance (hub-app M1)
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package instance
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/federation"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||
)
|
||||
|
||||
func testServices() []types.Container {
|
||||
return []types.Container{
|
||||
{
|
||||
ID: "c1", Name: "keepnote-1", ServiceName: "keepnote", DockerName: "keepnote-1",
|
||||
Image: "registry.example/dcntrlized/keepnote:1.0", Version: "1.0",
|
||||
Status: "running", Title: "KeepNote", Description: "notes app",
|
||||
Capabilities: []string{"database"}, HasAuth: true,
|
||||
Network: types.Network{Ports: []types.PortMapping{{Host: 8081, Guest: 8080}}},
|
||||
},
|
||||
{ID: "c2", Name: "dcntrlized-db", Role: "db", Hidden: true, Status: "running"},
|
||||
{ID: "c3", Name: "dcntrlized-auth", Role: "auth", Hidden: true, Status: "running"},
|
||||
{ID: "c4", Name: "secret", Hidden: true, Status: "running"},
|
||||
}
|
||||
}
|
||||
|
||||
func serve(t *testing.T, dataRoot string, services []types.Container) map[string]any {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(NewPublicProfileHandler(func() []types.Container { return services }, dataRoot))
|
||||
t.Cleanup(srv.Close)
|
||||
resp, err := http.Get(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("GET failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func TestPublicProfileFiltersHiddenInfra(t *testing.T) {
|
||||
dataRoot := t.TempDir()
|
||||
body := serve(t, dataRoot, testServices())
|
||||
|
||||
raw, _ := json.Marshal(body["services"])
|
||||
var services []map[string]any
|
||||
if err := json.Unmarshal(raw, &services); err != nil {
|
||||
t.Fatalf("services not an array: %v", err)
|
||||
}
|
||||
if len(services) != 1 {
|
||||
t.Fatalf("got %d services, want exactly the one public service (%s)", len(services), raw)
|
||||
}
|
||||
svc := services[0]
|
||||
if svc["service_name"] != "keepnote" || svc["status"] != "running" || svc["image"] == "" {
|
||||
t.Fatalf("unexpected public service shape: %v", svc)
|
||||
}
|
||||
|
||||
// No private fields may serialize.
|
||||
for _, forbidden := range []string{"id", "docker_name", "network", "ipAddress", "ports", "role", "hidden", "public_key"} {
|
||||
if _, ok := svc[forbidden]; ok {
|
||||
t.Errorf("public service leaked %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicProfileIdentityFallbackAndFile(t *testing.T) {
|
||||
dataRoot := t.TempDir()
|
||||
body := serve(t, dataRoot, nil)
|
||||
instance := body["instance"].(map[string]any)
|
||||
if instance["name"] == "" {
|
||||
t.Fatal("identity name must fall back to hostname/env, got empty")
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(dataRoot, "instance.json"),
|
||||
[]byte(`{"name":"home-node","title":"Home node","description":"main rig"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body = serve(t, dataRoot, nil)
|
||||
instance = body["instance"].(map[string]any)
|
||||
if instance["name"] != "home-node" || instance["title"] != "Home node" {
|
||||
t.Fatalf("file identity not used: %v", instance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicProfileTrustedInstancesNullVsEmpty(t *testing.T) {
|
||||
dataRoot := t.TempDir()
|
||||
|
||||
// Store absent → JSON null (field present with null value).
|
||||
body := serve(t, dataRoot, testServices())
|
||||
if v, ok := body["trusted_instances"]; !ok || v != nil {
|
||||
raw, _ := json.Marshal(body)
|
||||
t.Fatalf("absent federation store should yield trusted_instances:null, got %s", raw)
|
||||
}
|
||||
|
||||
// Present but empty → [].
|
||||
if err := os.MkdirAll(filepath.Join(dataRoot, "federation"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(federation.Path(dataRoot), []byte(`[]`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body = serve(t, dataRoot, testServices())
|
||||
raw, _ := json.Marshal(body["trusted_instances"])
|
||||
if string(raw) != "[]" {
|
||||
t.Fatalf("empty store should marshal as [], got %s", raw)
|
||||
}
|
||||
|
||||
// Populated → rows pass through.
|
||||
populated := `{"instances":[{"name":"peer-b","url":"http://192.168.1.20:8080","fingerprint":"sha256:ab","status":"authorized","scopes":["read"]}]}`
|
||||
if err := os.WriteFile(federation.Path(dataRoot), []byte(populated), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body = serve(t, dataRoot, testServices())
|
||||
list := body["trusted_instances"].([]any)
|
||||
if len(list) != 1 || list[0].(map[string]any)["name"] != "peer-b" {
|
||||
t.Fatalf("populated store not surfaced: %v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicProfilePrivacySwitchOmitsSections(t *testing.T) {
|
||||
t.Setenv("DCNTRLIZED_PUBLIC_PROFILE", "false")
|
||||
dataRoot := t.TempDir()
|
||||
body := serve(t, dataRoot, testServices())
|
||||
|
||||
for _, section := range []string{"services", "trusted_instances"} {
|
||||
if _, ok := body[section]; ok {
|
||||
t.Errorf("%q must be omitted when DCNTRLIZED_PUBLIC_PROFILE=false", section)
|
||||
}
|
||||
}
|
||||
if _, ok := body["instance"]; !ok {
|
||||
t.Error("instance identity must remain visible with the profile off")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user