package adminapi import ( "database/sql" "net/http" "os" "testing" "time" "github.com/iceBear67/simplepages/api" "github.com/iceBear67/simplepages/internal/cas" "github.com/iceBear67/simplepages/internal/deploy" ) // exec runs a statement against the store. Tests use it to put the database // into a state the API cannot produce — drifted reference counts, above all, // which is the only thing fsck exists to find. func (e *env) exec(t *testing.T, query string, args ...any) { t.Helper() err := e.db.Tx(t.Context(), func(tx *sql.Tx) error { _, err := tx.ExecContext(t.Context(), query, args...) return err }) if err != nil { t.Fatalf("%s: %v", query, err) } } // oneFile is a single-page deployment, enough to give each version content no // other version shares — which is what makes the collector's effect visible. func oneFile(version string) map[string]string { return map[string]string{"index.html": "

" + version + "

"} } // hasContent reports whether the content store still holds these bytes. func (e *env) hasContent(t *testing.T, content string) bool { t.Helper() ok, err := e.cas.Has(cas.Sum([]byte(content))) if err != nil { t.Fatalf("cas has: %v", err) } return ok } func (e *env) activate(t *testing.T, token, project, id string) { t.Helper() status, body := e.do(t, http.MethodPost, api.PathActivate(project, id), token, nil) mustJSON(t, status, http.StatusOK, body, nil) } func (e *env) listDeployments(t *testing.T, token, project, query string) api.DeploymentList { t.Helper() status, body := e.do(t, http.MethodGet, api.PathDeployments(project)+query, token, nil) var out api.DeploymentList mustJSON(t, status, http.StatusOK, body, &out) return out } func ids(list api.DeploymentList) []string { out := make([]string, 0, len(list.Deployments)) for _, d := range list.Deployments { out = append(out, d.ID) } return out } func TestListDeployments(t *testing.T) { e := newEnv(t) e.createProject(t, "demo") token := e.mintProject(t, e.projectID(t, "demo"), "ci") d1 := e.readyDeployment(t, token, "demo", oneFile("v1")) d2 := e.readyDeployment(t, token, "demo", oneFile("v2")) // Left pending: a listing exists partly so an operator can see the // deployments that never finished. d3 := e.startDeployment(t, token, "demo") e.activate(t, token, "demo", d1.ID) all := e.listDeployments(t, token, "demo", "") if got, want := ids(all), []string{d3.ID, d2.ID, d1.ID}; !equalStrings(got, want) { t.Errorf("ids = %v, want newest first %v", got, want) } if all.NextCursor != "" { t.Errorf("next_cursor = %q, want empty when a page is the whole list", all.NextCursor) } for _, d := range all.Deployments { if d.Project != "demo" { t.Errorf("deployment %s: project = %q", d.ID, d.Project) } if (d.ID == d1.ID) != d.Active { t.Errorf("deployment %s: active = %v, want it only for the activated one", d.ID, d.Active) } if len(d.Files) != 0 { t.Errorf("deployment %s: a listing carried %d manifest entries", d.ID, len(d.Files)) } } ready := e.listDeployments(t, token, "demo", "?state=ready") if got, want := ids(ready), []string{d2.ID, d1.ID}; !equalStrings(got, want) { t.Errorf("state=ready = %v, want %v", got, want) } if got := ids(e.listDeployments(t, token, "demo", "?state=failed")); len(got) != 0 { t.Errorf("state=failed = %v, want none", got) } // Paging: one at a time, following the cursor, visits the same list. var paged []string cursor := "" for range 5 { q := "?limit=1" if cursor != "" { q += "&cursor=" + cursor } page := e.listDeployments(t, token, "demo", q) paged = append(paged, ids(page)...) cursor = page.NextCursor if cursor == "" { break } } if want := []string{d3.ID, d2.ID, d1.ID}; !equalStrings(paged, want) { t.Errorf("paged = %v, want %v", paged, want) } // A typo in the filter is an error, not an empty list that reads as "this // project has no deployments". status, body := e.do(t, http.MethodGet, api.PathDeployments("demo")+"?state=redy", token, nil) if status != http.StatusBadRequest { t.Fatalf("state=redy: status = %d; body: %s", status, body) } if code := errCode(t, body); code != api.CodeBadRequest { t.Errorf("code = %q, want %q", code, api.CodeBadRequest) } status, body = e.do(t, http.MethodGet, api.PathDeployments("demo")+"?limit=9000", token, nil) if status != http.StatusBadRequest { t.Errorf("limit=9000: status = %d; body: %s", status, body) } // Another project's key cannot read this project's deployments. e.createProject(t, "other") otherToken := e.mintProject(t, e.projectID(t, "other"), "other-ci") status, body = e.do(t, http.MethodGet, api.PathDeployments("demo"), otherToken, nil) if status != http.StatusForbidden { t.Errorf("cross-project list: status = %d; body: %s", status, body) } } func TestGetDeployment(t *testing.T) { e := newEnv(t) e.createProject(t, "demo") token := e.mintProject(t, e.projectID(t, "demo"), "ci") contents := map[string]string{ "index.html": "

hello

", "assets/app.js": "console.log(1)", } dep := e.readyDeployment(t, token, "demo", contents) status, body := e.do(t, http.MethodGet, api.PathDeployment("demo", dep.ID), token, nil) var got api.Deployment mustJSON(t, status, http.StatusOK, body, &got) if got.ID != dep.ID || got.State != api.StateReady || got.FileCount != 2 { t.Errorf("deployment = %+v", got) } if got.Files != nil { t.Errorf("files = %v, want them withheld unless asked for", got.Files) } if got.URL != "" { t.Errorf("url = %q, want none: this deployment is not the one being served", got.URL) } status, body = e.do(t, http.MethodGet, api.PathDeployment("demo", dep.ID)+"?files=true", token, nil) got = api.Deployment{} mustJSON(t, status, http.StatusOK, body, &got) if len(got.Files) != len(contents) { t.Fatalf("files = %+v, want %d entries", got.Files, len(contents)) } for _, f := range got.Files { content, ok := contents[f.Path] if !ok { t.Errorf("unexpected manifest path %q", f.Path) continue } if f.Digest != cas.Sum([]byte(content)).String() || f.Size != int64(len(content)) { t.Errorf("%s = %+v, want the digest and size of its content", f.Path, f) } } if got.Files[0].Path != "assets/app.js" { t.Errorf("files start at %q, want them ordered by path", got.Files[0].Path) } status, body = e.do(t, http.MethodGet, api.PathDeployment("demo", "dpl_0000000000000000"), token, nil) if status != http.StatusNotFound || errCode(t, body) != api.CodeNotFound { t.Errorf("unknown id: status = %d; body: %s", status, body) } // A deployment is looked up within its project, so naming it under another // project is "no such deployment" — not a way to read across the boundary, // and not a confirmation that the id exists somewhere. e.createProject(t, "other") status, body = e.do(t, http.MethodGet, api.PathDeployment("other", dep.ID), e.adminToken, nil) if status != http.StatusNotFound || errCode(t, body) != api.CodeNotFound { t.Errorf("cross-project read: status = %d; body: %s", status, body) } } func TestDeleteDeploymentEndpoint(t *testing.T) { e := newEnv(t) e.createProject(t, "demo") pid := e.projectID(t, "demo") token := e.mintProject(t, pid, "ci") keep := e.readyDeployment(t, token, "demo", oneFile("v1")) spare := e.readyDeployment(t, token, "demo", oneFile("v2")) e.activate(t, token, "demo", keep.ID) // The one being served is a conflict, and specifically not a 403: the // caller is allowed to do this, just not yet. status, body := e.do(t, http.MethodDelete, api.PathDeployment("demo", keep.ID), token, nil) if status != http.StatusConflict { t.Fatalf("delete active: status = %d; body: %s", status, body) } if code := errCode(t, body); code != api.CodeDeploymentActive { t.Errorf("code = %q, want %q", code, api.CodeDeploymentActive) } dir := deploy.DeploymentDir(e.deployDir, pid, spare.ID) if _, err := os.Stat(dir); err != nil { t.Fatalf("stat %s: %v", dir, err) } status, body = e.do(t, http.MethodDelete, api.PathDeployment("demo", spare.ID), token, nil) if status != http.StatusNoContent || len(body) != 0 { t.Fatalf("delete: status = %d; body: %s", status, body) } if _, err := os.Stat(dir); !os.IsNotExist(err) { t.Errorf("stat %s after delete: err = %v, want it gone", dir, err) } // Gone means gone, and deleting it again says so rather than reporting a // success that did nothing. status, body = e.do(t, http.MethodGet, api.PathDeployment("demo", spare.ID), token, nil) if status != http.StatusNotFound { t.Errorf("get deleted: status = %d; body: %s", status, body) } status, body = e.do(t, http.MethodDelete, api.PathDeployment("demo", spare.ID), token, nil) if status != http.StatusNotFound || errCode(t, body) != api.CodeNotFound { t.Errorf("second delete: status = %d; body: %s", status, body) } // The site is still being served by the deployment that was left alone. if sp, ok := e.sites.Lookup("demo"); !ok || sp.Active() == nil || sp.Active().ID != keep.ID { t.Errorf("serving %+v, want %s untouched", sp, keep.ID) } status, body = e.do(t, http.MethodDelete, api.PathDeployment("demo", keep.ID), token, map[string]string{"unexpected": "body"}) if status != http.StatusBadRequest { t.Errorf("delete with a body: status = %d; body: %s", status, body) } } func TestGCEndpoint(t *testing.T) { e := newEnv(t) p := e.createProject(t, "demo") pid := e.projectID(t, "demo") token := e.mintProject(t, pid, "ci") // Keep one spare deployment and no grace, so retention has something to do // within the lifetime of a test. The blob grace is what protects content a // request may be about to open; an hour of it would outlast any test, so // this pass is told to collect immediately. zero, one := 0, 1 status, body := e.do(t, http.MethodPatch, api.PathProject(p.Name), e.adminToken, api.ProjectPatch{RetentionCount: &one, RetentionGrace: &zero}) mustJSON(t, status, http.StatusOK, body, nil) e.server.Deploy.BlobGrace = -time.Minute d1 := e.readyDeployment(t, token, "demo", oneFile("v1")) d2 := e.readyDeployment(t, token, "demo", oneFile("v2")) d3 := e.readyDeployment(t, token, "demo", oneFile("v3")) e.activate(t, token, "demo", d1.ID) // Active is excluded outright, then the newest inactive one is the single // deployment retention keeps — so d2 is what a pass would delete. status, body = e.do(t, http.MethodPost, api.PathGC(), e.adminToken, api.GCRequest{DryRun: true}) var dry api.GCStats mustJSON(t, status, http.StatusOK, body, &dry) if !dry.DryRun || dry.DeploymentsDeleted != 1 { t.Errorf("dry run = %+v, want 1 deployment reported", dry) } status, _ = e.do(t, http.MethodGet, api.PathDeployment("demo", d2.ID), token, nil) if status != http.StatusOK { t.Errorf("a dry run deleted %s", d2.ID) } status, body = e.do(t, http.MethodPost, api.PathGC(), e.adminToken, api.GCRequest{}) var stats api.GCStats mustJSON(t, status, http.StatusOK, body, &stats) if stats.DryRun || stats.DeploymentsDeleted != 1 { t.Fatalf("collect = %+v, want 1 deployment deleted", stats) } if stats.BlobsDeleted != 1 || stats.BytesFreed == 0 { t.Errorf("collect = %+v, want the content only that deployment held", stats) } if e.hasContent(t, oneFile("v2")["index.html"]) { t.Error("v2's content survived the deployment that referenced it") } for _, keep := range []string{"v1", "v3"} { if !e.hasContent(t, oneFile(keep)["index.html"]) { t.Errorf("%s's content was collected while a deployment still referenced it", keep) } } status, _ = e.do(t, http.MethodGet, api.PathDeployment("demo", d2.ID), token, nil) if status != http.StatusNotFound { t.Errorf("get collected: status = %d, want 404", status) } for _, d := range []api.Deployment{d1, d3} { if status, _ := e.do(t, http.MethodGet, api.PathDeployment("demo", d.ID), token, nil); status != http.StatusOK { t.Errorf("%s: status = %d, want it kept", d.ID, status) } } // A body is optional, and a second pass finds nothing left to do. status, body = e.do(t, http.MethodPost, api.PathGC(), e.adminToken, nil) stats = api.GCStats{} mustJSON(t, status, http.StatusOK, body, &stats) if stats.DeploymentsDeleted != 0 || stats.BlobsDeleted != 0 { t.Errorf("second pass = %+v, want nothing", stats) } // Collection is server-wide, so it is admin-only however trusted the // project key is. status, body = e.do(t, http.MethodPost, api.PathGC(), token, api.GCRequest{}) if status != http.StatusForbidden || errCode(t, body) != api.CodeForbidden { t.Errorf("project key: status = %d; body: %s", status, body) } } func TestFsckEndpoint(t *testing.T) { e := newEnv(t) e.createProject(t, "demo") token := e.mintProject(t, e.projectID(t, "demo"), "ci") e.readyDeployment(t, token, "demo", map[string]string{ "index.html": "

hello

", "app.js": "console.log(1)", }) // Triggers maintain the counts, so a server that has only been used through // the API is clean by construction. Asserting that is the baseline the // injected drift below is measured against. status, body := e.do(t, http.MethodPost, api.PathFsck(), e.adminToken, api.FsckRequest{}) var rep api.FsckReport mustJSON(t, status, http.StatusOK, body, &rep) if rep.Blobs != 2 || rep.DriftCount != 0 || len(rep.Drift) != 0 || rep.Repaired != 0 { t.Fatalf("clean report = %+v", rep) } digest := cas.Sum([]byte("console.log(1)")) e.exec(t, `UPDATE blobs SET refcount = 7 WHERE digest = ?`, digest[:]) status, body = e.do(t, http.MethodPost, api.PathFsck(), e.adminToken, api.FsckRequest{}) rep = api.FsckReport{} mustJSON(t, status, http.StatusOK, body, &rep) if rep.DriftCount != 1 || len(rep.Drift) != 1 { t.Fatalf("report = %+v, want the one drifted blob", rep) } if d := rep.Drift[0]; d.Digest != digest.String() || d.Stored != 7 || d.Actual != 1 { t.Errorf("drift = %+v, want stored 7 and actual 1 for %s", d, digest) } if rep.Repaired != 0 { t.Errorf("repaired = %d without being asked to", rep.Repaired) } status, body = e.do(t, http.MethodPost, api.PathFsck(), e.adminToken, api.FsckRequest{Repair: true}) rep = api.FsckReport{} mustJSON(t, status, http.StatusOK, body, &rep) if rep.DriftCount != 1 || rep.Repaired != 1 { t.Fatalf("repair = %+v", rep) } status, body = e.do(t, http.MethodPost, api.PathFsck(), e.adminToken, nil) rep = api.FsckReport{} mustJSON(t, status, http.StatusOK, body, &rep) if rep.DriftCount != 0 { t.Errorf("after repair = %+v, want no drift", rep) } status, body = e.do(t, http.MethodPost, api.PathFsck(), token, api.FsckRequest{}) if status != http.StatusForbidden || errCode(t, body) != api.CodeForbidden { t.Errorf("project key: status = %d; body: %s", status, body) } } func equalStrings(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true }