Add the Hammerhead control plane
Dashboard and API, accounts (organizations, roles, invitations, 2FA, OIDC single sign-on, API keys, revocable sessions), an encrypted SQLite database, resource logins, alerts and /metrics, mail egress management with DKIM keys, and the .env loader. Split out of the original single repository; depends on Reef for the shared types. The end-to-end tests that run Hammerhead, Blacktip and Bamboo together live in e2e/. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3daa368233
commit
f521fa8668
35 files changed
+10392
-1
No files matched your search
@@ -0,0 +1,224 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/blacktip"
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
)
|
||||
|
||||
// signedIn logs a visitor in through hammerhead's page for mapping id and
|
||||
// returns an edge client whose cookie jar now holds the session.
|
||||
func signedIn(t *testing.T, w *world, id, host string, port int, how, secret string) func(method, host, path string) webResp {
|
||||
t.Helper()
|
||||
_, get := edgeClient("http", port)
|
||||
back := "http://" + host + ":" + strconv.Itoa(port) + "/"
|
||||
vis := newClient(t, w.ts.URL)
|
||||
var grant struct{ Redirect string }
|
||||
body := map[string]string{"return": back}
|
||||
path := "/auth/r/" + id + "/" + how
|
||||
if how == "password" {
|
||||
body["password"] = secret
|
||||
}
|
||||
vis.must("POST", path, body, &grant)
|
||||
u, _ := url.Parse(grant.Redirect)
|
||||
if cb := get("GET", host, u.RequestURI()); cb.status != 302 {
|
||||
t.Fatalf("callback -> %+v", cb)
|
||||
}
|
||||
return get
|
||||
}
|
||||
|
||||
// waitStatus polls until the edge answers with want (config reaches it within seconds).
|
||||
func waitStatus(t *testing.T, get func(method, host, path string) webResp, host string, want int, what string) {
|
||||
t.Helper()
|
||||
var last webResp
|
||||
for i := 0; i < 50; i++ {
|
||||
if last = get("GET", host, "/"); last.status == want {
|
||||
return
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("%s: wanted %d, last answer %+v", what, want, last)
|
||||
}
|
||||
|
||||
func TestVisitorSessionsCanBeRevoked(t *testing.T) {
|
||||
w := setup(t, blacktip.TraefikConfig{})
|
||||
w.admin.must("POST", "/api/orgs/"+w.org+"/domains", map[string]any{"name": "example.test", "edgeId": w.edge}, nil)
|
||||
be := webBackend(t, "app", 200)
|
||||
base := "/api/orgs/" + w.org + "/mappings"
|
||||
|
||||
port := freePort(t)
|
||||
var m model.Mapping
|
||||
w.admin.must("POST", base, map[string]any{"proto": "http", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": port, "hostname": "rv.example.test",
|
||||
"targets": tgt(w.local, be), "enabled": true, "auth": model.Auth{Password: true}, "newPassword": "first-password", "authEpoch": 99}, &m)
|
||||
if m.AuthEpoch != 0 {
|
||||
t.Fatalf("a client set the epoch to %d", m.AuthEpoch)
|
||||
}
|
||||
put := func(pw string) {
|
||||
body := map[string]any{"proto": "http", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": port, "hostname": "rv.example.test",
|
||||
"targets": tgt(w.local, be), "enabled": true, "auth": model.Auth{Password: true}, "authEpoch": 1234}
|
||||
if pw != "" {
|
||||
body["newPassword"] = pw
|
||||
}
|
||||
w.admin.must("PUT", base+"/"+m.ID, body, &m)
|
||||
}
|
||||
|
||||
get := signedIn(t, w, m.ID, "rv.example.test", port, "password", "first-password")
|
||||
waitStatus(t, get, "rv.example.test", 200, "a signed-in visitor")
|
||||
|
||||
// signing everyone out on demand
|
||||
w.admin.must("POST", base+"/"+m.ID+"/revoke", nil, nil)
|
||||
waitStatus(t, get, "rv.example.test", 302, "after 'sign out visitors'")
|
||||
get = signedIn(t, w, m.ID, "rv.example.test", port, "password", "first-password")
|
||||
waitStatus(t, get, "rv.example.test", 200, "signing in again after a revoke")
|
||||
|
||||
// editing without touching who may enter keeps visitors signed in
|
||||
put("")
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
if got := get("GET", "rv.example.test", "/"); got.status != 200 {
|
||||
t.Errorf("an unrelated edit signed visitors out: %+v", got)
|
||||
}
|
||||
var st struct{ Mappings []model.Mapping }
|
||||
w.admin.must("GET", "/api/orgs/"+w.org+"/state", nil, &st)
|
||||
if e := st.Mappings[0].AuthEpoch; e != 1 {
|
||||
t.Errorf("epoch = %d after one revoke and one harmless edit, want 1 (a client-sent value must be ignored)", e)
|
||||
}
|
||||
|
||||
// changing the password signs out everyone who got in with the old one
|
||||
put("second-password")
|
||||
waitStatus(t, get, "rv.example.test", 302, "after a password change")
|
||||
get = signedIn(t, w, m.ID, "rv.example.test", port, "password", "second-password")
|
||||
waitStatus(t, get, "rv.example.test", 200, "the new password")
|
||||
|
||||
// removing a member ends their open sessions on SSO resources
|
||||
var roles []struct {
|
||||
ID, Name string
|
||||
Admin bool
|
||||
}
|
||||
w.admin.must("GET", "/api/orgs/"+w.org+"/roles", nil, &roles)
|
||||
member := ""
|
||||
for _, r := range roles {
|
||||
if r.Name == "Member" {
|
||||
member = r.ID
|
||||
}
|
||||
}
|
||||
var inv struct{ Link string }
|
||||
w.admin.must("POST", "/api/orgs/"+w.org+"/invites", map[string]string{"email": "leaver@test.local", "roleId": member}, &inv)
|
||||
leaver := newClient(t, w.ts.URL)
|
||||
leaver.must("POST", "/api/invites/accept", map[string]string{"token": inv.Link[strings.Index(inv.Link, "#invite=")+8:], "name": "L", "password": "password123"}, nil)
|
||||
var me struct{ ID string }
|
||||
leaver.must("GET", "/api/me", nil, &me)
|
||||
|
||||
port2 := freePort(t)
|
||||
var sso model.Mapping
|
||||
w.admin.must("POST", base, map[string]any{"proto": "http", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": port2, "hostname": "sso.example.test",
|
||||
"targets": tgt(w.local, be), "enabled": true, "auth": model.Auth{SSO: true}}, &sso)
|
||||
_, get2 := edgeClient("http", port2)
|
||||
var grant struct{ Redirect string }
|
||||
for i := 0; i < 50; i++ { // wait for the resource to exist on the edge
|
||||
if err := leaver.do2("POST", "/auth/r/"+sso.ID+"/sso", map[string]string{"return": "http://sso.example.test:" + strconv.Itoa(port2) + "/"}, &grant); err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
u, _ := url.Parse(grant.Redirect)
|
||||
for i := 0; i < 50; i++ {
|
||||
if cb := get2("GET", "sso.example.test", u.RequestURI()); cb.status == 302 {
|
||||
break
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
waitStatus(t, get2, "sso.example.test", 200, "an SSO member")
|
||||
w.admin.must("DELETE", "/api/orgs/"+w.org+"/members/"+me.ID, nil, nil)
|
||||
waitStatus(t, get2, "sso.example.test", 302, "after the member was removed")
|
||||
}
|
||||
|
||||
// do2 is like do but returns an error instead of failing the test on a non-200.
|
||||
func (a *client) do2(method, path string, in, out any) error {
|
||||
code, raw := a.do(method, path, in, out)
|
||||
if code != 200 {
|
||||
return &httpErr{code, raw}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type httpErr struct {
|
||||
code int
|
||||
body string
|
||||
}
|
||||
|
||||
func (e *httpErr) Error() string { return strconv.Itoa(e.code) + " " + e.body }
|
||||
|
||||
func TestDashboardSessionsAreListedAndEnded(t *testing.T) {
|
||||
w := setup(t, blacktip.TraefikConfig{})
|
||||
login := func() *client {
|
||||
c := newClient(t, w.ts.URL)
|
||||
c.must("POST", "/api/login", map[string]string{"email": "admin@test.local", "password": "password123"}, nil)
|
||||
return c
|
||||
}
|
||||
a, b, c := login(), login(), login()
|
||||
type row struct {
|
||||
ID string
|
||||
Current bool
|
||||
IP string
|
||||
}
|
||||
var rows []row
|
||||
a.must("GET", "/api/me/sessions", nil, &rows)
|
||||
// the setup client (w.admin) plus the three above
|
||||
if len(rows) < 4 {
|
||||
t.Fatalf("expected at least 4 sessions, got %d", len(rows))
|
||||
}
|
||||
current := 0
|
||||
for _, r := range rows {
|
||||
if r.Current {
|
||||
current++
|
||||
}
|
||||
if r.IP == "" {
|
||||
t.Error("a session has no IP recorded")
|
||||
}
|
||||
}
|
||||
if current != 1 {
|
||||
t.Errorf("exactly one session should be marked current, got %d", current)
|
||||
}
|
||||
|
||||
// end a specific one
|
||||
var bRows []row
|
||||
b.must("GET", "/api/me/sessions", nil, &bRows)
|
||||
bID := ""
|
||||
for _, r := range bRows {
|
||||
if r.Current {
|
||||
bID = r.ID
|
||||
}
|
||||
}
|
||||
a.must("DELETE", "/api/me/sessions/"+bID, nil, nil)
|
||||
b.expect(401, "GET", "/api/me", nil)
|
||||
c.must("GET", "/api/me", nil, nil)
|
||||
|
||||
// "sign out everywhere else" keeps only this browser
|
||||
a.must("POST", "/api/me/sessions/revoke-others", nil, nil)
|
||||
c.expect(401, "GET", "/api/me", nil)
|
||||
w.admin.expect(401, "GET", "/api/me", nil)
|
||||
a.must("GET", "/api/me", nil, nil)
|
||||
|
||||
// someone else's session id can't be ended through your account
|
||||
var inv struct{ Link string }
|
||||
var roles []struct{ ID, Name string }
|
||||
a.must("GET", "/api/orgs/"+w.org+"/roles", nil, &roles)
|
||||
member := ""
|
||||
for _, r := range roles {
|
||||
if r.Name == "Member" {
|
||||
member = r.ID
|
||||
}
|
||||
}
|
||||
a.must("POST", "/api/orgs/"+w.org+"/invites", map[string]string{"email": "other@test.local", "roleId": member}, &inv)
|
||||
other := newClient(t, w.ts.URL)
|
||||
other.must("POST", "/api/invites/accept", map[string]string{"token": inv.Link[strings.Index(inv.Link, "#invite=")+8:], "name": "O", "password": "password123"}, nil)
|
||||
var aRows []row
|
||||
a.must("GET", "/api/me/sessions", nil, &aRows)
|
||||
other.must("DELETE", "/api/me/sessions/"+aRows[0].ID, nil, nil)
|
||||
a.must("GET", "/api/me", nil, nil)
|
||||
}
|
||||
Reference in new issue
Block a user