package server import ( "bytes" "context" "fmt" "io" "net/http" "net/http/httptest" "net/url" "os" "strconv" "strings" "testing" "time" "git.unkin.net/unkin/artifactapi/internal/config" "git.unkin.net/unkin/artifactapi/internal/testsupport" ) var ( testTS *httptest.Server // the artifactapi router upstream *httptest.Server // mock upstream the proxy fetches from ) func TestMain(m *testing.M) { ctx := context.Background() dsn, termPG, err := testsupport.StartPostgres(ctx) if err != nil { os.Exit(m.Run()) } defer termPG() redisURL, termRedis, err := testsupport.StartRedis(ctx) if err != nil { termPG() os.Exit(m.Run()) } defer termRedis() minio, termMinio, err := testsupport.StartMinio(ctx) if err != nil { termPG() termRedis() os.Exit(m.Run()) } defer termMinio() u, _ := url.Parse(dsn) port, _ := strconv.Atoi(u.Port()) cfg := &config.Config{ ListenAddr: ":0", DBHost: u.Hostname(), DBPort: port, DBUser: "artifacts", DBPass: "artifacts123", DBName: "artifacts", DBSSL: "disable", RedisURL: redisURL, S3Endpoint: minio.Endpoint, S3AccessKey: minio.AccessKey, S3SecretKey: minio.SecretKey, S3Bucket: "server-test", } var srv *Server for i := 0; i < 20; i++ { // tolerate MinIO reporting ready before bucket ops succeed if srv, err = New(cfg, "test-version"); err == nil { break } time.Sleep(500 * time.Millisecond) } if err != nil { panic(err) } testTS = httptest.NewServer(srv.router) upstream = httptest.NewServer(http.HandlerFunc(mockUpstream)) code := m.Run() testTS.Close() upstream.Close() termMinio() termRedis() termPG() if code != 0 { os.Exit(code) } } func mockUpstream(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/data/file.bin": w.Write([]byte("upstream blob payload")) case "/helm-a/index.yaml": w.Write([]byte("apiVersion: v1\nentries:\n alpha:\n - name: alpha\n version: 1.0.0\n urls: [charts/alpha-1.0.0.tgz]\n")) case "/helm-b/index.yaml": w.Write([]byte("apiVersion: v1\nentries:\n beta:\n - name: beta\n version: 2.0.0\n urls: [charts/beta-2.0.0.tgz]\n")) default: http.NotFound(w, r) } } func requireStack(t *testing.T) { t.Helper() if testTS == nil { t.Skip("Docker unavailable; skipping server integration test") } } func req(t *testing.T, method, path string, body string) (*http.Response, []byte) { t.Helper() var r io.Reader if body != "" { r = strings.NewReader(body) } rq, _ := http.NewRequest(method, testTS.URL+path, r) if body != "" { rq.Header.Set("Content-Type", "application/json") } resp, err := http.DefaultClient.Do(rq) if err != nil { t.Fatalf("%s %s: %v", method, path, err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return resp, b } func TestServerHealthAndRoot(t *testing.T) { requireStack(t) if resp, _ := req(t, "GET", "/health", ""); resp.StatusCode != 200 { t.Errorf("health: %d", resp.StatusCode) } if resp, b := req(t, "GET", "/", ""); resp.StatusCode != 200 || !strings.Contains(string(b), "test-version") { t.Errorf("root: %d %s", resp.StatusCode, b) } if resp, _ := req(t, "GET", "/api/v2/health", ""); resp.StatusCode != 200 { t.Errorf("health v2: %d", resp.StatusCode) } } func TestServerRemoteAndProxy(t *testing.T) { requireStack(t) create := fmt.Sprintf(`{"name":"srv-remote","package_type":"generic","repo_type":"remote","base_url":%q,"stale_on_error":true}`, upstream.URL) if resp, b := req(t, "POST", "/api/v2/remotes", create); resp.StatusCode != 201 { t.Fatalf("create remote: %d %s", resp.StatusCode, b) } defer req(t, "DELETE", "/api/v2/remotes/srv-remote", "") if resp, _ := req(t, "GET", "/api/v2/remotes/srv-remote", ""); resp.StatusCode != 200 { t.Errorf("get remote: %d", resp.StatusCode) } if resp, _ := req(t, "GET", "/api/v2/remotes", ""); resp.StatusCode != 200 { t.Errorf("list remotes: %d", resp.StatusCode) } // Proxy fetch: miss then hit. resp, b := req(t, "GET", "/api/v1/remote/srv-remote/data/file.bin", "") if resp.StatusCode != 200 || string(b) != "upstream blob payload" { t.Fatalf("proxy miss: %d %s", resp.StatusCode, b) } if src := resp.Header.Get("X-Artifact-Source"); src != "remote" { t.Errorf("expected remote source, got %q", src) } resp, _ = req(t, "GET", "/api/v1/remote/srv-remote/data/file.bin", "") if resp.Header.Get("X-Artifact-Source") != "cache" { t.Errorf("second fetch should be cache: %q", resp.Header.Get("X-Artifact-Source")) } // Objects listing + stats now that we have an artifact. if resp, _ := req(t, "GET", "/api/v2/remotes/srv-remote/objects", ""); resp.StatusCode != 200 { t.Errorf("objects: %d", resp.StatusCode) } if resp, _ := req(t, "GET", "/api/v2/stats", ""); resp.StatusCode != 200 { t.Errorf("stats: %d", resp.StatusCode) } for _, p := range []string{"/api/v2/stats/top-remotes", "/api/v2/stats/top-files-by-hits", "/api/v2/stats/top-files-by-bandwidth"} { if resp, _ := req(t, "GET", p, ""); resp.StatusCode != 200 { t.Errorf("%s: %d", p, resp.StatusCode) } } } func TestServerLocalUpload(t *testing.T) { requireStack(t) if resp, b := req(t, "POST", "/api/v2/remotes", `{"name":"srv-local","package_type":"generic","repo_type":"local"}`); resp.StatusCode != 201 { t.Fatalf("create local: %d %s", resp.StatusCode, b) } defer req(t, "DELETE", "/api/v2/remotes/srv-local", "") rq, _ := http.NewRequest("PUT", testTS.URL+"/api/v2/remotes/srv-local/files/dir/hello.bin", strings.NewReader("local payload")) resp, err := http.DefaultClient.Do(rq) if err != nil || resp.StatusCode != 201 { t.Fatalf("upload: %v %d", err, resp.StatusCode) } resp.Body.Close() resp, b := req(t, "GET", "/api/v1/local/srv-local/dir/hello.bin", "") if resp.StatusCode != 200 || string(b) != "local payload" { t.Errorf("download local: %d %s", resp.StatusCode, b) } } func TestServerVirtualMerge(t *testing.T) { requireStack(t) for _, m := range []string{"a", "b"} { body := fmt.Sprintf(`{"name":"srv-helm-%s","package_type":"helm","repo_type":"remote","base_url":"%s/helm-%s","stale_on_error":true}`, m, upstream.URL, m) if resp, b := req(t, "POST", "/api/v2/remotes", body); resp.StatusCode != 201 { t.Fatalf("create helm-%s: %d %s", m, resp.StatusCode, b) } defer req(t, "DELETE", "/api/v2/remotes/srv-helm-"+m, "") } if resp, b := req(t, "POST", "/api/v2/virtuals", `{"name":"srv-vh","package_type":"helm","members":["srv-helm-a","srv-helm-b"]}`); resp.StatusCode != 201 { t.Fatalf("create virtual: %d %s", resp.StatusCode, b) } defer req(t, "DELETE", "/api/v2/virtuals/srv-vh", "") resp, b := req(t, "GET", "/api/v1/virtual/srv-vh/index.yaml", "") if resp.StatusCode != 200 { t.Fatalf("virtual fetch: %d %s", resp.StatusCode, b) } s := string(b) if !strings.Contains(s, "alpha") || !strings.Contains(s, "beta") { t.Errorf("merged index missing charts: %s", s) } } func TestServerProbe(t *testing.T) { requireStack(t) body := fmt.Sprintf(`{"package_type":"generic","base_url":%q,"path":"data/file.bin"}`, upstream.URL) resp, _ := req(t, "POST", "/api/v2/probe", body) // Probe should reach the mock upstream and report reachable (200) or a // structured result; either way not a server error. if resp.StatusCode >= 500 { t.Errorf("probe server error: %d", resp.StatusCode) } } func put(t *testing.T, path string, body []byte) (*http.Response, []byte) { t.Helper() rq, _ := http.NewRequest("PUT", testTS.URL+path, bytes.NewReader(body)) resp, err := http.DefaultClient.Do(rq) if err != nil { t.Fatalf("PUT %s: %v", path, err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return resp, b } func TestServerLocalPyPI(t *testing.T) { requireStack(t) if resp, b := req(t, "POST", "/api/v2/remotes", `{"name":"srv-pypi","package_type":"pypi","repo_type":"local"}`); resp.StatusCode != 201 { t.Fatalf("create pypi local: %d %s", resp.StatusCode, b) } defer req(t, "DELETE", "/api/v2/remotes/srv-pypi", "") if resp, b := put(t, "/api/v2/remotes/srv-pypi/files/foo-1.0-py3-none-any.whl", []byte("wheel bytes")); resp.StatusCode != 201 { t.Fatalf("upload wheel: %d %s", resp.StatusCode, b) } // Re-uploading the same file is rejected. if resp, _ := put(t, "/api/v2/remotes/srv-pypi/files/foo-1.0-py3-none-any.whl", []byte("again")); resp.StatusCode != 409 { t.Errorf("expected 409 on overwrite, got %d", resp.StatusCode) } // Invalid pypi filename rejected. if resp, _ := put(t, "/api/v2/remotes/srv-pypi/files/not-a-package.txt", []byte("x")); resp.StatusCode != 400 { t.Errorf("expected 400 for bad filename, got %d", resp.StatusCode) } if resp, b := req(t, "GET", "/api/v1/local/srv-pypi/simple/", ""); resp.StatusCode != 200 || !strings.Contains(string(b), "foo") { t.Errorf("simple index: %d %s", resp.StatusCode, b) } if resp, b := req(t, "GET", "/api/v1/local/srv-pypi/simple/foo/", ""); resp.StatusCode != 200 || !strings.Contains(string(b), "foo-1.0-py3-none-any.whl") { t.Errorf("package index: %d %s", resp.StatusCode, b) } } func TestServerLocalRPMRepodata(t *testing.T) { requireStack(t) rpm, err := os.ReadFile("../provider/rpm/testdata/e2e-testpkg-1.0-1.noarch.rpm") if err != nil { t.Skipf("rpm fixture unavailable: %v", err) } if resp, b := req(t, "POST", "/api/v2/remotes", `{"name":"srv-rpm","package_type":"rpm","repo_type":"local"}`); resp.StatusCode != 201 { t.Fatalf("create rpm local: %d %s", resp.StatusCode, b) } defer req(t, "DELETE", "/api/v2/remotes/srv-rpm", "") if resp, b := put(t, "/api/v2/remotes/srv-rpm/files/e2e-testpkg-1.0-1.noarch.rpm", rpm); resp.StatusCode != 201 { t.Fatalf("upload rpm: %d %s", resp.StatusCode, b) } // repodata is generated asynchronously; poll for it. var body []byte for i := 0; i < 40; i++ { var resp *http.Response resp, body = req(t, "GET", "/api/v1/local/srv-rpm/repodata/repomd.xml", "") if resp.StatusCode == 200 && strings.Contains(string(body), "= 400 { t.Errorf("evict object: %d", resp.StatusCode) } } func TestServerValidationErrors(t *testing.T) { requireStack(t) if resp, _ := req(t, "POST", "/api/v2/remotes", `{"name":"bad","package_type":"bogus","base_url":"https://x"}`); resp.StatusCode != 400 { t.Errorf("invalid package type: %d", resp.StatusCode) } if resp, _ := req(t, "POST", "/api/v2/remotes", `{"name":"bad","package_type":"generic","repo_type":"remote"}`); resp.StatusCode != 400 { t.Errorf("missing base_url: %d", resp.StatusCode) } if resp, _ := req(t, "POST", "/api/v2/remotes", `not json`); resp.StatusCode != 400 { t.Errorf("invalid json: %d", resp.StatusCode) } } func TestServerDockerAndHead(t *testing.T) { requireStack(t) create := fmt.Sprintf(`{"name":"srv-docker","package_type":"generic","repo_type":"remote","base_url":%q,"stale_on_error":true}`, upstream.URL) req(t, "POST", "/api/v2/remotes", create) defer req(t, "DELETE", "/api/v2/remotes/srv-docker", "") // Docker registry ping. if resp, _ := req(t, "GET", "/v2/", ""); resp.StatusCode != 200 { t.Errorf("docker ping: %d", resp.StatusCode) } // HEAD through the docker route resolves metadata (uncached -> upstream). rq, _ := http.NewRequest("HEAD", testTS.URL+"/v2/srv-docker/data/file.bin", nil) resp, err := http.DefaultClient.Do(rq) if err != nil { t.Fatalf("head: %v", err) } resp.Body.Close() if resp.StatusCode != 200 { t.Errorf("head status: %d", resp.StatusCode) } } func TestServerRemoteUpdateAndVirtualCRUD(t *testing.T) { requireStack(t) req(t, "POST", "/api/v2/remotes", `{"name":"srv-upd","package_type":"helm","repo_type":"remote","base_url":"https://a.example.com","stale_on_error":true}`) defer req(t, "DELETE", "/api/v2/remotes/srv-upd", "") if resp, b := req(t, "PUT", "/api/v2/remotes/srv-upd", `{"package_type":"helm","base_url":"https://b.example.com","stale_on_error":true}`); resp.StatusCode != 200 { t.Errorf("update remote: %d %s", resp.StatusCode, b) } req(t, "POST", "/api/v2/virtuals", `{"name":"srv-v2","package_type":"helm","members":["srv-upd"]}`) defer req(t, "DELETE", "/api/v2/virtuals/srv-v2", "") if resp, _ := req(t, "GET", "/api/v2/virtuals/srv-v2", ""); resp.StatusCode != 200 { t.Errorf("get virtual: %d", resp.StatusCode) } if resp, _ := req(t, "GET", "/api/v2/virtuals", ""); resp.StatusCode != 200 { t.Errorf("list virtuals: %d", resp.StatusCode) } if resp, b := req(t, "PUT", "/api/v2/virtuals/srv-v2", `{"package_type":"helm","members":["srv-upd"]}`); resp.StatusCode != 200 { t.Errorf("update virtual: %d %s", resp.StatusCode, b) } } func TestServerLocalRemoveAndMissing(t *testing.T) { requireStack(t) req(t, "POST", "/api/v2/remotes", `{"name":"srv-rm","package_type":"generic","repo_type":"local"}`) defer req(t, "DELETE", "/api/v2/remotes/srv-rm", "") put(t, "/api/v2/remotes/srv-rm/files/a/b.bin", []byte("payload")) if resp, _ := req(t, "DELETE", "/api/v2/remotes/srv-rm/files/a/b.bin", ""); resp.StatusCode >= 400 { t.Errorf("delete local file: %d", resp.StatusCode) } if resp, _ := req(t, "GET", "/api/v1/local/srv-rm/a/b.bin", ""); resp.StatusCode != 404 { t.Errorf("expected 404 for removed file, got %d", resp.StatusCode) } } func TestServerProbeUnreachable(t *testing.T) { requireStack(t) resp, _ := req(t, "POST", "/api/v2/probe", `{"package_type":"generic","base_url":"http://127.0.0.1:1","path":"x"}`) if resp.StatusCode >= 500 { t.Errorf("probe of unreachable upstream should not 500: %d", resp.StatusCode) } } func TestServerEvents(t *testing.T) { requireStack(t) client := &http.Client{Timeout: 800 * time.Millisecond} resp, err := client.Get(testTS.URL + "/api/v2/events") if err == nil { resp.Body.Close() if resp.StatusCode != 200 { t.Errorf("events status: %d", resp.StatusCode) } } // A timeout is expected for a streaming endpoint; the handler still ran. } func TestServerNotFound(t *testing.T) { requireStack(t) if resp, _ := req(t, "GET", "/api/v2/remotes/does-not-exist", ""); resp.StatusCode != 404 { t.Errorf("expected 404, got %d", resp.StatusCode) } if resp, _ := req(t, "GET", "/api/v1/remote/nope/x", ""); resp.StatusCode != 404 { t.Errorf("expected 404 for unknown remote, got %d", resp.StatusCode) } }