327 lines
11 KiB
Go
327 lines
11 KiB
Go
package clicmd
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/iceBear67/simplepages/api"
|
|
"github.com/iceBear67/simplepages/internal/cliutil"
|
|
)
|
|
|
|
// cliToken is syntactically plausible and otherwise meaningless: none of the
|
|
// fake servers below look at it, but Globals refuses to build a client without
|
|
// one.
|
|
const cliToken = "pgs_abcdefghijklmnop_secret"
|
|
|
|
// runCLI drives the real command tree the way main does and returns what the
|
|
// user would have seen. Going through Root rather than calling a command's Exec
|
|
// directly is the point: it covers the flag registration and the dispatch that
|
|
// a hand-built call would skip.
|
|
func runCLI(t *testing.T, server, stdin string, args ...string) (stdout, stderr string, err error) {
|
|
t.Helper()
|
|
clearEnv(t)
|
|
t.Setenv("PAGES_TOKEN", cliToken)
|
|
var out, errOut strings.Builder
|
|
g := &Globals{
|
|
In: strings.NewReader(stdin),
|
|
Out: &out,
|
|
Err: &errOut,
|
|
Config: filepath.Join(t.TempDir(), "absent.json"),
|
|
Server: server,
|
|
}
|
|
err = cliutil.Run(context.Background(), Root(g), args, &errOut, g.Register)
|
|
return out.String(), errOut.String(), err
|
|
}
|
|
|
|
func writeJSON(t *testing.T, w http.ResponseWriter, v any) {
|
|
t.Helper()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
|
t.Error(err)
|
|
}
|
|
}
|
|
|
|
func fakeDeployment(id string) api.Deployment {
|
|
return api.Deployment{
|
|
ID: id, Project: "demo", State: "ready",
|
|
FileCount: 2, TotalBytes: 4096,
|
|
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
|
}
|
|
}
|
|
|
|
// indexOf is strings.Index with a failure message, used to assert ordering.
|
|
func indexOf(t *testing.T, haystack, needle string) int {
|
|
t.Helper()
|
|
i := strings.Index(haystack, needle)
|
|
if i < 0 {
|
|
t.Fatalf("output does not mention %q:\n%s", needle, haystack)
|
|
}
|
|
return i
|
|
}
|
|
|
|
// TestDeploymentListFollowsTheCursor: the listing is read to decide which
|
|
// deployment to roll back to, so one that silently showed the first page would
|
|
// be worse than one that failed.
|
|
func TestDeploymentListFollowsTheCursor(t *testing.T) {
|
|
var queries []string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
queries = append(queries, r.URL.RawQuery)
|
|
if r.URL.Query().Get("cursor") == "dpl_2" {
|
|
writeJSON(t, w, api.DeploymentList{
|
|
Deployments: []api.Deployment{fakeDeployment("dpl_1")},
|
|
})
|
|
return
|
|
}
|
|
writeJSON(t, w, api.DeploymentList{
|
|
Deployments: []api.Deployment{fakeDeployment("dpl_3"), fakeDeployment("dpl_2")},
|
|
NextCursor: "dpl_2",
|
|
})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
out, _, err := runCLI(t, srv.URL, "", "deployment", "list", "--project", "demo")
|
|
if err != nil {
|
|
t.Fatalf("deployment list: %v", err)
|
|
}
|
|
// Newest first, as the server returned them: the order is what tells the
|
|
// reader which one is the previous release.
|
|
first := indexOf(t, out, "dpl_3")
|
|
second := indexOf(t, out, "dpl_2")
|
|
third := indexOf(t, out, "dpl_1")
|
|
if !(first < second && second < third) {
|
|
t.Errorf("rows out of order:\n%s", out)
|
|
}
|
|
if len(queries) != 2 {
|
|
t.Fatalf("made %d requests (%q), want 2", len(queries), queries)
|
|
}
|
|
if !strings.Contains(queries[1], "cursor=dpl_2") {
|
|
t.Errorf("second request query = %q, want the cursor from the first page", queries[1])
|
|
}
|
|
}
|
|
|
|
// TestDeploymentListStopsAtTheLimit — the server here always offers another
|
|
// page, so a --limit that was not honoured would page until the test timed out.
|
|
func TestDeploymentListStopsAtTheLimit(t *testing.T) {
|
|
var requests int
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requests++
|
|
if requests > 4 {
|
|
t.Errorf("still paging after %d requests; --limit was ignored", requests)
|
|
writeJSON(t, w, api.DeploymentList{})
|
|
return
|
|
}
|
|
writeJSON(t, w, api.DeploymentList{
|
|
Deployments: []api.Deployment{fakeDeployment("dpl_3"), fakeDeployment("dpl_2")},
|
|
NextCursor: "dpl_2",
|
|
})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
out, _, err := runCLI(t, srv.URL, "", "deployment", "list", "--project", "demo", "--limit", "2")
|
|
if err != nil {
|
|
t.Fatalf("deployment list: %v", err)
|
|
}
|
|
if requests != 1 {
|
|
t.Errorf("made %d requests, want 1: two rows already satisfy --limit 2", requests)
|
|
}
|
|
if !strings.Contains(out, "dpl_3") || !strings.Contains(out, "dpl_2") {
|
|
t.Errorf("output is missing a row:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// TestDeploymentListEmptyIsAnEmptyArray: -o json is what a CI step parses, and
|
|
// jq treats null and [] very differently.
|
|
func TestDeploymentListEmptyIsAnEmptyArray(t *testing.T) {
|
|
var query string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
query = r.URL.RawQuery
|
|
writeJSON(t, w, api.DeploymentList{})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
out, _, err := runCLI(t, srv.URL, "",
|
|
"deployment", "list", "--project", "demo", "--state", "failed", "-o", "json")
|
|
if err != nil {
|
|
t.Fatalf("deployment list: %v", err)
|
|
}
|
|
if !strings.Contains(query, "state=failed") {
|
|
t.Errorf("query = %q, want the state filter", query)
|
|
}
|
|
if !strings.Contains(out, `"deployments": []`) {
|
|
t.Errorf("output = %s, want an empty array", out)
|
|
}
|
|
if strings.Contains(out, "null") {
|
|
t.Errorf("output = %s, want no null", out)
|
|
}
|
|
}
|
|
|
|
// TestDeploymentShowAsksForFilesOnlyWhenTold — the manifest is one line per
|
|
// file, so a large site's would drown the rest of the output.
|
|
func TestDeploymentShowAsksForFilesOnlyWhenTold(t *testing.T) {
|
|
d := fakeDeployment("dpl_1")
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
out := d
|
|
if r.URL.Query().Get("files") == "true" {
|
|
out.Files = []api.FileEntry{
|
|
{Path: "assets/app.js", Digest: strings.Repeat("ab", 32), Size: 15},
|
|
{Path: "index.html", Digest: strings.Repeat("cd", 32), Size: 42},
|
|
}
|
|
}
|
|
writeJSON(t, w, out)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
plain, _, err := runCLI(t, srv.URL, "", "deployment", "show", "dpl_1", "--project", "demo")
|
|
if err != nil {
|
|
t.Fatalf("deployment show: %v", err)
|
|
}
|
|
if strings.Contains(plain, "assets/app.js") {
|
|
t.Errorf("the manifest was printed without --files:\n%s", plain)
|
|
}
|
|
|
|
full, _, err := runCLI(t, srv.URL, "", "deployment", "show", "dpl_1", "--project", "demo", "--files")
|
|
if err != nil {
|
|
t.Fatalf("deployment show --files: %v", err)
|
|
}
|
|
if !strings.Contains(full, "assets/app.js") || !strings.Contains(full, "index.html") {
|
|
t.Errorf("--files did not list the manifest:\n%s", full)
|
|
}
|
|
}
|
|
|
|
// TestDeploymentDeleteAsksFirst. Deleting the wrong deployment is not
|
|
// recoverable from the CLI, so the prompt is the safety net and --yes is the
|
|
// documented way past it.
|
|
func TestDeploymentDeleteAsksFirst(t *testing.T) {
|
|
newServer := func(seen *[]string) *httptest.Server {
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
*seen = append(*seen, r.Method+" "+r.URL.Path)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
}
|
|
|
|
t.Run("declining deletes nothing", func(t *testing.T) {
|
|
var seen []string
|
|
srv := newServer(&seen)
|
|
defer srv.Close()
|
|
|
|
_, errOut, err := runCLI(t, srv.URL, "n\n", "deployment", "delete", "dpl_1", "--project", "demo")
|
|
if !errors.Is(err, cliutil.ErrAborted) {
|
|
t.Fatalf("err = %v, want it to report the abort", err)
|
|
}
|
|
if len(seen) != 0 {
|
|
t.Errorf("requests = %q, want none", seen)
|
|
}
|
|
if !strings.Contains(errOut, "dpl_1") {
|
|
t.Errorf("prompt = %q, want it to name the deployment", errOut)
|
|
}
|
|
})
|
|
|
|
t.Run("confirming deletes", func(t *testing.T) {
|
|
var seen []string
|
|
srv := newServer(&seen)
|
|
defer srv.Close()
|
|
|
|
out, _, err := runCLI(t, srv.URL, "y\n", "deployment", "delete", "dpl_1", "--project", "demo")
|
|
if err != nil {
|
|
t.Fatalf("deployment delete: %v", err)
|
|
}
|
|
want := "DELETE " + api.PathDeployment("demo", "dpl_1")
|
|
if len(seen) != 1 || seen[0] != want {
|
|
t.Errorf("requests = %q, want [%q]", seen, want)
|
|
}
|
|
if !strings.Contains(out, "dpl_1") {
|
|
t.Errorf("output = %q, want it to confirm what was deleted", out)
|
|
}
|
|
})
|
|
|
|
t.Run("--yes does not prompt", func(t *testing.T) {
|
|
var seen []string
|
|
srv := newServer(&seen)
|
|
defer srv.Close()
|
|
|
|
// Empty stdin: without --yes this would abort rather than delete.
|
|
_, errOut, err := runCLI(t, srv.URL, "", "deployment", "delete", "dpl_1", "--project", "demo", "--yes")
|
|
if err != nil {
|
|
t.Fatalf("deployment delete --yes: %v", err)
|
|
}
|
|
if len(seen) != 1 {
|
|
t.Errorf("requests = %q, want one delete", seen)
|
|
}
|
|
if strings.Contains(errOut, "[y/N]") {
|
|
t.Errorf("stderr = %q, want no prompt", errOut)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestSystemGCPostsTheDryRunFlag: a dry run that silently ran for real is the
|
|
// worst bug this command could have, so the flag's trip to the wire is checked
|
|
// rather than assumed.
|
|
func TestSystemGCPostsTheDryRunFlag(t *testing.T) {
|
|
var gotPath, gotBody string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body := make([]byte, 256)
|
|
n, _ := r.Body.Read(body)
|
|
gotPath, gotBody = r.Method+" "+r.URL.Path, string(body[:n])
|
|
writeJSON(t, w, api.GCStats{DryRun: true, DeploymentsDeleted: 3, BlobsDeleted: 4, BytesFreed: 5120})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
out, _, err := runCLI(t, srv.URL, "", "system", "gc", "--dry-run")
|
|
if err != nil {
|
|
t.Fatalf("system gc: %v", err)
|
|
}
|
|
if want := "POST " + api.PathGC(); gotPath != want {
|
|
t.Errorf("request = %q, want %q", gotPath, want)
|
|
}
|
|
if !strings.Contains(gotBody, `"dry_run":true`) {
|
|
t.Errorf("body = %q, want dry_run set", gotBody)
|
|
}
|
|
for _, want := range []string{"dry_run", "yes", "deployments_deleted", "3", "blobs_deleted", "4"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("output is missing %q:\n%s", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSystemFsckReportsDrift — the report exists for the case where the counts
|
|
// are wrong, so the drifting digests have to reach the operator's screen.
|
|
func TestSystemFsckReportsDrift(t *testing.T) {
|
|
digest := strings.Repeat("ab", 32)
|
|
var gotBody string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body := make([]byte, 256)
|
|
n, _ := r.Body.Read(body)
|
|
gotBody = string(body[:n])
|
|
writeJSON(t, w, api.FsckReport{
|
|
Blobs: 9,
|
|
DriftCount: 1,
|
|
Repaired: 1,
|
|
Drift: []api.BlobDrift{{Digest: digest, Stored: 7, Actual: 1}},
|
|
})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
out, _, err := runCLI(t, srv.URL, "", "system", "fsck", "--repair")
|
|
if err != nil {
|
|
t.Fatalf("system fsck: %v", err)
|
|
}
|
|
if !strings.Contains(gotBody, `"repair":true`) {
|
|
t.Errorf("body = %q, want repair set", gotBody)
|
|
}
|
|
// Truncate spends its last column on the ellipsis, so twelve columns of
|
|
// digest are eleven characters and a marker that there is more.
|
|
if !strings.Contains(out, digest[:11]+"…") {
|
|
t.Errorf("output does not name the drifting blob:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "stored 7, actual 1") {
|
|
t.Errorf("output does not give the counts:\n%s", out)
|
|
}
|
|
}
|