406 lines
14 KiB
Go
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
|
|
}
|