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) } }