| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "io" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "os/exec" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | ) |
| 13 | |
| 14 | func runCarlosCLI(t *testing.T, args ...string) (string, error) { |
| 15 | t.Helper() |
| 16 | bin := buildCarlos(t) |
| 17 | cmd := exec.Command(bin, args...) |
| 18 | cmd.Env = carlosTestEnv(t) |
| 19 | cmd.Dir = t.TempDir() |
| 20 | out, err := cmd.CombinedOutput() |
| 21 | return string(out), err |
| 22 | } |
| 23 | |
| 24 | func TestGateNeedsASubVerb(t *testing.T) { |
| 25 | out, err := runCarlosCLI(t, "gate") |
| 26 | if err == nil { |
| 27 | t.Fatalf("bare `gate` exited 0: %s", out) |
| 28 | } |
| 29 | for _, w := range []string{"set", "clear", "list"} { |
| 30 | if !strings.Contains(out, w) { |
| 31 | t.Fatalf("usage should name %s: %s", w, out) |
| 32 | } |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | func TestGateSetRefusesAnEmptyPasswordBeforeTheNetwork(t *testing.T) { |
| 37 | out, err := runCarlosCLI(t, "gate", "set", "-app", "site", "-password", "") |
| 38 | if err == nil || !strings.Contains(out, "password") { |
| 39 | t.Fatalf("empty -password: err=%v out=%s", err, out) |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | func TestReadGatePassword(t *testing.T) { |
| 44 | if got, err := readGatePassword("flagged", strings.NewReader("")); err != nil || got != "flagged" { |
| 45 | t.Fatalf("flag: %q %v", got, err) |
| 46 | } |
| 47 | if got, err := readGatePassword("", strings.NewReader("from stdin\n")); err != nil || got != "from stdin" { |
| 48 | t.Fatalf("stdin: %q %v", got, err) |
| 49 | } |
| 50 | if _, err := readGatePassword("", strings.NewReader("\n")); err == nil { |
| 51 | t.Fatal("empty stdin line accepted") |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | // A `gate set` in a shell with no console must refuse BEFORE it reads |
| 56 | // stdin. Reading first meant the CLI sat there swallowing a typed password |
| 57 | // and only then said it could not use it — the person has now typed a |
| 58 | // secret at a prompt that was never going to work, with no idea where it |
| 59 | // went. Stdin is empty here, which is what separates the two orders: read |
| 60 | // first and the CLI complains about the missing password, when the real |
| 61 | // problem is that it had nowhere to send one. |
| 62 | func TestGateSetResolvesTheSessionBeforeReadingThePassword(t *testing.T) { |
| 63 | bin := buildCarlos(t) |
| 64 | cmd := exec.Command(bin, "gate", "set", "-app", "site") |
| 65 | cmd.Env = carlosTestEnv(t) |
| 66 | cmd.Dir = t.TempDir() |
| 67 | cmd.Stdin = strings.NewReader("") |
| 68 | out, err := cmd.CombinedOutput() |
| 69 | if err == nil { |
| 70 | t.Fatalf("gate set with no console exited 0: %s", out) |
| 71 | } |
| 72 | if strings.Contains(string(out), "no password given") { |
| 73 | t.Fatalf("the password was read before the session was resolved: %s", out) |
| 74 | } |
| 75 | if !strings.Contains(string(out), "auth login") { |
| 76 | t.Fatalf("want the sign-in refusal, got: %s", out) |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | // gateConsole is a fake console for the gate client: it answers the two |
| 81 | // doors `carlos gate` uses (GET the instance list, PATCH one instance) with |
| 82 | // whatever the test hands it, and records every request so a test can prove |
| 83 | // a door was NOT knocked on. |
| 84 | type gateConsole struct { |
| 85 | ts *httptest.Server |
| 86 | seen []string |
| 87 | list string // body for GET .../instances |
| 88 | patch func(host string) (int, string) // status and body for PATCH .../instances/<host> |
| 89 | } |
| 90 | |
| 91 | func newGateConsole(t *testing.T) *gateConsole { |
| 92 | t.Helper() |
| 93 | fc := &gateConsole{} |
| 94 | fc.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 95 | fc.seen = append(fc.seen, r.Method+" "+r.URL.Path) |
| 96 | if r.Method == http.MethodPatch { |
| 97 | host := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:] |
| 98 | status, body := 200, `{"host":"`+host+`","gated":true}` |
| 99 | if fc.patch != nil { |
| 100 | status, body = fc.patch(host) |
| 101 | } |
| 102 | w.WriteHeader(status) |
| 103 | w.Write([]byte(body)) |
| 104 | return |
| 105 | } |
| 106 | w.Write([]byte(fc.list)) |
| 107 | })) |
| 108 | t.Cleanup(fc.ts.Close) |
| 109 | return fc |
| 110 | } |
| 111 | |
| 112 | // instanceSetGate sends the plaintext for the console to hash, and decodes |
| 113 | // the console's word about the host. |
| 114 | func TestInstanceSetGate(t *testing.T) { |
| 115 | var gotBody, gotMethod string |
| 116 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 117 | gotMethod = r.Method |
| 118 | b, _ := io.ReadAll(r.Body) |
| 119 | gotBody = string(b) |
| 120 | w.Write([]byte(`{"host":"h","gated":true}`)) |
| 121 | })) |
| 122 | defer ts.Close() |
| 123 | |
| 124 | rec, err := newTestSession(ts.URL).instanceSetGate(context.Background(), apiAcct{Seg: "bah"}, "app", "h", "correct horse") |
| 125 | if err != nil { |
| 126 | t.Fatalf("instanceSetGate: %v", err) |
| 127 | } |
| 128 | if gotMethod != http.MethodPatch { |
| 129 | t.Errorf("method = %s, want PATCH", gotMethod) |
| 130 | } |
| 131 | if !strings.Contains(gotBody, `"gate":"correct horse"`) { |
| 132 | t.Errorf("request body = %s, want the plaintext for the console to hash", gotBody) |
| 133 | } |
| 134 | if rec.Gated == nil || !*rec.Gated { |
| 135 | t.Errorf("decoded gated = %v, want the console's true", rec.Gated) |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | // The one refusal an operator cannot otherwise read: a PATCH carrying only |
| 140 | // `gate` looks like an empty update to a door that has never heard of the |
| 141 | // field, and the door's own "nothing to update" reads like a bug in the CLI. |
| 142 | func TestInstanceSetGateAgainstAnOldConsole(t *testing.T) { |
| 143 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 144 | w.WriteHeader(http.StatusBadRequest) |
| 145 | w.Write([]byte(`{"error":"nothing to update — send addrs, probe, steering"}`)) |
| 146 | })) |
| 147 | defer ts.Close() |
| 148 | |
| 149 | _, err := newTestSession(ts.URL).instanceSetGate(context.Background(), apiAcct{Seg: "bah"}, "app", "h", "correct horse") |
| 150 | if err == nil || !strings.Contains(err.Error(), "does not know about password gates") { |
| 151 | t.Fatalf("err = %v, want the older-console sentence", err) |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | // Every other refusal is the console's own, passed through verbatim: |
| 156 | // guessing at what a 403 or a 409 meant would put words in its mouth. |
| 157 | func TestInstanceSetGatePassesOtherRefusalsThrough(t *testing.T) { |
| 158 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 159 | w.WriteHeader(http.StatusConflict) |
| 160 | w.Write([]byte(`{"error":"the record moved under you — try again"}`)) |
| 161 | })) |
| 162 | defer ts.Close() |
| 163 | |
| 164 | _, err := newTestSession(ts.URL).instanceSetGate(context.Background(), apiAcct{Seg: "bah"}, "app", "h", "") |
| 165 | if err == nil || err.Error() != "the record moved under you — try again" { |
| 166 | t.Fatalf("err = %v, want the console's own message", err) |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | // The fan-out: no -host means every instance of the app, and -host means |
| 171 | // exactly that one — without asking the console for a list it does not need. |
| 172 | func TestGateHostsFanOut(t *testing.T) { |
| 173 | fc := newGateConsole(t) |
| 174 | fc.list = `{"instances":[{"host":"a.example.com"},{"host":"b.example.com"}],"total":2}` |
| 175 | sess := newTestSession(fc.ts.URL) |
| 176 | |
| 177 | got := gateHosts(sess, apiAcct{Seg: "bah"}, "app", "") |
| 178 | if len(got) != 2 || got[0] != "a.example.com" || got[1] != "b.example.com" { |
| 179 | t.Fatalf("hosts = %v, want both instances", got) |
| 180 | } |
| 181 | |
| 182 | fc.seen = nil |
| 183 | if got := gateHosts(sess, apiAcct{Seg: "bah"}, "app", "one.example.com"); len(got) != 1 || got[0] != "one.example.com" { |
| 184 | t.Fatalf("hosts = %v, want just the named one", got) |
| 185 | } |
| 186 | if len(fc.seen) != 0 { |
| 187 | t.Fatalf("-host still called the console: %v", fc.seen) |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | // gateApply keeps going after a refusal and counts it. With no -host the |
| 192 | // fan-out is every instance of the app, so stopping at the first failure |
| 193 | // would leave the operator not knowing which of the rest took the password |
| 194 | // — and the exit code must still say something went wrong. |
| 195 | func TestGateApplyReportsEveryHostAndCountsFailures(t *testing.T) { |
| 196 | fc := newGateConsole(t) |
| 197 | fc.patch = func(host string) (int, string) { |
| 198 | if host == "b.example.com" { |
| 199 | return http.StatusConflict, `{"error":"the record moved under you"}` |
| 200 | } |
| 201 | return 200, `{"host":"` + host + `","gated":true}` |
| 202 | } |
| 203 | var out bytes.Buffer |
| 204 | failed := gateApply(newTestSession(fc.ts.URL), apiAcct{Seg: "bah"}, "app", |
| 205 | []string{"a.example.com", "b.example.com", "c.example.com"}, "correct horse", &out) |
| 206 | |
| 207 | if failed != 1 { |
| 208 | t.Errorf("failed = %d, want 1 — the caller turns this into the exit code", failed) |
| 209 | } |
| 210 | s := out.String() |
| 211 | for _, want := range []string{ |
| 212 | "password set on a.example.com", |
| 213 | "gate not set on b.example.com: the record moved under you", |
| 214 | "password set on c.example.com", |
| 215 | } { |
| 216 | if !strings.Contains(s, want) { |
| 217 | t.Errorf("output missing %q:\n%s", want, s) |
| 218 | } |
| 219 | } |
| 220 | if n := len(fc.seen); n != 3 { |
| 221 | t.Errorf("%d PATCHes, want 3 — the loop stopped at the refusal: %v", n, fc.seen) |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | // Clearing says so. Same loop, and the words an operator reads are the only |
| 226 | // thing that tells them which way it went. |
| 227 | func TestGateApplyWordsTheClear(t *testing.T) { |
| 228 | fc := newGateConsole(t) |
| 229 | fc.patch = func(host string) (int, string) { return 200, `{"host":"` + host + `","gated":false}` } |
| 230 | var out bytes.Buffer |
| 231 | if failed := gateApply(newTestSession(fc.ts.URL), apiAcct{Seg: "bah"}, "app", |
| 232 | []string{"a.example.com"}, "", &out); failed != 0 { |
| 233 | t.Fatalf("failed = %d, want 0", failed) |
| 234 | } |
| 235 | if !strings.Contains(out.String(), "password turned off on a.example.com") { |
| 236 | t.Errorf("output = %q, want the clear's own wording", out.String()) |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | // All three states of the GATE column. The third is the one that matters: |
| 241 | // a console that predates the field sends no "gated" key, and calling that |
| 242 | // "open" would tell someone checking whether a host is exposed exactly what |
| 243 | // they hoped to hear on no evidence at all. |
| 244 | func TestPrintGateList(t *testing.T) { |
| 245 | yes, no := true, false |
| 246 | var out bytes.Buffer |
| 247 | printGateList(&out, []apiInstanceRecord{ |
| 248 | {Host: "set.example.com", Gated: &yes}, |
| 249 | {Host: "open.example.com", Gated: &no}, |
| 250 | {Host: "old.example.com"}, |
| 251 | }) |
| 252 | s := out.String() |
| 253 | for _, want := range []string{ |
| 254 | "set.example.com", "password set", |
| 255 | "open.example.com", "open", |
| 256 | "old.example.com", "unknown — console predates gates", |
| 257 | } { |
| 258 | if !strings.Contains(s, want) { |
| 259 | t.Errorf("table missing %q:\n%s", want, s) |
| 260 | } |
| 261 | } |
| 262 | for _, line := range strings.Split(s, "\n") { |
| 263 | if strings.HasPrefix(line, "old.example.com") && !strings.Contains(line, "unknown") { |
| 264 | t.Errorf("a console that said nothing was reported as %q", line) |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | out.Reset() |
| 269 | printGateList(&out, nil) |
| 270 | if strings.TrimSpace(out.String()) != "no instances" { |
| 271 | t.Errorf("empty list = %q, want \"no instances\"", out.String()) |
| 272 | } |
| 273 | } |
| 274 | |