Files
2026-08-15 07:13:00 +00:00

406 lines
14 KiB
Go

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": "<h1>" + version + "</h1>"}
}
// 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": "<h1>hello</h1>",
"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": "<h1>hello</h1>",
"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
}