Files
Hammerhead/e2e/revoke_test.go
T
VeillaxandClaude Sonnet 5.5 f521fa8668 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>
2026-10-26 02:50:03 -06:00

225 lines
7.8 KiB
Go

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