package server import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "crypto/x509/pkix" "encoding/json" "encoding/pem" "io" "net/http" "net/http/httptest" "strings" "testing" ) // testAPI returns an API against an in-memory store, on the domain // "opentunnel.test" like the recorded contract. func testAPI(t *testing.T) (*API, *httptest.Server) { t.Helper() store, err := OpenStore("") if err != nil { t.Fatal(err) } api := &API{Store: store, Hub: NewHub(store), Domain: "opentunnel.test"} return api, httptest.NewServer(api) } type createdTunnel struct { ID string Hostname string Token string } func createTunnel(t *testing.T, srv *httptest.Server) createdTunnel { t.Helper() resp, err := http.Post(srv.URL+"/api/tunnel", "application/json", strings.NewReader("{}")) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != 201 { t.Fatalf("create: status %d", resp.StatusCode) } var body struct { Tunnel TunnelInfo `json:"tunnel"` Token string `json:"token"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { t.Fatal(err) } if len(body.Tunnel.ID) != 12 || body.Tunnel.Hostname != body.Tunnel.ID+".opentunnel.test" { t.Fatalf("create: odd tunnel %+v", body.Tunnel) } if body.Tunnel.State != "offline" { t.Fatalf("create: state %q", body.Tunnel.State) } if !strings.HasPrefix(body.Token, "rly_") { t.Fatalf("create: token %q", body.Token) } return createdTunnel{ID: body.Tunnel.ID, Hostname: body.Tunnel.Hostname, Token: body.Token} } func request(t *testing.T, method, url, token, contentType, body string) *http.Response { t.Helper() var reader io.Reader if body != "" { reader = strings.NewReader(body) } req, err := http.NewRequest(method, url, reader) if err != nil { t.Fatal(err) } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } if contentType != "" { req.Header.Set("Content-Type", contentType) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } return resp } func bodyString(t *testing.T, resp *http.Response) string { t.Helper() defer resp.Body.Close() raw, err := io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } return string(raw) } // makeCSR builds a PEM PKCS#10 CSR for cn with the given SANs, as the CLI does. func makeCSR(t *testing.T, cn string, sans ...string) string { t.Helper() key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatal(err) } der, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ Subject: pkix.Name{CommonName: cn}, DNSNames: sans, }, key) if err != nil { t.Fatal(err) } return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: der})) } func TestAPIContract(t *testing.T) { _, srv := testAPI(t) defer srv.Close() base := srv.URL // create, and the malformed variants the contract pins resp := request(t, "POST", base+"/api/tunnel", "", "application/json", "{}") if resp.StatusCode != 201 { t.Fatalf("create: %d", resp.StatusCode) } resp.Body.Close() resp = request(t, "POST", base+"/api/tunnel", "", "application/json", `{"name":"x"}`) if got := bodyString(t, resp); resp.StatusCode != 400 || got != `{"_tag":"InvalidRequestError","message":"Custom tunnel names are not supported; omit name to get a random hostname"}` { t.Errorf("create with name: %d %q", resp.StatusCode, got) } resp = request(t, "POST", base+"/api/tunnel", "", "application/json", "{") if got := bodyString(t, resp); resp.StatusCode != 400 || got != "" { t.Errorf("create with invalid json: %d %q", resp.StatusCode, got) } resp = request(t, "POST", base+"/api/tunnel", "", "text/plain", "{}") if got := bodyString(t, resp); resp.StatusCode != 415 || got != "Unsupported content-type: text/plain" { t.Errorf("create with text body: %d %q", resp.StatusCode, got) } resp = request(t, "POST", base+"/api/tunnel", "", "application/json", `{"name":null}`) if resp.StatusCode != 201 { t.Errorf("create with null name: %d", resp.StatusCode) } resp.Body.Close() tunnel := createTunnel(t, srv) // get: auth behavior and the unknown-tunnel 404 resp = request(t, "GET", base+"/api/tunnel/"+tunnel.ID, "", "", "") if got := bodyString(t, resp); resp.StatusCode != 401 || got != `{"_tag":"UnauthorizedError","message":"Invalid bearer token"}` { t.Errorf("get without auth: %d %q", resp.StatusCode, got) } resp = request(t, "GET", base+"/api/tunnel/zzzzzzzzzzzz", "", "", "") if got := bodyString(t, resp); resp.StatusCode != 404 || got != `{"_tag":"TunnelNotFoundError","tunnelID":"zzzzzzzzzzzz","message":"Tunnel not found"}` { t.Errorf("get unknown: %d %q", resp.StatusCode, got) } resp = request(t, "GET", base+"/api/tunnel/"+tunnel.ID, tunnel.Token, "", "") want := `{"id":"` + tunnel.ID + `","hostname":"` + tunnel.Hostname + `","state":"offline"}` if got := bodyString(t, resp); resp.StatusCode != 200 || got != want { t.Errorf("get: %d %q, want %q", resp.StatusCode, got, want) } // lowercase bearer, double slash, trailing slash req, _ := http.NewRequest("GET", base+"/api//tunnel/"+tunnel.ID+"/", nil) req.Header.Set("Authorization", "bearer "+tunnel.Token) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } if got := bodyString(t, resp); resp.StatusCode != 200 || got != want { t.Errorf("get double slash: %d %q", resp.StatusCode, got) } // certificate flow resp = request(t, "GET", base+"/api/tunnel/"+tunnel.ID+"/certificate", tunnel.Token, "", "") if got := bodyString(t, resp); resp.StatusCode != 404 || got != `{"_tag":"CertificateNotFoundError","tunnelID":"`+tunnel.ID+`","message":"Certificate not found"}` { t.Errorf("certificate before bind: %d %q", resp.StatusCode, got) } resp = request(t, "POST", base+"/api/tunnel/"+tunnel.ID+"/certificate", tunnel.Token, "application/json", `{"csr":"garbage"}`) if got := bodyString(t, resp); resp.StatusCode != 400 || got != `{"_tag":"InvalidRequestError","message":"Failed to parse CSR"}` { t.Errorf("bind garbage: %d %q", resp.StatusCode, got) } wrong := makeCSR(t, "other.opentunnel.test", "other.opentunnel.test") resp = request(t, "POST", base+"/api/tunnel/"+tunnel.ID+"/certificate", tunnel.Token, "application/json", `{"csr":`+jsonString(wrong)+`}`) wantWrong := `{"_tag":"InvalidHostnameError","provided":"other.opentunnel.test","expected":"` + tunnel.Hostname + `","message":"CSR hostname does not match tunnel hostname"}` if got := bodyString(t, resp); resp.StatusCode != 400 || got != wantWrong { t.Errorf("bind wrong hostname: %d %q, want %q", resp.StatusCode, got, wantWrong) } csr := makeCSR(t, tunnel.Hostname, tunnel.Hostname, "*."+tunnel.Hostname) resp = request(t, "POST", base+"/api/tunnel/"+tunnel.ID+"/certificate", tunnel.Token, "application/json", `{"csr":`+jsonString(csr)+`}`) body := bodyString(t, resp) if resp.StatusCode != 202 || !strings.Contains(body, `"type":"issuing"`) || !strings.Contains(body, `"id":"cert_`) { t.Errorf("bind: %d %q", resp.StatusCode, body) } resp = request(t, "GET", base+"/api/tunnel/"+tunnel.ID+"/certificate", tunnel.Token, "", "") var cert CertificateInfo if err := json.Unmarshal([]byte(bodyString(t, resp)), &cert); err != nil { t.Fatal(err) } if cert.State.Type != "ready" || cert.State.Certificate == "" || cert.State.Chain == "" || cert.State.Expiry == "" { t.Errorf("certificate after bind: %+v", cert.State) } // rebinding the same CSR answers with the current certificate resp = request(t, "POST", base+"/api/tunnel/"+tunnel.ID+"/certificate", tunnel.Token, "application/json", `{"csr":`+jsonString(csr)+`}`) if got := bodyString(t, resp); resp.StatusCode != 202 || !strings.Contains(got, `"type":"ready"`) { t.Errorf("rebind: %d %q", resp.StatusCode, got) } // get now carries certificateID resp = request(t, "GET", base+"/api/tunnel/"+tunnel.ID, tunnel.Token, "", "") if got := bodyString(t, resp); !strings.Contains(got, `"certificateID":"cert_`) { t.Errorf("get after bind: %q", got) } // connect: the pinned pre-upgrade answers resp = request(t, "GET", base+"/api/tunnel/"+tunnel.ID+"/connect", "", "", "") if got := bodyString(t, resp); resp.StatusCode != 426 || got != "Expected WebSocket upgrade" { t.Errorf("connect without upgrade: %d %q", resp.StatusCode, got) } req, _ = http.NewRequest("GET", base+"/api/tunnel/"+tunnel.ID+"/connect", nil) req.Header.Set("Upgrade", "websocket") resp, err = http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } if got := bodyString(t, resp); resp.StatusCode != 426 || got != "Expected opentunnel WebSocket subprotocol" { t.Errorf("connect without subprotocol: %d %q", resp.StatusCode, got) } req, _ = http.NewRequest("GET", base+"/api/tunnel/zzzzzzzzzzzz/connect", nil) req.Header.Set("Upgrade", "websocket") resp, err = http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } if got := bodyString(t, resp); resp.StatusCode != 404 || got != "Tunnel not found" { t.Errorf("connect unknown: %d %q", resp.StatusCode, got) } // unknown paths and methods are an empty 404; health answers resp = request(t, "PUT", base+"/api/tunnel", "", "", "") if got := bodyString(t, resp); resp.StatusCode != 404 || got != "" { t.Errorf("put: %d %q", resp.StatusCode, got) } resp = request(t, "GET", base+"/api/nope", "", "", "") if got := bodyString(t, resp); resp.StatusCode != 404 || got != "" { t.Errorf("unknown path: %d %q", resp.StatusCode, got) } resp = request(t, "GET", base+"/health", "", "", "") if got := bodyString(t, resp); resp.StatusCode != 200 || got != `{"ok":true}` { t.Errorf("health: %d %q", resp.StatusCode, got) } // delete resp = request(t, "DELETE", base+"/api/tunnel/"+tunnel.ID, tunnel.Token, "", "") if resp.StatusCode != 204 { t.Errorf("delete: %d", resp.StatusCode) } resp = request(t, "GET", base+"/api/tunnel/"+tunnel.ID, tunnel.Token, "", "") if resp.StatusCode != 404 { t.Errorf("get after delete: %d", resp.StatusCode) } resp.Body.Close() } // jsonString encodes s as a JSON string literal. func jsonString(s string) string { raw, _ := json.Marshal(s) return string(raw) }