package main import ( "bytes" "context" "io" "net/http" "net/http/httptest" "os/exec" "strings" "testing" ) func runCarlosCLI(t *testing.T, args ...string) (string, error) { t.Helper() bin := buildCarlos(t) cmd := exec.Command(bin, args...) cmd.Env = carlosTestEnv(t) cmd.Dir = t.TempDir() out, err := cmd.CombinedOutput() return string(out), err } func TestGateNeedsASubVerb(t *testing.T) { out, err := runCarlosCLI(t, "gate") if err == nil { t.Fatalf("bare `gate` exited 0: %s", out) } for _, w := range []string{"set", "clear", "list"} { if !strings.Contains(out, w) { t.Fatalf("usage should name %s: %s", w, out) } } } func TestGateSetRefusesAnEmptyPasswordBeforeTheNetwork(t *testing.T) { out, err := runCarlosCLI(t, "gate", "set", "-app", "site", "-password", "") if err == nil || !strings.Contains(out, "password") { t.Fatalf("empty -password: err=%v out=%s", err, out) } } func TestReadGatePassword(t *testing.T) { if got, err := readGatePassword("flagged", strings.NewReader("")); err != nil || got != "flagged" { t.Fatalf("flag: %q %v", got, err) } if got, err := readGatePassword("", strings.NewReader("from stdin\n")); err != nil || got != "from stdin" { t.Fatalf("stdin: %q %v", got, err) } if _, err := readGatePassword("", strings.NewReader("\n")); err == nil { 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/ } 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()) } }