An instance distributes installable Android apps independently of the services it runs. Add <dataRoot>/catalog/apps.json (owner-authored, atomic, 0600) with hosted-or-linked artifacts, SHA-256 and optional icon; expose the owner-gated add/edit/delete/replace API plus session-free artifact and icon downloads, and add the apps section to the public profile. The catalog is deliberately independent of services and the registry, and registry listings gain the declared service identity and a validated source href so cards can be paired correctly.
249 lines
8.6 KiB
Go
249 lines
8.6 KiB
Go
// 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"
|
|
"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")
|
|
}
|
|
}
|