Files
Hammerhead/redirects_test.go
VeillaxandClaude Sonnet 5.5 39659f0144
CI / test (push) Successful in 4m36s
Release / release (push) Successful in 5m10s
Domain redirects: send an address to another, over HTTP and HTTPS
A website resource can now redirect to a full address (301 or 302), optionally
keeping the path and query, with no target or login. The Add resource wizard
has a Domain redirect choice that opens the HTTPS and HTTP sides together.
Deleting a site no longer removes redirects. Uses Reef v0.1.10 and Blacktip v0.1.9.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 04:15:40 -06:00

110 lines
5.2 KiB
Go

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)
}
}