[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:
@@ -17,12 +17,94 @@ package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||
)
|
||||
|
||||
// TestSourceHrefAllowlist pins audit finding N7: the registry item's Source
|
||||
// comes verbatim from the image's OCI annotations, so the href the console
|
||||
// renders must be gated by the same http/https allowlist as the Web App chip.
|
||||
// An invalid source must never yield a usable href — it is served only as
|
||||
// plain display text.
|
||||
func TestSourceHrefAllowlist(t *testing.T) {
|
||||
valid := []string{
|
||||
"https://github.com/Dcntrlized/Maestro",
|
||||
"https://git.dcntrlized.org/Dcntrlized/Calendar.git",
|
||||
"http://192.168.122.183:3000/Dcntrlized/KeepNote",
|
||||
"https://example.com/x?a=b#frag",
|
||||
"http://[::1]:8080/repo",
|
||||
}
|
||||
for _, raw := range valid {
|
||||
if got := sourceHrefFor(raw); got != raw {
|
||||
t.Errorf("valid source %q: got href %q, want it verbatim", raw, got)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
"javascript:alert(1)",
|
||||
"JAVASCRIPT:alert(1)",
|
||||
"data:text/html,<script>alert(1)</script>",
|
||||
"vbscript:msgbox(1)",
|
||||
"file:///etc/passwd",
|
||||
"https://user:[email protected]/repo",
|
||||
"https://[email protected]/repo",
|
||||
"github.com/Dcntrlized/Maestro", // relative: no scheme
|
||||
"//example.com/repo", // scheme-relative
|
||||
"/repo", // path only
|
||||
"", // absent
|
||||
" ", // whitespace only
|
||||
"https://example.com/re po", // space
|
||||
"https://example.com/<repo>", // angle brackets
|
||||
"https://example.com/repo\njavascript:1", // newline
|
||||
"https://example.com/repo\x00tail", // NUL
|
||||
"https://example.com/repo\x7f", // DEL
|
||||
"https:", // no host
|
||||
"ftp://example.com/repo", // wrong scheme
|
||||
}
|
||||
for _, raw := range invalid {
|
||||
if got := sourceHrefFor(raw); got != "" {
|
||||
t.Errorf("invalid source %q must not become an href, got %q", raw, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceHrefWireKey locks the console contract: the validated href rides
|
||||
// as `source_href` next to the raw `source`, so the frontend can render plain
|
||||
// text for an invalid value instead of an anchor.
|
||||
func TestSourceHrefWireKey(t *testing.T) {
|
||||
b, err := json.Marshal(types.RegistryContainer{
|
||||
Name: "keepnote",
|
||||
Source: "javascript:alert(1)",
|
||||
SourceHref: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(b), `"source":"javascript:alert(1)"`) {
|
||||
t.Fatalf("raw source must stay on the wire for display: %s", b)
|
||||
}
|
||||
if strings.Contains(string(b), "source_href") {
|
||||
t.Fatalf("an invalid source must not emit source_href: %s", b)
|
||||
}
|
||||
|
||||
b, err = json.Marshal(types.RegistryContainer{
|
||||
Name: "keepnote",
|
||||
Source: "https://github.com/Dcntrlized/KeepNote",
|
||||
SourceHref: "https://github.com/Dcntrlized/KeepNote",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(b), `"source_href":"https://github.com/Dcntrlized/KeepNote"`) {
|
||||
t.Fatalf("valid source must emit source_href: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestItemByNameLive resolves the infrastructure services against a live
|
||||
// registry. It is opt-in (MAESTRO_REGISTRY_TEST=1) so the default test run
|
||||
// stays offline; the dev VM and prod CI both set the Gitea env the function
|
||||
@@ -51,6 +133,32 @@ func TestItemByNameLive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceNameFromLabels locks the "is this package a Maestro service?" rule:
|
||||
// the org.dcntrlized.service label must be declared by the image (manifest
|
||||
// annotations or config labels). The runner job image dcntrlized/ci-tools
|
||||
// declares neither and must not surface in the console.
|
||||
func TestServiceNameFromLabels(t *testing.T) {
|
||||
if got := serviceNameFromLabels(map[string]string{"org.dcntrlized.service": "KeepNote"}, nil); got != "KeepNote" {
|
||||
t.Fatalf("annotation: got %q", got)
|
||||
}
|
||||
if got := serviceNameFromLabels(nil, map[string]string{"org.dcntrlized.service": "Calendar"}); got != "Calendar" {
|
||||
t.Fatalf("label: got %q", got)
|
||||
}
|
||||
// Annotations win when both are present.
|
||||
if got := serviceNameFromLabels(
|
||||
map[string]string{"org.dcntrlized.service": "KeepNote"},
|
||||
map[string]string{"org.dcntrlized.service": "Calendar"},
|
||||
); got != "KeepNote" {
|
||||
t.Fatalf("annotation precedence: got %q", got)
|
||||
}
|
||||
if got := serviceNameFromLabels(nil, nil); got != "" {
|
||||
t.Fatalf("no label must yield empty, got %q", got)
|
||||
}
|
||||
if got := serviceNameFromLabels(map[string]string{"org.opencontainers.image.title": "ci-tools"}, nil); got != "" {
|
||||
t.Fatalf("unrelated labels must yield empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseURLNeverFallsBackToPublicInDev locks the dev registry seam: with
|
||||
// DCNTRLIZED_DEV=true Maestro must talk to the debug registry
|
||||
// (DCNTRLIZED_GITEA_URL) only — an unset dev URL yields "" (loud failure),
|
||||
|
||||
Reference in New Issue
Block a user