package api import ( "archive/tar" "context" "encoding/json" "fmt" "git.misaka.ren/m1saka/dsh-web/internal/docker" "git.misaka.ren/m1saka/dsh-web/internal/store" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "sync/atomic" "testing" "time" ) func imageTestAPI(t *testing.T, h http.HandlerFunc) (*API, string) { t.Helper() oldDiskAvailable := buildDiskAvailable buildDiskAvailable = func(string) (uint64, error) { return minimumBuildDiskBytes, nil } t.Cleanup(func() { buildDiskAvailable = oldDiskAvailable }) oldClient := npmBuildClient npmBuildClient = &http.Client{Transport: imageRoundTrip(func(r *http.Request) (*http.Response, error) { return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"version":"0.2.0-rc.1"}`))}, nil })} t.Cleanup(func() { npmBuildClient = oldClient }) root := t.TempDir() t.Setenv("DSH_DOCKER_ROOT_VISIBLE", root) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/v1.47/info" { writeJSON(w, map[string]string{"DockerRootDir": root}) return } h(w, r) })) t.Cleanup(srv.Close) s, err := store.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) } s.DB.SetMaxOpenConns(1) a := New(s, t.TempDir()) a.docker = docker.New(srv.URL) t.Cleanup(func() { deadline := time.Now().Add(5 * time.Second) for { buildQueue.Lock() active := buildQueue.active[s.DB] buildQueue.Unlock() if !active { break } if time.Now().After(deadline) { t.Error("worker did not finish") break } time.Sleep(time.Millisecond) } s.Close() }) if err = s.CreateUser(store.User{ID: "u", Username: "user"}, "unused"); err != nil { t.Fatal(err) } sid, _, err := s.Session("u") if err != nil { t.Fatal(err) } registerTestAccess(t, a, sid) return a, sid } func imageRequest(h http.HandlerFunc, sid, method, body string) *httptest.ResponseRecorder { r := httptest.NewRequest(method, "/", strings.NewReader(body)) if sid != "" { r.AddCookie(&http.Cookie{Name: "dsh_session", Value: sid}) addTestAccess(r, sid) } w := httptest.NewRecorder() h(w, r) return w } func TestImageListContract(t *testing.T) { a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/v1.47/images/unverified/json" { writeImageInspect(w, "unverified", "0.2.0-rc.1") return } if r.URL.Path == "/v1.47/images/sha256:abc/json" { writeImageInspect(w, "sha256:abc", "0.2.0-rc.1") return } if !strings.Contains(r.URL.Query().Get("filters"), "dsh-mgr.role=runtime") { t.Error("missing label filter") } io.WriteString(w, `[{"Id":"sha256:abc","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"0.2.0-rc.1","dsh-mgr.recipe":"`+imageRecipeRev+`"},"RepoTags":["runtime:0.2.0-rc.1"]},{"Id":"other","Labels":{}},{"Id":"unverified","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"0.2.0-rc.1","dsh-mgr.recipe":"`+imageRecipeRev+`"}},{"Id":"sha256:abc","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"9.9.9"}}]`) }) seedVerifiedImage(t, a, "sha256:abc", "0.2.0-rc.1") if w := imageRequest(a.images, "", "GET", ""); w.Code != 401 { t.Fatal(w.Code) } w := imageRequest(a.images, sid, "GET", "") var images []imageRecord if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil { t.Fatal(err) } if len(images) != 2 || images[0].ID != "sha256:abc" || images[0].ImageID != images[0].ID || images[0].Status != "available" || images[1].Status != "unverified" { t.Fatal(w.Body.String()) } if w = imageRequest(a.images, sid, "POST", ""); w.Code != 405 { t.Fatal(w.Code) } } func seedVerifiedImage(t *testing.T, a *API, id, version string) { t.Helper() buildQueue.Lock() defer buildQueue.Unlock() if err := a.ensureBuilds(); err != nil { t.Fatal(err) } if _, err := a.s.DB.Exec("INSERT INTO image_builds(id,version,status,image_id,created_at,recipe_rev,platform) VALUES(?,?,'success',?,'now',?,?)", store.ID(), version, id, imageRecipeRev, imagePlatform); err != nil { t.Fatal(err) } if _, err := a.s.DB.Exec("INSERT OR REPLACE INTO image_admissions(image_id,version,recipe_rev,platform,state,evidence,admitted_by,admitted_at) VALUES(?,?,?,?,'compatible','test fixture explicit acceptance','test','now')", id, version, imageRecipeRev, imagePlatform); err != nil { t.Fatal(err) } } func TestImageContextAndSanitizing(t *testing.T) { r, err := imageBuildContext() if err != nil { t.Fatal(err) } tr := tar.NewReader(r) n := 0 for { h, err := tr.Next() if err == io.EOF { break } if err != nil { t.Fatal(err) } data, _ := io.ReadAll(tr) original, err := os.ReadFile(filepath.Join("..", "..", "image", h.Name)) if err != nil || string(data) != string(original) { t.Fatalf("recipe drift %s: %v", h.Name, err) } if h.Name == "Dockerfile" && (!strings.Contains(string(data), "ARG RECIPE_REV="+imageRecipeRev+"\n") || !strings.Contains(string(data), "dsh-mgr.recipe=$RECIPE_REV")) { t.Fatal("recipe revision or label differs from image policy") } n++ } if n != 7 { t.Fatal(n) } s := sanitizeBuildLog("token=topsecret Authorization: Bearer hidden password=private https://user:pass@example.com/") for _, secret := range []string{"topsecret", "hidden", "private", "user:pass"} { if strings.Contains(s, secret) { t.Fatal(s) } } if len(sanitizeBuildLog(strings.Repeat("x", 2*buildLogLimit))) > buildLogLimit { t.Fatal("unbounded") } for _, v := range []string{"latest", "*", "^0.2.0-rc.1", "1.2", "0.2.0-rc.1;id", "https://example.com", ""} { if validBuildVersion(v) { t.Fatal(v) } } if !validBuildVersion("0.2.0-rc.1-beta.1") { t.Fatal("fixed prerelease rejected") } } type imageRoundTrip func(*http.Request) (*http.Response, error) func (f imageRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } func TestLatestOfficialRegistry(t *testing.T) { client := &http.Client{Transport: imageRoundTrip(func(r *http.Request) (*http.Response, error) { if r.URL.Host != "registry.npmjs.org" || r.URL.Scheme != "https" { t.Fatal(r.URL) } return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"version":"0.2.0-rc.1"}`)), Header: make(http.Header)}, nil })} v, err := fetchLatest(context.Background(), client) if err != nil || v != "0.2.0-rc.1" { t.Fatalf("%s %v", v, err) } } func TestBuildDedupAndVerification(t *testing.T) { var calls, deleted atomic.Int32 a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1.47/images/sha256:abc/json", "/v1.47/images/sha256:reuse/json": writeImageInspect(w, strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/v1.47/images/"), "/json"), "0.2.0-rc.1") case "/v1.47/images/json": if calls.Load() == 0 { io.WriteString(w, "[]") } else { io.WriteString(w, `[{"Id":"sha256:abc","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"0.2.0-rc.1","dsh-mgr.recipe":"`+imageRecipeRev+`"}}]`) } case "/v1.47/build": calls.Add(1) if r.URL.Query().Get("t") != "dsh-web:0.2.0-rc.1-"+imageRecipeRev { t.Error("unexpected tag", r.URL.Query()) } var args map[string]string json.Unmarshal([]byte(r.URL.Query().Get("buildargs")), &args) if args["DSH_VERSION"] != "0.2.0-rc.1" { t.Error(args) } io.Copy(io.Discard, r.Body) io.WriteString(w, `{"stream":"token=secretvalue"}`+string(rune(10))+`{"aux":{"ID":"sha256:abc"}}`) case "/v1.47/containers/create": var cfg struct { Image string HostConfig struct { NetworkMode string ReadonlyRootfs bool Tmpfs map[string]string } } json.NewDecoder(r.Body).Decode(&cfg) if cfg.Image != "sha256:abc" || cfg.HostConfig.NetworkMode != "none" || !cfg.HostConfig.ReadonlyRootfs || !strings.Contains(cfg.HostConfig.Tmpfs["/tmp"], "exec") { t.Errorf("unsafe config %+v", cfg) } io.WriteString(w, `{"Id":"check"}`) case "/v1.47/containers/check/start", "/v1.47/exec/probe/start": w.WriteHeader(204) case "/v1.47/containers/check/exec": var cfg struct{ Cmd []string } if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil || len(cfg.Cmd) != 3 || !strings.Contains(cfg.Cmd[2], "execFileSync('dsh',['--version']") || !strings.Contains(cfg.Cmd[2], `actual!=="0.2.0-rc.1"`) { t.Errorf("missing version verification: %+v %v", cfg, err) } io.WriteString(w, `{"Id":"probe"}`) case "/v1.47/exec/probe/json": io.WriteString(w, `{"Running":false,"ExitCode":0}`) case "/v1.47/containers/check/json": io.WriteString(w, `{"State":{"Running":true}}`) case "/v1.47/containers/check": deleted.Add(1) w.WriteHeader(204) default: t.Errorf("unexpected %s", r.URL) w.WriteHeader(500) } }) responses := make(chan *httptest.ResponseRecorder, 8) var wg sync.WaitGroup for i := 0; i < 8; i++ { wg.Add(1) go func() { defer wg.Done() responses <- imageRequest(a.builds, sid, "POST", `{"version":"0.2.0-rc.1"}`) }() } wg.Wait() close(responses) first := "" for w := range responses { if w.Code != 202 { t.Fatal(w.Body.String()) } var b buildRecord json.Unmarshal(w.Body.Bytes(), &b) if first == "" { first = b.ID } if first != b.ID { t.Fatal("duplicate job") } } deadline := time.Now().Add(5 * time.Second) for { w := imageRequest(a.builds, sid, "GET", "") var jobs []buildRecord json.Unmarshal(w.Body.Bytes(), &jobs) if len(jobs) == 1 && jobs[0].Status == "success" { if strings.Contains(jobs[0].Logs, "secretvalue") { t.Fatal(jobs) } break } if time.Now().After(deadline) { t.Fatal(w.Body.String()) } time.Sleep(time.Millisecond) } if calls.Load() != 1 || deleted.Load() != 1 { t.Fatalf("builds %d cleanup %d", calls.Load(), deleted.Load()) } for _, body := range []string{`{"version":"latest"}`, `{"version":"0.2.0-rc.1","url":"bad"}`, `{"version":"0.2.0-rc.1"} {}`} { if w := imageRequest(a.builds, sid, "POST", body); w.Code != 400 { t.Fatal(w.Code) } } } func TestVerificationFailureCleansUp(t *testing.T) { var deleted atomic.Bool a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasSuffix(r.URL.Path, "/create"): io.WriteString(w, `{"Id":"bad"}`) case r.Method == "DELETE": deleted.Store(true) w.WriteHeader(204) default: w.WriteHeader(500) io.WriteString(w, `{"message":"cannot start"}`) } }) if err := a.verifyBuiltImage(context.Background(), "bad", "0.2.0-rc.1"); err == nil { t.Fatal("accepted failed start") } if !deleted.Load() { t.Fatal("container leaked") } } func TestBuildReuseAndMissingImage(t *testing.T) { var exists atomic.Bool exists.Store(true) var builds, checks atomic.Int32 a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1.47/images/sha256:abc/json", "/v1.47/images/sha256:reuse/json": writeImageInspect(w, strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/v1.47/images/"), "/json"), "0.2.0-rc.1") case "/v1.47/images/json": if exists.Load() { io.WriteString(w, `[{"Id":"sha256:reuse","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"0.2.0-rc.1","dsh-mgr.recipe":"`+imageRecipeRev+`"}}]`) } else { io.WriteString(w, "[]") } case "/v1.47/build": builds.Add(1) exists.Store(true) io.Copy(io.Discard, r.Body) io.WriteString(w, `{"aux":{"ID":"sha256:reuse"}}`) case "/v1.47/containers/create": checks.Add(1) io.WriteString(w, `{"Id":"c"}`) case "/v1.47/containers/c/start", "/v1.47/exec/e/start", "/v1.47/containers/c": w.WriteHeader(204) case "/v1.47/containers/c/exec": io.WriteString(w, `{"Id":"e"}`) case "/v1.47/exec/e/json": io.WriteString(w, `{"Running":false,"ExitCode":0}`) case "/v1.47/containers/c/json": io.WriteString(w, `{"State":{"Running":true}}`) default: t.Errorf("unexpected %s", r.URL) w.WriteHeader(500) } }) wait := func(id string) { t.Helper() deadline := time.Now().Add(5 * time.Second) for { var status, image string err := a.s.DB.QueryRow("SELECT status,image_id FROM image_builds WHERE id=?", id).Scan(&status, &image) if err == nil && status == "success" { if image != "sha256:reuse" { t.Fatal(image) } return } if time.Now().After(deadline) { t.Fatalf("status %s: %v", status, err) } time.Sleep(time.Millisecond) } } post := func() buildRecord { t.Helper() w := imageRequest(a.builds, sid, "POST", `{"version":"0.2.0-rc.1"}`) if w.Code != 202 { t.Fatal(w.Body.String()) } var b buildRecord if err := json.Unmarshal(w.Body.Bytes(), &b); err != nil { t.Fatal(err) } return b } first := post() wait(first.ID) if builds.Load() != 0 || checks.Load() != 1 { t.Fatal("local image not reused and verified") } exists.Store(false) second := post() if second.ID == first.ID { t.Fatal("missing image deduplicated") } wait(second.ID) if builds.Load() != 1 || checks.Load() != 2 { t.Fatal("missing image not rebuilt") } } func TestBuildRestartRecovery(t *testing.T) { a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(500) }) buildQueue.Lock() defer buildQueue.Unlock() if err := a.ensureBuilds(); err != nil { t.Fatal(err) } if _, err := a.s.DB.Exec("INSERT INTO image_builds(id,version,status,created_at) VALUES('old','0.2.0-rc.1','running','now')"); err != nil { t.Fatal(err) } delete(buildQueue.initialized, a.s.DB) if err := a.ensureBuilds(); err != nil { t.Fatal(err) } var status, detail string if err := a.s.DB.QueryRow("SELECT status,error FROM image_builds WHERE id='old'").Scan(&status, &detail); err != nil { t.Fatal(err) } if status != "failed" || !strings.Contains(detail, "restart") { t.Fatalf("%s %s", status, detail) } } func TestFailedVerificationNeverAvailable(t *testing.T) { var deleted atomic.Bool imageID := "sha256:" + strings.Repeat("c", 64) a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1.47/images/sha256:abc/json", "/v1.47/images/sha256:reuse/json": writeImageInspect(w, strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/v1.47/images/"), "/json"), "0.2.0-rc.1") case "/v1.47/images/json": writeJSON(w, []any{map[string]any{"Id": imageID, "Labels": map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": "0.2.0-rc.1", "dsh-mgr.recipe": imageRecipeRev}}}) case "/v1.47/containers/create": io.WriteString(w, `{"Id":"check"}`) case "/v1.47/containers/check/start", "/v1.47/exec/probe/start": w.WriteHeader(204) case "/v1.47/containers/check/exec": io.WriteString(w, `{"Id":"probe"}`) case "/v1.47/exec/probe/json": io.WriteString(w, `{"Running":false,"ExitCode":1}`) case "/v1.47/containers/check": deleted.Store(true) w.WriteHeader(204) case "/v1.47/images/" + imageID + "/json": writeImageInspect(w, imageID, "0.2.0-rc.1") default: t.Errorf("unexpected Docker call %s", r.URL) w.WriteHeader(500) } }) w := imageRequest(a.builds, sid, "POST", `{"version":"0.2.0-rc.1"}`) if w.Code != 202 { t.Fatal(w.Body.String()) } deadline := time.Now().Add(5 * time.Second) for { var status, id, detail string err := a.s.DB.QueryRow("SELECT status,image_id,error FROM image_builds").Scan(&status, &id, &detail) if err == nil && status == "failed" { if id != imageID || !strings.Contains(detail, "verification failed") || !deleted.Load() { t.Fatalf("%s %s cleanup=%v", id, detail, deleted.Load()) } break } if time.Now().After(deadline) { t.Fatalf("status=%s error=%v", status, err) } time.Sleep(time.Millisecond) } w = imageRequest(a.images, sid, "GET", "") var images []imageRecord if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil || w.Code != 200 || len(images) != 1 || images[0].Status != "unverified" { t.Fatal("failed image advertised", w.Body.String()) } w = imageRequest(a.instances, sid, "POST", fmt.Sprintf(`{"name":"rejected","image_id":%q}`, imageID)) if w.Code != 400 { t.Fatal("unverified image accepted", w.Code, w.Body.String()) } } func TestLocalBuildImagePrefersPersistedID(t *testing.T) { a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/v1.47/images/verified/json" { writeImageInspect(w, "verified", "0.2.0-rc.1") return } io.WriteString(w, `[{"Id":"newer","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"0.2.0-rc.1","dsh-mgr.recipe":"`+imageRecipeRev+`"}},{"Id":"verified","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"0.2.0-rc.1","dsh-mgr.recipe":"`+imageRecipeRev+`"}}]`) }) id, err := a.localBuildImage(context.Background(), "0.2.0-rc.1", "verified") if err != nil || id != "verified" { t.Fatalf("id=%s err=%v", id, err) } } func writeImageInspect(w http.ResponseWriter, id, version string) { parts := strings.Split(imagePlatform, "/") writeJSON(w, map[string]any{"Id": id, "Os": parts[0], "Architecture": parts[1], "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": version, "dsh-mgr.recipe": imageRecipeRev}}}) }