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