carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "bytes"
5 "context"
6 "io"
7 "net/http"
8 "net/http/httptest"
9 "os/exec"
10 "strings"
11 "testing"
12)
13
14func 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
24func 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
36func 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
43func 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.
62func 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.
84type 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
91func 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.
114func 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.
142func 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.
157func 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.
172func 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.
195func 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.
227func 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.
244func 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