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.
157 lines
5.3 KiB
Go
157 lines
5.3 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 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)
|
|
}
|
|
}
|