diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..529c547 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,239 @@ +package server + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strconv" + "strings" + "testing" + + "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", + } + + srv, err := New(cfg, "test-version") + 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 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) + } +}