1// SPDX-License-Identifier: MIT
2
3package host
4
5import (
6 "context"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "sync/atomic"
11 "testing"
12
13 "connectrpc.com/connect"
14
15 ermite "bnema.dev/ermite-api/gen/ermite"
16)
17
18func TestCheckURL(t *testing.T) {
19 for _, tc := range []struct {
20 url string
21 insecure bool
22 want string // "" = accepted
23 }{
24 {"https://ermite.example.org", false, ""},
25 {"https://ermite.example.org:8443/base", false, ""},
26 {"http://localhost:8080", false, ""},
27 {"http://LOCALHOST:8080", false, ""},
28 {"http://127.0.0.1:8080", false, ""},
29 {"http://[::1]:8080", false, ""},
30 {"http://ermite.example.org", false, "must be https"},
31 {"http://10.0.0.5:8080", false, "must be https"},
32 {"http://localhost.evil.example", false, "must be https"},
33 {"http://127.0.0.1.evil.example", false, "must be https"},
34 {"http://ermite.example.org", true, ""},
35 {"ftp://ermite.example.org", false, "scheme must be https"},
36 {"ermite.example.org", false, "not a valid URL"},
37 {"https://user:pw@ermite.example.org", false, "credentials"},
38 {"", false, "not a valid URL"},
39 } {
40 err := CheckURL(tc.url, tc.insecure)
41 if tc.want == "" {
42 if err != nil {
43 t.Errorf("%q: %v", tc.url, err)
44 }
45 continue
46 }
47 if err == nil || !strings.Contains(err.Error(), tc.want) {
48 t.Errorf("%q (insecure=%v): %v, want %q", tc.url, tc.insecure, err, tc.want)
49 }
50 }
51}
52
53// The client does not follow a redirect: the token must not reach the target,
54// whoever it is.
55func TestClientDoesNotFollowRedirects(t *testing.T) {
56 var hits atomic.Int32
57 var leaked atomic.Value
58 target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
59 hits.Add(1)
60 leaked.Store(r.Header.Get("Authorization"))
61 w.WriteHeader(200)
62 }))
63 defer target.Close()
64 redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
65 http.Redirect(w, r, target.URL+"/stolen", http.StatusTemporaryRedirect)
66 }))
67 defer redirector.Close()
68
69 c := NewClient(redirector.URL, "ermiterun_secret", nil)
70 _, err := c.FetchJob(context.Background(), connect.NewRequest(&ermite.FetchJobRequest{}))
71 if err == nil {
72 t.Fatal("a redirect answered a call")
73 }
74 if hits.Load() != 0 {
75 t.Fatalf("the redirect was followed (%d hits), the token was sent to the target: %v", hits.Load(), leaked.Load())
76 }
77}
78
79// The token is not sent over plain http to a host that is not loopback.
80func TestClientRefusesToSendTheTokenInClear(t *testing.T) {
81 var hits atomic.Int32
82 rt := roundTripFunc(func(*http.Request) (*http.Response, error) { hits.Add(1); return nil, nil })
83 c := NewClient("http://ermite.example.org", "ermiterun_secret", rt)
84 _, err := c.FetchJob(context.Background(), connect.NewRequest(&ermite.FetchJobRequest{}))
85 if err == nil || !strings.Contains(err.Error(), "must be https") || hits.Load() != 0 {
86 t.Fatalf("hits=%d err=%v", hits.Load(), err)
87 }
88 // The explicit flag allows it.
89 c = NewInsecureClient("http://ermite.example.org", "ermiterun_secret", rt)
90 _, _ = c.FetchJob(context.Background(), connect.NewRequest(&ermite.FetchJobRequest{}))
91 if hits.Load() != 1 {
92 t.Fatal("the insecure client did not send the request")
93 }
94}
95
96// The API lives under /-/api of the base URL, which may carry a path prefix
97// and a trailing slash.
98func TestClientCallsTheAPIPath(t *testing.T) {
99 for base, want := range map[string]string{
100 "https://h": "/-/api/ermite.RunnerService/FetchJob",
101 "https://h/": "/-/api/ermite.RunnerService/FetchJob",
102 "https://h/ermite": "/ermite/-/api/ermite.RunnerService/FetchJob",
103 "https://h/ermite/": "/ermite/-/api/ermite.RunnerService/FetchJob",
104 } {
105 var got string
106 rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
107 got = r.URL.Path
108 return nil, context.Canceled
109 })
110 _, _ = NewClient(base, "ermiterun_secret", rt).FetchJob(context.Background(), connect.NewRequest(&ermite.FetchJobRequest{}))
111 if got != want {
112 t.Errorf("%s: called %q, want %q", base, got, want)
113 }
114 }
115}
116
117type roundTripFunc func(*http.Request) (*http.Response, error)
118
119func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
120
121func TestLoadConfigChecksTheURL(t *testing.T) {
122 env := map[string]string{EnvURL: "http://ermite.example.org"}
123 get := func(k string) string { return env[k] }
124 if _, err := LoadConfig(get, "h", "/s", 1, ""); err == nil || !strings.Contains(err.Error(), EnvURL) {
125 t.Fatalf("%v", err)
126 }
127 env[EnvInsecureHTTP] = "1"
128 c, err := LoadConfig(get, "h", "/s", 1, "")
129 if err != nil || !c.InsecureHTTP {
130 t.Fatalf("%+v %v", c, err)
131 }
132 env[EnvInsecureHTTP] = "maybe"
133 if _, err := LoadConfig(get, "h", "/s", 1, ""); err == nil {
134 t.Fatal("a bad value was accepted")
135 }
136}
137
138func TestResolveRegisterToken(t *testing.T) {
139 env := map[string]string{}
140 get := func(k string) string { return env[k] }
141 if _, err := ResolveRegisterToken("", false, strings.NewReader(""), get); err == nil || !strings.Contains(err.Error(), EnvRegisterToken) {
142 t.Fatalf("%v", err)
143 }
144 if tok, _ := ResolveRegisterToken("from-flag", false, nil, get); tok != "from-flag" {
145 t.Fatal(tok)
146 }
147 env[EnvRegisterToken] = " from-env \n"
148 if tok, _ := ResolveRegisterToken("from-flag", false, nil, get); tok != "from-env" {
149 t.Fatalf("the environment must win over the flag: %q", tok)
150 }
151 if tok, _ := ResolveRegisterToken("from-flag", true, strings.NewReader("from-stdin\n"), get); tok != "from-stdin" {
152 t.Fatalf("standard input must win: %q", tok)
153 }
154 if _, err := ResolveRegisterToken("x", true, strings.NewReader(" \n"), get); err == nil {
155 t.Fatal("an empty standard input was accepted")
156 }
157}