package hammerhead import ( "encoding/json" "strings" "testing" ) // a redirect is a website with a destination instead of a target func TestDomainRedirects(t *testing.T) { s, err := New(t.TempDir(), Options{AdminEmail: "admin@test.local", AdminPassword: "password123", MasterKey: key}) if err != nil { t.Fatal(err) } defer s.Close() do := apiClient(t, s) var me struct{ Orgs []struct{ ID string } } _, b := do("GET", "/api/me", nil) json.Unmarshal(b, &me) org := me.Orgs[0].ID var edge, site struct{ ID string } _, b = do("POST", "/api/edges", map[string]any{"name": "e", "tunnelAddr": "203.0.113.5:51820"}) json.Unmarshal(b, &edge) _, b = do("POST", "/api/orgs/"+org+"/sites", map[string]any{"name": "home"}) json.Unmarshal(b, &site) if code, b := do("POST", "/api/orgs/"+org+"/domains", map[string]any{"name": "example.test", "edgeId": edge.ID, "ip": "203.0.113.10"}); code != 200 { t.Fatalf("domain: %d %s", code, b) } base := "/api/orgs/" + org + "/mappings" open := func(m map[string]any) (int, []byte) { m["edgeId"], m["enabled"], m["bindIp"] = edge.ID, true, "0.0.0.0" return do("POST", base, m) } // the redirect needs no target, and anything that only makes sense with one is dropped code, raw := open(map[string]any{"proto": "https", "port": 443, "hostname": "old.example.test", "redirectTo": " https://new.example.test/home ", "redirectPermanent": true, "redirectKeepPath": true, "auth": map[string]any{"password": true}, "rewriteHost": "x", "healthPath": "/h"}) if code != 200 { t.Fatalf("create redirect: %d %s", code, raw) } var m struct { ID, RedirectTo, RewriteHost, HealthPath string RedirectPermanent, RedirectKeepPath bool Targets []any HealthSeconds int Auth struct{ Password bool } } json.Unmarshal(raw, &m) if m.RedirectTo != "https://new.example.test/home" || !m.RedirectPermanent || !m.RedirectKeepPath || len(m.Targets) != 0 || m.Auth.Password || m.RewriteHost != "" || m.HealthPath != "" || m.HealthSeconds != -1 { t.Fatalf("redirect was not normalised: %s", raw) } // it shares the port with other websites by hostname, like any website if code, raw := open(map[string]any{"proto": "https", "port": 443, "hostname": "app.example.test", "targets": []any{map[string]any{"siteId": site.ID, "host": "127.0.0.1", "port": 8080}}}); code != 200 { t.Fatalf("a website beside the redirect: %d %s", code, raw) } // and its plain-HTTP twin on port 80 is its own resource if code, raw := open(map[string]any{"proto": "http", "port": 80, "hostname": "old.example.test", "redirectTo": "https://new.example.test/home"}); code != 200 { t.Fatalf("http side of the redirect: %d %s", code, raw) } for name, in := range map[string]map[string]any{ "not a web port": {"proto": "tcp", "port": 9000, "redirectTo": "https://new.example.test"}, "no scheme": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "new.example.test"}, "other scheme": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "ftp://new.example.test"}, "credentials": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "https://u:p@new.example.test"}, "a dollar sign": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "https://new.example.test/$1"}, "back to itself": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "https://a.example.test/"}, "itself with a path": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "https://a.example.test/x", "redirectKeepPath": true}, "host not in domains": {"proto": "https", "port": 443, "hostname": "a.elsewhere.test", "redirectTo": "https://new.example.test"}, } { if code, raw := open(in); code != 400 { t.Errorf("%s: expected 400, got %d %s", name, code, raw) } } // http to https on the same name is the normal use, and is allowed if code, raw := open(map[string]any{"proto": "http", "port": 8080, "hostname": "a.example.test", "redirectTo": "https://a.example.test/"}); code != 200 { t.Errorf("an http to https redirect: %d %s", code, raw) } // turning a redirect into a normal website needs a target again code, raw = do("PUT", base+"/"+m.ID, map[string]any{"proto": "https", "edgeId": edge.ID, "bindIp": "0.0.0.0", "port": 443, "hostname": "old.example.test", "enabled": true}) if code != 400 || !strings.Contains(string(raw), "target") { t.Errorf("a website with no target and no redirect: %d %s", code, raw) } // deleting a site must not sweep away redirects, which have no targets if code, raw := do("DELETE", "/api/orgs/"+org+"/sites/"+site.ID, nil); code != 200 { t.Fatalf("delete site: %d %s", code, raw) } _, raw = do("GET", "/api/orgs/"+org+"/state", nil) var st struct { Mappings []struct{ Hostname, RedirectTo string } } json.Unmarshal(raw, &st) var redirects, apps int for _, x := range st.Mappings { if x.RedirectTo != "" { redirects++ } if x.Hostname == "app.example.test" { apps++ } } if redirects != 3 || apps != 0 { t.Errorf("after deleting the site: %d redirects (want 3), %d apps (want 0): %s", redirects, apps, raw) } }