| index 048bca8a8..ca0debaf0 100644 |
| --- a/gate_cli_test.go |
| +++ b/gate_cli_test.go |
| @@ -1,6 +1,11 @@ |
| package main |
| |
| import ( |
| + "bytes" |
| + "context" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| "os/exec" |
| "strings" |
| "testing" |
| @@ -46,3 +51,223 @@ func TestReadGatePassword(t *testing.T) { |
| t.Fatal("empty stdin line accepted") |
| } |
| } |
| + |
| +// A `gate set` in a shell with no console must refuse BEFORE it reads |
| +// stdin. Reading first meant the CLI sat there swallowing a typed password |
| +// and only then said it could not use it — the person has now typed a |
| +// secret at a prompt that was never going to work, with no idea where it |
| +// went. Stdin is empty here, which is what separates the two orders: read |
| +// first and the CLI complains about the missing password, when the real |
| +// problem is that it had nowhere to send one. |
| +func TestGateSetResolvesTheSessionBeforeReadingThePassword(t *testing.T) { |
| + bin := buildCarlos(t) |
| + cmd := exec.Command(bin, "gate", "set", "-app", "site") |
| + cmd.Env = carlosTestEnv(t) |
| + cmd.Dir = t.TempDir() |
| + cmd.Stdin = strings.NewReader("") |
| + out, err := cmd.CombinedOutput() |
| + if err == nil { |
| + t.Fatalf("gate set with no console exited 0: %s", out) |
| + } |
| + if strings.Contains(string(out), "no password given") { |
| + t.Fatalf("the password was read before the session was resolved: %s", out) |
| + } |
| + if !strings.Contains(string(out), "auth login") { |
| + t.Fatalf("want the sign-in refusal, got: %s", out) |
| + } |
| +} |
| + |
| +// gateConsole is a fake console for the gate client: it answers the two |
| +// doors `carlos gate` uses (GET the instance list, PATCH one instance) with |
| +// whatever the test hands it, and records every request so a test can prove |
| +// a door was NOT knocked on. |
| +type gateConsole struct { |
| + ts *httptest.Server |
| + seen []string |
| + list string // body for GET .../instances |
| + patch func(host string) (int, string) // status and body for PATCH .../instances/<host> |
| +} |
| + |
| +func newGateConsole(t *testing.T) *gateConsole { |
| + t.Helper() |
| + fc := &gateConsole{} |
| + fc.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + fc.seen = append(fc.seen, r.Method+" "+r.URL.Path) |
| + if r.Method == http.MethodPatch { |
| + host := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:] |
| + status, body := 200, `{"host":"`+host+`","gated":true}` |
| + if fc.patch != nil { |
| + status, body = fc.patch(host) |
| + } |
| + w.WriteHeader(status) |
| + w.Write([]byte(body)) |
| + return |
| + } |
| + w.Write([]byte(fc.list)) |
| + })) |
| + t.Cleanup(fc.ts.Close) |
| + return fc |
| +} |
| + |
| +// instanceSetGate sends the plaintext for the console to hash, and decodes |
| +// the console's word about the host. |
| +func TestInstanceSetGate(t *testing.T) { |
| + var gotBody, gotMethod string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotMethod = r.Method |
| + b, _ := io.ReadAll(r.Body) |
| + gotBody = string(b) |
| + w.Write([]byte(`{"host":"h","gated":true}`)) |
| + })) |
| + defer ts.Close() |
| + |
| + rec, err := newTestSession(ts.URL).instanceSetGate(context.Background(), apiAcct{Seg: "bah"}, "app", "h", "correct horse") |
| + if err != nil { |
| + t.Fatalf("instanceSetGate: %v", err) |
| + } |
| + if gotMethod != http.MethodPatch { |
| + t.Errorf("method = %s, want PATCH", gotMethod) |
| + } |
| + if !strings.Contains(gotBody, `"gate":"correct horse"`) { |
| + t.Errorf("request body = %s, want the plaintext for the console to hash", gotBody) |
| + } |
| + if rec.Gated == nil || !*rec.Gated { |
| + t.Errorf("decoded gated = %v, want the console's true", rec.Gated) |
| + } |
| +} |
| + |
| +// The one refusal an operator cannot otherwise read: a PATCH carrying only |
| +// `gate` looks like an empty update to a door that has never heard of the |
| +// field, and the door's own "nothing to update" reads like a bug in the CLI. |
| +func TestInstanceSetGateAgainstAnOldConsole(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusBadRequest) |
| + w.Write([]byte(`{"error":"nothing to update — send addrs, probe, steering"}`)) |
| + })) |
| + defer ts.Close() |
| + |
| + _, err := newTestSession(ts.URL).instanceSetGate(context.Background(), apiAcct{Seg: "bah"}, "app", "h", "correct horse") |
| + if err == nil || !strings.Contains(err.Error(), "does not know about password gates") { |
| + t.Fatalf("err = %v, want the older-console sentence", err) |
| + } |
| +} |
| + |
| +// Every other refusal is the console's own, passed through verbatim: |
| +// guessing at what a 403 or a 409 meant would put words in its mouth. |
| +func TestInstanceSetGatePassesOtherRefusalsThrough(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusConflict) |
| + w.Write([]byte(`{"error":"the record moved under you — try again"}`)) |
| + })) |
| + defer ts.Close() |
| + |
| + _, err := newTestSession(ts.URL).instanceSetGate(context.Background(), apiAcct{Seg: "bah"}, "app", "h", "") |
| + if err == nil || err.Error() != "the record moved under you — try again" { |
| + t.Fatalf("err = %v, want the console's own message", err) |
| + } |
| +} |
| + |
| +// The fan-out: no -host means every instance of the app, and -host means |
| +// exactly that one — without asking the console for a list it does not need. |
| +func TestGateHostsFanOut(t *testing.T) { |
| + fc := newGateConsole(t) |
| + fc.list = `{"instances":[{"host":"a.example.com"},{"host":"b.example.com"}],"total":2}` |
| + sess := newTestSession(fc.ts.URL) |
| + |
| + got := gateHosts(sess, apiAcct{Seg: "bah"}, "app", "") |
| + if len(got) != 2 || got[0] != "a.example.com" || got[1] != "b.example.com" { |
| + t.Fatalf("hosts = %v, want both instances", got) |
| + } |
| + |
| + fc.seen = nil |
| + if got := gateHosts(sess, apiAcct{Seg: "bah"}, "app", "one.example.com"); len(got) != 1 || got[0] != "one.example.com" { |
| + t.Fatalf("hosts = %v, want just the named one", got) |
| + } |
| + if len(fc.seen) != 0 { |
| + t.Fatalf("-host still called the console: %v", fc.seen) |
| + } |
| +} |
| + |
| +// gateApply keeps going after a refusal and counts it. With no -host the |
| +// fan-out is every instance of the app, so stopping at the first failure |
| +// would leave the operator not knowing which of the rest took the password |
| +// — and the exit code must still say something went wrong. |
| +func TestGateApplyReportsEveryHostAndCountsFailures(t *testing.T) { |
| + fc := newGateConsole(t) |
| + fc.patch = func(host string) (int, string) { |
| + if host == "b.example.com" { |
| + return http.StatusConflict, `{"error":"the record moved under you"}` |
| + } |
| + return 200, `{"host":"` + host + `","gated":true}` |
| + } |
| + var out bytes.Buffer |
| + failed := gateApply(newTestSession(fc.ts.URL), apiAcct{Seg: "bah"}, "app", |
| + []string{"a.example.com", "b.example.com", "c.example.com"}, "correct horse", &out) |
| + |
| + if failed != 1 { |
| + t.Errorf("failed = %d, want 1 — the caller turns this into the exit code", failed) |
| + } |
| + s := out.String() |
| + for _, want := range []string{ |
| + "password set on a.example.com", |
| + "gate not set on b.example.com: the record moved under you", |
| + "password set on c.example.com", |
| + } { |
| + if !strings.Contains(s, want) { |
| + t.Errorf("output missing %q:\n%s", want, s) |
| + } |
| + } |
| + if n := len(fc.seen); n != 3 { |
| + t.Errorf("%d PATCHes, want 3 — the loop stopped at the refusal: %v", n, fc.seen) |
| + } |
| +} |
| + |
| +// Clearing says so. Same loop, and the words an operator reads are the only |
| +// thing that tells them which way it went. |
| +func TestGateApplyWordsTheClear(t *testing.T) { |
| + fc := newGateConsole(t) |
| + fc.patch = func(host string) (int, string) { return 200, `{"host":"` + host + `","gated":false}` } |
| + var out bytes.Buffer |
| + if failed := gateApply(newTestSession(fc.ts.URL), apiAcct{Seg: "bah"}, "app", |
| + []string{"a.example.com"}, "", &out); failed != 0 { |
| + t.Fatalf("failed = %d, want 0", failed) |
| + } |
| + if !strings.Contains(out.String(), "password turned off on a.example.com") { |
| + t.Errorf("output = %q, want the clear's own wording", out.String()) |
| + } |
| +} |
| + |
| +// All three states of the GATE column. The third is the one that matters: |
| +// a console that predates the field sends no "gated" key, and calling that |
| +// "open" would tell someone checking whether a host is exposed exactly what |
| +// they hoped to hear on no evidence at all. |
| +func TestPrintGateList(t *testing.T) { |
| + yes, no := true, false |
| + var out bytes.Buffer |
| + printGateList(&out, []apiInstanceRecord{ |
| + {Host: "set.example.com", Gated: &yes}, |
| + {Host: "open.example.com", Gated: &no}, |
| + {Host: "old.example.com"}, |
| + }) |
| + s := out.String() |
| + for _, want := range []string{ |
| + "set.example.com", "password set", |
| + "open.example.com", "open", |
| + "old.example.com", "unknown — console predates gates", |
| + } { |
| + if !strings.Contains(s, want) { |
| + t.Errorf("table missing %q:\n%s", want, s) |
| + } |
| + } |
| + for _, line := range strings.Split(s, "\n") { |
| + if strings.HasPrefix(line, "old.example.com") && !strings.Contains(line, "unknown") { |
| + t.Errorf("a console that said nothing was reported as %q", line) |
| + } |
| + } |
| + |
| + out.Reset() |
| + printGateList(&out, nil) |
| + if strings.TrimSpace(out.String()) != "no instances" { |
| + t.Errorf("empty list = %q, want \"no instances\"", out.String()) |
| + } |
| +} |