[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:
2026-10-19 02:40:05 +01:00
parent 13f6d14521
commit 786b7a3b5f
18 changed files with 4307 additions and 94 deletions
+156
View File
@@ -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 catalog
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/catalog"
)
func serveDownload(t *testing.T, h *Handler, id, filename string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/catalog/artifacts/"+id+"/"+filename, nil)
req.SetPathValue("id", id)
req.SetPathValue("filename", filename)
rec := httptest.NewRecorder()
h.Download(rec, req)
return rec
}
func TestDownloadHostedExactBytesAndHeaders(t *testing.T) {
h, _ := newTestHandler(t)
app := seedHostedApp(t, h, "com.example.download", "exact-apk-bytes")
rec := serveDownload(t, h, app.ID, app.Artifact.Filename)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != "exact-apk-bytes" {
t.Fatalf("body = %q", rec.Body.String())
}
if got := rec.Header().Get("Content-Length"); got != "15" {
t.Fatalf("Content-Length = %q, want 15", got)
}
if got := rec.Header().Get("X-Content-SHA256"); got != app.Artifact.SHA256 {
t.Fatalf("X-Content-SHA256 = %q, want %q", got, app.Artifact.SHA256)
}
if got := rec.Header().Get("Content-Type"); got != catalog.DefaultArtifactContentType {
t.Fatalf("Content-Type = %q", got)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("Access-Control-Allow-Origin = %q", got)
}
}
func TestDownloadUnknownAndWrongFilename(t *testing.T) {
h, _ := newTestHandler(t)
app := seedHostedApp(t, h, "com.example.dl2", "bytes")
if rec := serveDownload(t, h, app.ID, "other.apk"); rec.Code != http.StatusNotFound {
t.Fatalf("wrong filename status = %d", rec.Code)
}
if rec := serveDownload(t, h, "a_missing", "app.apk"); rec.Code != http.StatusNotFound {
t.Fatalf("unknown id status = %d", rec.Code)
}
}
func TestDownloadLinkRedirects(t *testing.T) {
h, _ := newTestHandler(t)
link := "https://downloads.example.org/app.apk"
app := catalog.App{
ID: catalog.AppID("com.example.redirect"), Package: "com.example.redirect", Name: "Link", VersionName: "1.0",
Artifact: catalog.Artifact{
Filename: "app.apk", SHA256: strings.Repeat("c", 64),
Kind: catalog.KindLink, SourceURL: link,
},
}
if err := h.store.Upsert(app); err != nil {
t.Fatalf("seed link: %v", err)
}
rec := serveDownload(t, h, app.ID, "app.apk")
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != link {
t.Fatalf("Location = %q, want %q", loc, link)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("redirect ACAO = %q", got)
}
}
func TestDownloadPrivacySwitchOff(t *testing.T) {
t.Setenv("DCNTRLIZED_PUBLIC_PROFILE", "false")
h, _ := newTestHandler(t)
app := seedHostedApp(t, h, "com.example.private", "bytes")
if rec := serveDownload(t, h, app.ID, app.Artifact.Filename); rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 when the profile switch is off", rec.Code)
}
req := httptest.NewRequest(http.MethodGet, "/api/catalog/icons/"+app.ID, nil)
req.SetPathValue("id", app.ID)
rec := httptest.NewRecorder()
h.IconBytes(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("icon status = %d, want 404 when the profile switch is off", rec.Code)
}
}
func TestIconBytes(t *testing.T) {
h, _ := newTestHandler(t)
app := seedHostedApp(t, h, "com.example.icon2", "bytes")
icon := append([]byte("\x89PNG\r\n\x1a\n"), []byte("png-body")...)
if _, err := h.store.SaveIcon(app.ID, icon); err != nil {
t.Fatalf("save icon: %v", err)
}
stored, _ := h.store.Get(app.ID)
stored.IconPath = "icons/" + app.ID
if err := h.store.Upsert(*stored); err != nil {
t.Fatalf("persist icon path: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/catalog/icons/"+app.ID, nil)
req.SetPathValue("id", app.ID)
rec := httptest.NewRecorder()
h.IconBytes(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != string(icon) {
t.Fatalf("icon body = %q", rec.Body.String())
}
if got := rec.Header().Get("Content-Type"); got != "image/png" {
t.Fatalf("Content-Type = %q", got)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("ACAO = %q", got)
}
// App without an uploaded icon → 404.
other := seedHostedApp(t, h, "com.example.noicon", "bytes")
req = httptest.NewRequest(http.MethodGet, "/api/catalog/icons/"+other.ID, nil)
req.SetPathValue("id", other.ID)
rec = httptest.NewRecorder()
h.IconBytes(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("missing icon status = %d", rec.Code)
}
}