package api import ( "encoding/json" "net/http" "testing" "github.com/alexei/tinyforge/internal/store" ) // ============================================================================= // stripImageTag — pure helper, no fixtures needed // ============================================================================= func TestStripImageTag(t *testing.T) { cases := []struct { name string in string want string }{ {"empty", "", ""}, {"bare", "nginx", "nginx"}, {"tagged", "nginx:1.25", "nginx"}, {"latest", "nginx:latest", "nginx"}, {"owner_tagged", "library/nginx:1.25", "library/nginx"}, {"registry_tagged", "registry.example.com/owner/app:v1", "registry.example.com/owner/app"}, {"registry_port_no_tag", "registry.example.com:5000/owner/app", "registry.example.com:5000/owner/app"}, {"registry_port_with_tag", "registry.example.com:5000/owner/app:v1", "registry.example.com:5000/owner/app"}, {"digest", "nginx@sha256:abcd", "nginx"}, {"digest_with_owner", "library/nginx@sha256:abcd", "library/nginx"}, {"trailing_whitespace", " nginx:1.25 ", "nginx"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := stripImageTag(tc.in) if got != tc.want { t.Errorf("stripImageTag(%q) = %q, want %q", tc.in, got, tc.want) } }) } } // ============================================================================= // imageRefFromSourceConfig — pure helper // ============================================================================= func TestImageRefFromSourceConfig(t *testing.T) { cases := []struct { name string raw string want string }{ {"empty", "", ""}, {"malformed", "{not json", ""}, {"no_image_field", `{"port":8080}`, ""}, {"basic", `{"image":"nginx:1.25"}`, "nginx:1.25"}, {"whitespace_trim", `{"image":" nginx:1.25 "}`, "nginx:1.25"}, {"with_extras", `{"image":"nginx","port":8080,"env":{"K":"v"}}`, "nginx"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := imageRefFromSourceConfig(tc.raw) if got != tc.want { t.Errorf("imageRefFromSourceConfig(%q) = %q, want %q", tc.raw, got, tc.want) } }) } } // ============================================================================= // GET /api/discovery/image/conflicts // ============================================================================= // seedImageWorkload inserts a plugin-shaped image workload via the store // directly. We bypass the API here so each test case starts with a // known fixture independent of /api/workloads create-path behaviour. func seedImageWorkload(t *testing.T, st *store.Store, name, imageRef string) { t.Helper() cfg, err := json.Marshal(map[string]any{"image": imageRef, "port": 8080}) if err != nil { t.Fatalf("marshal source_config: %v", err) } if _, err := st.CreateWorkload(store.Workload{ Kind: string(store.WorkloadKindProject), Name: name, SourceKind: "image", SourceConfig: string(cfg), }); err != nil { t.Fatalf("seed workload %q: %v", name, err) } } func TestListImageConflicts_NoMatches_ReturnsEmpty(t *testing.T) { e := newAPITestEnv(t) seedImageWorkload(t, e.store, "alpha", "nginx:1.25") seedImageWorkload(t, e.store, "beta", "registry.example.com/owner/web:v2") resp := e.do(t, http.MethodGet, "/api/discovery/image/conflicts?image=postgres:16", nil) if resp.StatusCode != http.StatusOK { _ = decodeEnvelope(t, resp, nil) t.Fatalf("status = %d, want 200", resp.StatusCode) } var got []imageConflict if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" { t.Fatalf("envelope error: %q", errMsg) } if len(got) != 0 { t.Errorf("expected 0 conflicts, got %d: %+v", len(got), got) } } func TestListImageConflicts_TagMismatch_StillCollides(t *testing.T) { // The legacy quickDeploy collided on repo without tag so nginx:1.25 // surfaces nginx:1.26 — this preserves that behaviour. e := newAPITestEnv(t) seedImageWorkload(t, e.store, "nginx-prod", "nginx:1.25") seedImageWorkload(t, e.store, "nginx-latest", "nginx:latest") resp := e.do(t, http.MethodGet, "/api/discovery/image/conflicts?image=nginx:1.26", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } var got []imageConflict if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" { t.Fatalf("envelope error: %q", errMsg) } if len(got) != 2 { t.Fatalf("expected 2 conflicts, got %d: %+v", len(got), got) } names := map[string]bool{} for _, c := range got { names[c.Name] = true } if !names["nginx-prod"] || !names["nginx-latest"] { t.Errorf("expected both nginx-prod and nginx-latest in conflicts, got %+v", got) } } func TestListImageConflicts_RegistryPortPreserved(t *testing.T) { // Make sure stripImageTag preserves a registry port in the host // segment — registry.example.com:5000/owner/app:v1 must collide // only with refs whose repo is registry.example.com:5000/owner/app. e := newAPITestEnv(t) seedImageWorkload(t, e.store, "with-port", "registry.example.com:5000/owner/app:v1") seedImageWorkload(t, e.store, "no-port", "owner/app:v1") resp := e.do(t, http.MethodGet, "/api/discovery/image/conflicts?image=registry.example.com:5000/owner/app:v2", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } var got []imageConflict if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" { t.Fatalf("envelope error: %q", errMsg) } if len(got) != 1 || got[0].Name != "with-port" { t.Errorf("expected sole conflict on with-port, got %+v", got) } } func TestListImageConflicts_NonImageSourceIgnored(t *testing.T) { // Static-source workloads must never appear in image conflicts even // if their JSON happens to contain a stray "image" key — guard // against source_kind != "image" rows. e := newAPITestEnv(t) if _, err := e.store.CreateWorkload(store.Workload{ Kind: string(store.WorkloadKindProject), Name: "static-with-image-key", SourceKind: "static", SourceConfig: `{"image":"nginx:1.25","provider":"gitea"}`, }); err != nil { t.Fatalf("seed static workload: %v", err) } resp := e.do(t, http.MethodGet, "/api/discovery/image/conflicts?image=nginx:1.25", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } var got []imageConflict if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" { t.Fatalf("envelope error: %q", errMsg) } if len(got) != 0 { t.Errorf("expected 0 conflicts (static source filtered out), got %+v", got) } } func TestListImageConflicts_MissingImageParam_400(t *testing.T) { e := newAPITestEnv(t) resp := e.do(t, http.MethodGet, "/api/discovery/image/conflicts", nil) if resp.StatusCode != http.StatusBadRequest { _ = decodeEnvelope(t, resp, nil) t.Fatalf("status = %d, want 400", resp.StatusCode) } } // ============================================================================= // POST /api/discovery/git/* — input validation // ============================================================================= // // These tests only assert request-shape validation. The provider // implementations themselves are exercised by their own tests in // internal/staticsite; we don't reach upstream Git in unit tests. func TestDetectGitProvider_MissingBaseURL_400(t *testing.T) { e := newAPITestEnv(t) resp := e.do(t, http.MethodPost, "/api/discovery/git/detect-provider", map[string]string{}) if resp.StatusCode != http.StatusBadRequest { _ = decodeEnvelope(t, resp, nil) t.Fatalf("status = %d, want 400", resp.StatusCode) } } func TestTestGitConnection_MissingRepo_400(t *testing.T) { e := newAPITestEnv(t) resp := e.do(t, http.MethodPost, "/api/discovery/git/test-connection", map[string]string{ "base_url": "https://git.example.com", }) if resp.StatusCode != http.StatusBadRequest { _ = decodeEnvelope(t, resp, nil) t.Fatalf("status = %d, want 400", resp.StatusCode) } } func TestListGitBranches_MissingRepo_400(t *testing.T) { e := newAPITestEnv(t) resp := e.do(t, http.MethodPost, "/api/discovery/git/branches", map[string]string{ "base_url": "https://git.example.com", }) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400", resp.StatusCode) } } func TestListGitTree_MissingBranch_400(t *testing.T) { e := newAPITestEnv(t) resp := e.do(t, http.MethodPost, "/api/discovery/git/tree", map[string]string{ "base_url": "https://git.example.com", "repo_owner": "owner", "repo_name": "repo", }) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400", resp.StatusCode) } } func TestListGitRepos_MissingBaseURL_400(t *testing.T) { e := newAPITestEnv(t) resp := e.do(t, http.MethodPost, "/api/discovery/git/repos", map[string]string{}) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400", resp.StatusCode) } } // ============================================================================= // Validators added during security hardening — boundary checks the // providers depend on for safe URL interpolation. // ============================================================================= func TestValidateGitIdent(t *testing.T) { cases := []struct { name string input string wantError bool }{ {"ok_simple", "owner", false}, {"ok_with_dash", "my-org", false}, {"ok_with_dot", "user.name", false}, {"ok_with_underscore", "my_repo", false}, {"empty", "", true}, {"whitespace_only", " ", true}, {"leading_dot", ".hidden", true}, {"leading_dash", "-flag", true}, {"slash", "owner/repo", true}, {"traversal", "..", true}, {"path_traversal", "../admin", true}, {"with_space", "my org", true}, {"with_special", "owner;rm", true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := validateGitIdent("test", tc.input) if tc.wantError && err == nil { t.Errorf("validateGitIdent(%q) = nil, want error", tc.input) } if !tc.wantError && err != nil { t.Errorf("validateGitIdent(%q) = %v, want nil", tc.input, err) } }) } } func TestValidateGitBranch(t *testing.T) { cases := []struct { name string input string wantError bool }{ {"ok_main", "main", false}, {"ok_master", "master", false}, {"ok_with_slash", "feature/foo", false}, {"ok_release_tag", "release/v1.2.3", false}, {"empty", "", true}, {"traversal", "feature/..", true}, {"hidden_traversal", "feature/../admin", true}, {"leading_dash", "-flag", true}, {"with_space", "feature/my branch", true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := validateGitBranch(tc.input) if tc.wantError && err == nil { t.Errorf("validateGitBranch(%q) = nil, want error", tc.input) } if !tc.wantError && err != nil { t.Errorf("validateGitBranch(%q) = %v, want nil", tc.input, err) } }) } } func TestTestGitConnection_InvalidOwner_400(t *testing.T) { e := newAPITestEnv(t) resp := e.do(t, http.MethodPost, "/api/discovery/git/test-connection", map[string]string{ "base_url": "https://git.example.com", "repo_owner": "../admin", "repo_name": "repo", }) if resp.StatusCode != http.StatusBadRequest { _ = decodeEnvelope(t, resp, nil) t.Fatalf("status = %d, want 400 (traversal rejected)", resp.StatusCode) } } func TestListGitTree_InvalidBranch_400(t *testing.T) { e := newAPITestEnv(t) resp := e.do(t, http.MethodPost, "/api/discovery/git/tree", map[string]string{ "base_url": "https://git.example.com", "repo_owner": "owner", "repo_name": "repo", "branch": "feature/../admin", }) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400 (branch traversal rejected)", resp.StatusCode) } } func TestDetectGitProvider_InvalidScheme_400(t *testing.T) { e := newAPITestEnv(t) resp := e.do(t, http.MethodPost, "/api/discovery/git/detect-provider", map[string]string{ "base_url": "ftp://git.example.com", }) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400 (non-http scheme rejected)", resp.StatusCode) } }