[feat](maestro): publish an operator-authored app catalog
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.
This commit is contained in:
@@ -21,9 +21,10 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/federation"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/catalog"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||
)
|
||||
|
||||
@@ -125,38 +126,41 @@ func TestPublicProfileIdentityFallbackAndFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicProfileTrustedInstancesNullVsEmpty(t *testing.T) {
|
||||
// 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()
|
||||
|
||||
// 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)
|
||||
raw, _ := json.Marshal(body["trusted_instances"])
|
||||
if string(raw) != "[]" {
|
||||
t.Fatalf("trusted_instances should be [] after the federation removal, got %s", raw)
|
||||
}
|
||||
|
||||
// Present but empty → [].
|
||||
// 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(federation.Path(dataRoot), []byte(`[]`), 0o600); err != nil {
|
||||
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"])
|
||||
raw, _ = json.Marshal(body["trusted_instances"])
|
||||
if string(raw) != "[]" {
|
||||
t.Fatalf("empty store should marshal as [], got %s", raw)
|
||||
t.Fatalf("stale federation store leaked into the profile: %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)
|
||||
// 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())
|
||||
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)
|
||||
if again := body["instance"].(map[string]any)["fingerprint"].(string); again != first {
|
||||
t.Fatalf("fingerprint not stable: %q vs %q", first, again)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,3 +178,71 @@ func TestPublicProfilePrivacySwitchOmitsSections(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user