// 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 . package instance import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "git.dcntrlized.org/Dcntrlized/Maestro/lib/catalog" "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 TestPublicServicesSortedCaseInsensitive(t *testing.T) { // The cache is a Go map, so input order is random per call; the profile // must still be stable across polls. all := []types.Container{ {ID: "1", ServiceName: "calendar", Status: "running"}, {ID: "2", ServiceName: "Blog", Status: "running"}, {ID: "3", ServiceName: "auth-proxy", Status: "running"}, } got := publicServices(all) want := []string{"auth-proxy", "Blog", "calendar"} if len(got) != len(want) { t.Fatalf("len = %d, want %d", len(got), len(want)) } for i := range want { if got[i].ServiceName != want[i] { t.Fatalf("order[%d] = %q, want %q (full: %v)", i, got[i].ServiceName, want[i], got) } } } 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) } } // TestPublicProfileTrustedInstancesAlwaysEmpty covers rev 9: the federation // trust store is retired, so trusted_instances is always an empty array (never // null) regardless of on-disk state, and the instance fingerprint is a stable // app-level value derived from the persisted instance key. func TestPublicProfileTrustedInstancesAlwaysEmpty(t *testing.T) { dataRoot := t.TempDir() body := serve(t, dataRoot, testServices()) raw, _ := json.Marshal(body["trusted_instances"]) if string(raw) != "[]" { t.Fatalf("trusted_instances should be [] after the federation removal, got %s", raw) } // A stale federation directory on disk must not resurrect the surface. if err := os.MkdirAll(filepath.Join(dataRoot, "federation"), 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dataRoot, "federation", "instances.json"), []byte(`{"instances":[{"name":"peer-b","status":"authorized"}]}`), 0o600); err != nil { t.Fatal(err) } body = serve(t, dataRoot, testServices()) raw, _ = json.Marshal(body["trusted_instances"]) if string(raw) != "[]" { t.Fatalf("stale federation store leaked into the profile: %s", raw) } // The fingerprint is present and stable across requests. first := body["instance"].(map[string]any)["fingerprint"].(string) if !strings.HasPrefix(first, "sha256:") { t.Fatalf("fingerprint = %q, want sha256: prefix", first) } body = serve(t, dataRoot, testServices()) if again := body["instance"].(map[string]any)["fingerprint"].(string); again != first { t.Fatalf("fingerprint not stable: %q vs %q", first, again) } } 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") } } func TestPublicProfileAppsSection(t *testing.T) { dataRoot := t.TempDir() id := catalog.AppID("com.example.profile") app := catalog.App{ ID: id, Package: "com.example.profile", Name: "Profile App", VersionName: "1.0", AddedAt: "2026-10-06T12:00:00Z", UpdatedAt: "2026-10-06T12:00:00Z", AddedBy: "owner", Artifact: catalog.Artifact{ Filename: "app.apk", SizeBytes: 5, SHA256: strings.Repeat("d", 64), Kind: catalog.KindHosted, ContentType: catalog.DefaultArtifactContentType, }, ArtifactPath: filepath.Join("artifacts", id, "app.apk"), IconPath: filepath.Join("icons", id), } if err := catalog.NewStore(dataRoot).Upsert(app); err != nil { t.Fatalf("seed catalog: %v", err) } body := serve(t, dataRoot, testServices()) raw, _ := json.Marshal(body["apps"]) var apps []map[string]any if err := json.Unmarshal(raw, &apps); err != nil { t.Fatalf("apps not an array: %v (%s)", err, raw) } if len(apps) != 1 { t.Fatalf("apps = %s", raw) } row := apps[0] if row["package"] != "com.example.profile" || row["name"] != "Profile App" { t.Fatalf("app metadata wrong: %v", row) } if _, leaked := row["added_by"]; leaked { t.Fatal("public profile leaked added_by") } if _, leaked := row["artifact_path"]; leaked { t.Fatal("public profile leaked the internal artifact path") } artifact, ok := row["artifact"].(map[string]any) if !ok { t.Fatalf("artifact missing: %v", row) } download := artifact["download_url"].(string) if !strings.HasPrefix(download, "http://127.0.0.1:") || !strings.HasSuffix(download, "/api/catalog/artifacts/"+id+"/app.apk") { t.Fatalf("download_url = %q", download) } icon := row["icon_url"].(string) if !strings.HasSuffix(icon, "/api/catalog/icons/"+id) { t.Fatalf("icon_url = %q", icon) } if artifact["sha256"] != strings.Repeat("d", 64) { t.Fatalf("sha256 not surfaced: %v", artifact) } } func TestPublicProfileAppsEmptyAndSwitchOff(t *testing.T) { dataRoot := t.TempDir() body := serve(t, dataRoot, testServices()) raw, _ := json.Marshal(body["apps"]) if string(raw) != "[]" { t.Fatalf("empty catalog must marshal as [], got %s", raw) } t.Setenv("DCNTRLIZED_PUBLIC_PROFILE", "false") body = serve(t, dataRoot, testServices()) if _, ok := body["apps"]; ok { t.Error("apps must be omitted when DCNTRLIZED_PUBLIC_PROFILE=false") } }