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.
182 lines
6.9 KiB
Go
182 lines
6.9 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 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
|
|
// reads. Infrastructure services must resolve so Maestro can auto-install them
|
|
// on a fresh host (ensureInfraRunning).
|
|
func TestItemByNameLive(t *testing.T) {
|
|
if os.Getenv("MAESTRO_REGISTRY_TEST") != "1" {
|
|
t.Skip("set MAESTRO_REGISTRY_TEST=1 with GITEA_* env to run against a live registry")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
for _, name := range []string{"dcntrlized-db", "dcntrlized-auth"} {
|
|
item, err := ItemByName(ctx, name)
|
|
if err != nil {
|
|
t.Fatalf("ItemByName(%q): %v", name, err)
|
|
}
|
|
if item.Image == "" {
|
|
t.Errorf("ItemByName(%q): empty image", name)
|
|
}
|
|
if !strings.HasPrefix(item.URL, "http") {
|
|
t.Errorf("ItemByName(%q): url %q not an http(s) compose source", name, item.URL)
|
|
}
|
|
t.Logf("resolved %s -> image=%s url=%s version=%s", name, item.Image, item.URL, item.Version)
|
|
}
|
|
}
|
|
|
|
// 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),
|
|
// never the public git.dcntrlized.org.
|
|
func TestBaseURLNeverFallsBackToPublicInDev(t *testing.T) {
|
|
t.Setenv("DCNTRLIZED_DEV", "true")
|
|
t.Setenv("DCNTRLIZED_GITEA_URL", "http://192.168.122.183:3000")
|
|
if got := BaseURL(); got != "http://192.168.122.183:3000" {
|
|
t.Fatalf("dev with DCNTRLIZED_GITEA_URL: got %q", got)
|
|
}
|
|
t.Setenv("DCNTRLIZED_GITEA_URL", "")
|
|
if got := BaseURL(); got != "" {
|
|
t.Fatalf("dev without DCNTRLIZED_GITEA_URL must not fall back to public, got %q", got)
|
|
}
|
|
t.Setenv("DCNTRLIZED_DEV", "")
|
|
t.Setenv("DCNTRLIZED_GITEA_URL", "http://192.168.122.183:3000")
|
|
if got := BaseURL(); got != "https://git.dcntrlized.org" {
|
|
t.Fatalf("production must use the public registry, got %q", got)
|
|
}
|
|
}
|