package selfupdate import ( "fmt" "io" "log" "net/http" "net/http/httptest" "strings" "sync" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/config" ) // newRegistryUpdater builds an updater pointed at a fake registry with the given creds. func newRegistryUpdater(t *testing.T, baseURL, user, token string) *Updater { t.Helper() cfg := &config.SelfUpdateConfig{Enabled: true, Image: imageBase} git := &config.GitConfig{Username: user, Token: token} u := NewUpdater(cfg, git, "0.111.0", t.TempDir(), nil, log.New(io.Discard, "", 0), false) u.registryBase = baseURL return u } // fakeRegistry is an httptest server speaking the Docker Registry v2 anonymous flow. type fakeRegistry struct { *httptest.Server mu sync.Mutex tokenHits int tokenHadAuth bool tokenQuery string tagsBasicAuths []string // Authorization headers seen on tags/list denyToken bool // token endpoint refuses (private registry) denyBearerTags bool // tags/list refuses even WITH the bearer } func newFakeRegistry(t *testing.T) *fakeRegistry { t.Helper() f := &fakeRegistry{} mux := http.NewServeMux() mux.HandleFunc("/v2/token", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() f.tokenHits++ f.tokenHadAuth = r.Header.Get("Authorization") != "" f.tokenQuery = r.URL.RawQuery deny := f.denyToken f.mu.Unlock() if deny { w.WriteHeader(401) return } w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `{"token":"anon-token-123"}`) }) mux.HandleFunc("/v2/admin/felhom-controller/tags/list", func(w http.ResponseWriter, r *http.Request) { auth := r.Header.Get("Authorization") f.mu.Lock() f.tagsBasicAuths = append(f.tagsBasicAuths, auth) denyBearer := f.denyBearerTags f.mu.Unlock() switch { case strings.HasPrefix(auth, "Basic "): // authenticated path — serve directly case auth == "Bearer anon-token-123" && !denyBearer: // anonymous path with the issued token — serve default: w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="%s/v2/token",service="container_registry"`, f.Server.URL)) w.WriteHeader(401) return } w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `{"name":"admin/felhom-controller","tags":["latest","dev","0.111.0","0.112.0","0.99.0"]}`) }) f.Server = httptest.NewServer(mux) t.Cleanup(f.Server.Close) return f } // Part 1 — the anonymous token dance end-to-end: 401 + WWW-Authenticate → token with NO // auth → tags with the Bearer → highest semver, with ZERO credentials configured. // Red-proof: restoring the old creds-required guard in queryRegistry fails this with // "registry hitelesítő adatok hiányoznak". func TestQueryRegistry_AnonymousTokenDance(t *testing.T) { reg := newFakeRegistry(t) u := newRegistryUpdater(t, reg.URL, "", "") got, err := u.queryRegistry() if err != nil { t.Fatalf("anonymous queryRegistry failed: %v", err) } if got != "0.112.0" { t.Fatalf("latest = %q, want 0.112.0 (highest semver, non-semver tags skipped)", got) } reg.mu.Lock() defer reg.mu.Unlock() if reg.tokenHits != 1 { t.Fatalf("token endpoint hit %d times, want 1", reg.tokenHits) } if reg.tokenHadAuth { t.Fatalf("anonymous token request carried an Authorization header — must be credential-free") } if !strings.Contains(reg.tokenQuery, "service=container_registry") || !strings.Contains(reg.tokenQuery, "scope=repository%3Aadmin%2Ffelhom-controller%3Apull") { t.Fatalf("token query missing service/scope: %q", reg.tokenQuery) } } // Creds-present path: BasicAuth request shape unchanged, and NO token dance attempted. func TestQueryRegistry_CredsPath_Unchanged(t *testing.T) { reg := newFakeRegistry(t) u := newRegistryUpdater(t, reg.URL, "user1", "tok1") got, err := u.queryRegistry() if err != nil { t.Fatalf("creds queryRegistry failed: %v", err) } if got != "0.112.0" { t.Fatalf("latest = %q, want 0.112.0", got) } reg.mu.Lock() defer reg.mu.Unlock() if reg.tokenHits != 0 { t.Fatalf("creds path attempted the anonymous token dance (%d token hits)", reg.tokenHits) } if len(reg.tagsBasicAuths) != 1 || !strings.HasPrefix(reg.tagsBasicAuths[0], "Basic ") { t.Fatalf("tags/list not requested with BasicAuth: %v", reg.tagsBasicAuths) } } // Denied-anonymous (token endpoint refuses): the NEW clear error — not a crash, not the // old "credentials missing" message. func TestQueryRegistry_AnonymousDenied_TokenEndpoint(t *testing.T) { reg := newFakeRegistry(t) reg.denyToken = true u := newRegistryUpdater(t, reg.URL, "", "") _, err := u.queryRegistry() if err == nil { t.Fatal("expected error from a denying registry") } if !strings.Contains(err.Error(), "denied anonymous access") || !strings.Contains(err.Error(), "Git Sync credentials") { t.Fatalf("wrong denial error: %v", err) } if strings.Contains(err.Error(), "hiányoznak") { t.Fatalf("the OLD credentials-missing message leaked back: %v", err) } } // Denied-anonymous (tags 401 even WITH the Bearer): same clear error. func TestQueryRegistry_AnonymousDenied_TagsWithBearer(t *testing.T) { reg := newFakeRegistry(t) reg.denyBearerTags = true u := newRegistryUpdater(t, reg.URL, "", "") _, err := u.queryRegistry() if err == nil || !strings.Contains(err.Error(), "denied anonymous access") { t.Fatalf("want the anonymous-denial error, got: %v", err) } } // Half-configured credentials stay a loud misconfig, never silently anonymous. func TestQueryRegistry_PartialCreds_LoudError(t *testing.T) { reg := newFakeRegistry(t) for _, pair := range [][2]string{{"user-only", ""}, {"", "token-only"}} { u := newRegistryUpdater(t, reg.URL, pair[0], pair[1]) _, err := u.queryRegistry() if err == nil || !strings.Contains(err.Error(), "hiányos registry hitelesítő adatok") { t.Fatalf("partial creds (%q,%q): want the incomplete-credentials error, got %v", pair[0], pair[1], err) } } } // realm/service parsing: quoted + bare values, order independence, commas inside quotes, // and clear errors on unusable headers. func TestParseWWWAuthenticate(t *testing.T) { cases := []struct { in string realm, svc string wantErrSubstr string }{ {in: `Bearer realm="https://r.example/v2/token",service="container_registry"`, realm: "https://r.example/v2/token", svc: "container_registry"}, {in: `bearer service=svc,realm=https://r.example/tok`, // bare values, swapped order, lowercase scheme word realm: "https://r.example/tok", svc: "svc"}, {in: `Bearer realm="https://r.example/t",service="s",error="insufficient_scope, retry"`, realm: "https://r.example/t", svc: "s"}, // comma INSIDE a quoted param must not split {in: `Bearer service="s"`, wantErrSubstr: "no realm"}, {in: `Basic realm="x"`, wantErrSubstr: "not a Bearer"}, {in: ``, wantErrSubstr: "empty"}, } for _, c := range cases { realm, svc, err := parseWWWAuthenticate(c.in) if c.wantErrSubstr != "" { if err == nil || !strings.Contains(err.Error(), c.wantErrSubstr) { t.Errorf("parse(%q): want error containing %q, got %v", c.in, c.wantErrSubstr, err) } continue } if err != nil { t.Errorf("parse(%q): unexpected error %v", c.in, err) continue } if realm != c.realm || svc != c.svc { t.Errorf("parse(%q) = (%q,%q), want (%q,%q)", c.in, realm, svc, c.realm, c.svc) } } } // fakeRunner records docker CLI invocations for pullImage tests. type fakeRunner struct { mu sync.Mutex commands []string // "login"/"pull"/"logout"/... stdins []string } func (f *fakeRunner) install(t *testing.T) { t.Helper() origRun, origRunStdin := runCommand, runCommandStdin runCommand = func(name string, args ...string) (string, error) { f.mu.Lock() defer f.mu.Unlock() f.commands = append(f.commands, strings.Join(append([]string{name}, args...), " ")) return "", nil } runCommandStdin = func(stdin, name string, args ...string) (string, error) { f.mu.Lock() defer f.mu.Unlock() f.commands = append(f.commands, strings.Join(append([]string{name}, args...), " ")) f.stdins = append(f.stdins, stdin) return "", nil } t.Cleanup(func() { runCommand, runCommandStdin = origRun, origRunStdin }) } // Part 2 — no creds → NO docker login, the pull still runs (docker's native anonymous flow). func TestPullImage_NoCreds_SkipsLogin(t *testing.T) { fr := &fakeRunner{} fr.install(t) u := newRegistryUpdater(t, "http://unused", "", "") if err := u.pullImage(imageBase + ":0.112.0"); err != nil { t.Fatalf("anonymous pullImage failed: %v", err) } fr.mu.Lock() defer fr.mu.Unlock() joined := strings.Join(fr.commands, " | ") if strings.Contains(joined, "login") || strings.Contains(joined, "logout") { t.Fatalf("anonymous pull invoked docker login/logout: %v", fr.commands) } if len(fr.commands) != 1 || fr.commands[0] != "docker pull "+imageBase+":0.112.0" { t.Fatalf("pull not invoked exactly once: %v", fr.commands) } } // Creds present → login (token via stdin) → pull → logout, unchanged. func TestPullImage_WithCreds_LoginPullLogout(t *testing.T) { fr := &fakeRunner{} fr.install(t) u := newRegistryUpdater(t, "http://unused", "user1", "tok1") if err := u.pullImage(imageBase + ":0.112.0"); err != nil { t.Fatalf("authenticated pullImage failed: %v", err) } fr.mu.Lock() defer fr.mu.Unlock() want := []string{ "docker login gitea.dooplex.hu -u user1 --password-stdin", "docker pull " + imageBase + ":0.112.0", "docker logout gitea.dooplex.hu", } if len(fr.commands) != 3 { t.Fatalf("commands = %v, want login/pull/logout", fr.commands) } for i := range want { if fr.commands[i] != want[i] { t.Fatalf("command[%d] = %q, want %q", i, fr.commands[i], want[i]) } } if len(fr.stdins) != 1 || fr.stdins[0] != "tok1" { t.Fatalf("token not passed via stdin: %v", fr.stdins) } } // Half-configured creds refuse the pull loudly (never a silent anonymous downgrade). func TestPullImage_PartialCreds_LoudError(t *testing.T) { fr := &fakeRunner{} fr.install(t) u := newRegistryUpdater(t, "http://unused", "user-only", "") err := u.pullImage(imageBase + ":0.112.0") if err == nil || !strings.Contains(err.Error(), "hiányos registry hitelesítő adatok") { t.Fatalf("want incomplete-credentials error, got %v", err) } fr.mu.Lock() defer fr.mu.Unlock() if len(fr.commands) != 0 { t.Fatalf("no docker command may run on misconfig: %v", fr.commands) } } // RegistryAnonymous drives the settings-page mode line; DryRun.PullCapable counts // anonymous as capable (only a half-configured pair is incapable). func TestRegistryMode_And_PullCapable(t *testing.T) { anon := newRegistryUpdater(t, "http://unused", "", "") if !anon.RegistryAnonymous() { t.Fatal("empty creds must report anonymous mode") } authed := newRegistryUpdater(t, "http://unused", "u", "t") if authed.RegistryAnonymous() { t.Fatal("full creds must report authenticated mode") } if capable := (anon.gitCfg.Username != "" && anon.gitCfg.Token != "") || anon.RegistryAnonymous(); !capable { t.Fatal("anonymous must be pull-capable") } partial := newRegistryUpdater(t, "http://unused", "u", "") if capable := (partial.gitCfg.Username != "" && partial.gitCfg.Token != "") || partial.RegistryAnonymous(); capable { t.Fatal("partial creds must NOT be pull-capable") } }