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>
225 lines
7.8 KiB
Go
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)
|
|
}
|