Files
ipcr-forge/forge/gate_test.go
T
Worph c961d74e0e IPCR Forge: org-root image names and per-repository push credentials
Short names: the public repositories of one Gitea organisation (IPCR_ROOT_ORG,
then the admin page; metadec on our boxes) are also published at the root of
the forge's name, so ipcr.localhost:4767/ipns/metadec.eth/ipcr-hello:1.0.0
works. Collisions (a user/org of that name, another owner's folder) are
skipped and explained.

Push credentials: the staging registry now sits behind forge-gate
(ipcr-forge-bridge gate), in the CI daemon's network namespace, with the
registry on a unix socket and no network. Each publishing repository logs in
as <owner>/<repo> with an IPCR_PUSH_TOKEN Actions secret the bridge sets, and
may push its own name only; IPCR logs in with a credential it generates.
Workflows need a docker/login-action step (example and page updated).

ipcr 1.4.0: allow.aliases with per-pass alias sync; unpublish/retag on every
path; IMPORT_AUTH_GENERATE; names this node publishes resolve via
name/resolve --nocache (Kubo's resolve cache lagged, and the generic resolve
ignores nocache).

bridge 0.3.0: gate, push credentials (rotate in the admin page), root
organisation setting, setup creates the org and the example in it; the
Radicle description carries the Gitea path (identical documents gave the same
RID); OAuth2 app updated only when hosts change, keeping the new secret.
Admin page: tabs, one-line keys, name-check feedback, per-tag docker pull
commands (IPFS, IPNS, ENS, short path first).

Verified on holyhorse (docs/forge.md).
2026-10-05 11:37:33 +00:00

176 lines
6.4 KiB
Go

package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
func newTestGate(t *testing.T) (*gate, *[]*http.Request) {
t.Helper()
var seen []*http.Request
var mu sync.Mutex
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
seen = append(seen, r.Clone(r.Context()))
mu.Unlock()
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(up.Close)
proxy, err := upstreamProxy(up.URL)
if err != nil {
t.Fatal(err)
}
file := filepath.Join(t.TempDir(), "credentials.json")
writeCreds(file, gateCreds{
Push: map[string]string{"metadec/app": hashSecret("app-token"), "alice/tool": hashSecret("alice-token")},
Importer: map[string]string{"ipcr": hashSecret("importer-pass")},
})
return &gate{credFile: file, upstream: proxy}, &seen
}
func TestGateAuthorization(t *testing.T) {
g, _ := newTestGate(t)
for _, c := range []struct {
name, method, path, user, pass string
want int
}{
{"no credentials", "GET", "/v2/", "", "", 401},
{"wrong password", "GET", "/v2/", "metadec/app", "nope", 401},
{"login check", "GET", "/v2/", "metadec/app", "app-token", 200},
{"login, uppercase user", "GET", "/v2/", "Metadec/App", "app-token", 200},
{"own manifest", "PUT", "/v2/metadec/app/manifests/1.0.0", "metadec/app", "app-token", 200},
{"own blob upload", "POST", "/v2/metadec/app/blobs/uploads/", "metadec/app", "app-token", 200},
{"own upload chunk", "PATCH", "/v2/metadec/app/blobs/uploads/abc", "metadec/app", "app-token", 200},
{"own blob check", "HEAD", "/v2/metadec/app/blobs/sha256:00", "metadec/app", "app-token", 200},
{"other repository", "PUT", "/v2/alice/tool/manifests/1.0.0", "metadec/app", "app-token", 403},
{"prefix of another name", "PUT", "/v2/metadec/app2/manifests/1", "metadec/app", "app-token", 403},
{"nested name", "PUT", "/v2/metadec/app/sub/manifests/1", "metadec/app", "app-token", 403},
{"catalog as pusher", "GET", "/v2/_catalog", "metadec/app", "app-token", 403},
{"delete as pusher", "DELETE", "/v2/metadec/app/manifests/sha256:00", "metadec/app", "app-token", 403},
{"dot-dot", "GET", "/v2/metadec/app/../alice/tool/tags/list", "metadec/app", "app-token", 400},
{"importer catalog", "GET", "/v2/_catalog", "ipcr", "importer-pass", 200},
{"importer pull", "GET", "/v2/alice/tool/manifests/1.0.0", "ipcr", "importer-pass", 200},
{"importer delete", "DELETE", "/v2/alice/tool/manifests/sha256:00", "ipcr", "importer-pass", 200},
{"importer push", "PUT", "/v2/alice/tool/manifests/1.0.0", "ipcr", "importer-pass", 403},
} {
r := httptest.NewRequest(c.method, "http://localhost:5000"+c.path, nil)
if c.user != "" {
r.SetBasicAuth(c.user, c.pass)
}
w := httptest.NewRecorder()
g.ServeHTTP(w, r)
if w.Code != c.want {
t.Errorf("%s: %d, want %d", c.name, w.Code, c.want)
}
if w.Code == 401 && !strings.HasPrefix(w.Header().Get("WWW-Authenticate"), "Basic ") {
t.Errorf("%s: no Basic challenge", c.name)
}
}
}
func TestGateForwarding(t *testing.T) {
g, seen := newTestGate(t)
r := httptest.NewRequest("POST", "http://localhost:5000/v2/metadec/app/blobs/uploads/?mount=sha256:00&from=alice/tool", nil)
r.Host = "localhost:5000"
r.SetBasicAuth("metadec/app", "app-token")
w := httptest.NewRecorder()
g.ServeHTTP(w, r)
if w.Code != 200 || len(*seen) != 1 {
t.Fatalf("%d, %d upstream requests", w.Code, len(*seen))
}
up := (*seen)[0]
if up.URL.RawQuery != "" {
t.Errorf("cross-repository mount forwarded: %q", up.URL.RawQuery)
}
if up.Host != "localhost:5000" {
t.Errorf("Host %q: the registry builds upload URLs from it", up.Host)
}
if up.Header.Get("Authorization") != "" {
t.Error("credentials forwarded to the registry")
}
}
func TestGateReloadsCredentials(t *testing.T) {
g, _ := newTestGate(t)
try := func() int {
r := httptest.NewRequest("GET", "http://localhost:5000/v2/", nil)
r.SetBasicAuth("new/repo", "t")
w := httptest.NewRecorder()
g.ServeHTTP(w, r)
return w.Code
}
if try() != 401 {
t.Fatal("unknown repository accepted")
}
c := gateCreds{Push: map[string]string{"new/repo": hashSecret("t")}}
writeCreds(g.credFile, c)
// A different modification time, as a later write would have.
st, _ := os.Stat(g.credFile)
os.Chtimes(g.credFile, st.ModTime().Add(1e9), st.ModTime().Add(1e9))
if try() != 200 {
t.Error("new credentials not picked up")
}
}
// sync against a fake Gitea: secrets set for new repositories, kept for known ones, deleted for
// removed ones, the importer taken from IPCR's file.
func TestPushCredsSync(t *testing.T) {
var mu sync.Mutex
secrets := map[string]string{}
var deleted []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
repo := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/api/v1/repos/"), "/actions/secrets/"+pushSecret)
switch r.Method {
case "PUT":
var b struct{ Data string }
json.NewDecoder(r.Body).Decode(&b)
secrets[repo] = b.Data
w.WriteHeader(201)
case "DELETE":
deleted = append(deleted, repo)
w.WriteHeader(204)
}
}))
defer srv.Close()
dir := t.TempDir()
tok := filepath.Join(dir, "token")
os.WriteFile(tok, []byte("admin-token"), 0o600)
auth := filepath.Join(dir, "staging-auth")
os.WriteFile(auth, []byte("ipcr:importer-pass\n"), 0o644)
p := &pushCreds{file: filepath.Join(dir, "gate", "credentials.json"), stagingAuth: auth, gitea: newGitea(srv.URL, "gitea_admin", tok)}
if err := p.sync([]string{"Metadec/App", "alice/tool"}, ""); err != nil {
t.Fatal(err)
}
c := p.read()
if !matches(c.Push["metadec/app"], secrets["Metadec/App"]) || !matches(c.Push["alice/tool"], secrets["alice/tool"]) {
t.Fatalf("credentials do not match the secrets: %v %v", c.Push, secrets)
}
if !matches(c.Importer["ipcr"], "importer-pass") {
t.Error("importer credential missing")
}
first := secrets["alice/tool"]
// alice/tool keeps its token; Metadec/App is rotated; a removed repository loses it.
if err := p.sync([]string{"Metadec/App", "alice/tool"}, "metadec/app"); err != nil {
t.Fatal(err)
}
if secrets["alice/tool"] != first || !matches(p.read().Push["metadec/app"], secrets["Metadec/App"]) {
t.Error("rotation")
}
if err := p.sync([]string{"Metadec/App"}, ""); err != nil {
t.Fatal(err)
}
if _, ok := p.read().Push["alice/tool"]; ok || len(deleted) != 1 || deleted[0] != "alice/tool" {
t.Errorf("removal: %v, deleted %v", p.read().Push, deleted)
}
}