package server import ( "net/http" "net/url" "regexp" "strings" "testing" "cc.tiredsysadmin.keeporg/internal/store" ) // signInAs takes the browser through the sign-in form as any account. func (b *browser) signInAs(user, password string) string { b.t.Helper() status, page := b.post("/web/signin", url.Values{ "username": {user}, "password": {password}}) if status != http.StatusOK { b.t.Fatalf("sign in as %s: %d\n%s", user, status, page) } return b.csrf(page) } var inviteLink = regexp.MustCompile(`/invite/([0-9a-f]{64})`) // invite creates an invite from the web form and returns its token. func (b *browser) invite(csrf, group, role string) string { b.t.Helper() status, page := b.post("/web/invites", url.Values{ "csrf": {csrf}, "action": {"create"}, "group": {group}, "role": {role}, "ttl": {"24"}, "uses": {"1"}}) if status != http.StatusOK { b.t.Fatalf("create invite: %d\n%s", status, page) } m := inviteLink.FindStringSubmatch(page) if m == nil { b.t.Fatalf("the new invite link is not on the page:\n%s", page) } return m[1] } // join redeems an invite, which is how every account after the first is made. func (h *harness) join(token, username, password string) { h.t.Helper() resp, err := h.http.Client().PostForm(h.http.URL+"/invite/"+token, url.Values{ "username": {username}, "password": {password}, "password2": {password}}) if err != nil { h.t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { h.t.Fatalf("redeem invite: %d", resp.StatusCode) } } func TestWebAdminNavigationFollowsWhatYouAdminister(t *testing.T) { h := newHarness(t) admin := newBrowser(t, h) csrf := admin.signIn() _, page := admin.get("/web/vaults") for _, want := range []string{`href="/web/groups"`, `href="/web/users"`, `href="/web/invites"`} { if !strings.Contains(page, want) { t.Fatalf("an administrator was not offered %s:\n%s", want, page) } } h.join(admin.invite(csrf, "everyone", "member"), "plain", "plain-password") member := newBrowser(t, h) _, page = member.post("/web/signin", url.Values{ "username": {"plain"}, "password": {"plain-password"}}) for _, gone := range []string{`href="/web/groups"`, `href="/web/users"`, `href="/web/invites"`} { if strings.Contains(page, gone) { t.Fatalf("a plain member was offered %s:\n%s", gone, page) } } // The pages themselves still only ever show what the viewer may see. _, page = member.get("/web/users") if strings.Contains(page, "admin<") || strings.Contains(page, "Delete") { t.Fatalf("a plain member saw other accounts or their controls:\n%s", page) } } func TestWebRunsAGroupFromMembersToGrants(t *testing.T) { h := newHarness(t) b := newBrowser(t, h) csrf := b.signIn() h.join(b.invite(csrf, "everyone", "member"), "dana", "dana-password-1") status, page := b.post("/web/groups", url.Values{ "csrf": {csrf}, "name": {"Engineering"}, "parent": {"everyone"}}) if status != http.StatusOK || !strings.Contains(page, "Created the group Engineering") { t.Fatalf("create group = %d\n%s", status, page) } var group string h.srv.Store().Read(func(st *store.State) { for id, g := range st.Groups { if g.Name == "Engineering" { group = id } } }) if group == "" { t.Fatal("the group was not stored") } if !strings.Contains(page, "/web/groups/"+group) { t.Fatal("the new group is not listed") } // A member, then promoted, then demoted. _, page = b.post("/web/groups/"+group, url.Values{ "csrf": {csrf}, "action": {"member"}, "user": {"dana"}, "role": {"member"}}) if !strings.Contains(page, "dana") || !strings.Contains(page, "Make admin") { t.Fatalf("dana was not added:\n%s", page) } _, page = b.post("/web/groups/"+group, url.Values{ "csrf": {csrf}, "action": {"member"}, "user": {"dana"}, "role": {"admin"}}) if !strings.Contains(page, "Make member") { t.Fatalf("dana was not promoted:\n%s", page) } var role store.Role h.srv.Store().Read(func(st *store.State) { for _, m := range st.Memberships { if m.GroupID == group { role = m.Role } } }) if role != store.RoleAdmin { t.Fatalf("stored role = %q", role) } // A vault the group may then reach. if _, p := b.post("/web/vaults", url.Values{ "csrf": {csrf}, "name": {"Shared"}, "group": {"everyone"}, "strategy": {"server-wins"}, "password": {vaultPassword}, "password2": {vaultPassword}, }); !strings.Contains(p, "Created Shared") { t.Fatalf("vault not created:\n%s", p) } var dbID string h.srv.Store().Read(func(st *store.State) { for id, d := range st.Databases { if d.Name == "Shared" { dbID = id } } }) _, page = b.post("/web/groups/"+group, url.Values{ "csrf": {csrf}, "action": {"grant"}, "database": {dbID}, "perm": {"write"}}) if !strings.Contains(page, "Shared.kdbx") || !strings.Contains(page, "granted here") { t.Fatalf("the grant is not shown:\n%s", page) } var perm store.Perm h.srv.Store().Read(func(st *store.State) { if u := st.UserByName("dana"); u != nil { perm = st.DatabasePerm(u.ID, dbID) } }) if perm != store.PermWrite { t.Fatalf("dana's permission on the vault = %q", perm) } // And back out again, in the order the group's page allows. _, page = b.post("/web/groups/"+group, url.Values{ "csrf": {csrf}, "action": {"grant-remove"}, "database": {dbID}}) if !strings.Contains(page, "no longer has access") { t.Fatalf("the grant was not revoked:\n%s", page) } _, page = b.post("/web/groups/"+group, url.Values{ "csrf": {csrf}, "action": {"member-remove"}, "user": {"dana"}}) if !strings.Contains(page, "was removed from this group") { t.Fatalf("dana was not removed:\n%s", page) } status, page = b.post("/web/groups/"+group, url.Values{ "csrf": {csrf}, "action": {"delete"}}) if status != http.StatusOK || strings.Contains(page, "/web/groups/"+group) { t.Fatalf("delete = %d, the group is still listed:\n%s", status, page) } } // A grant on a group above is why a member has access, and the group's page // has to say so rather than showing an empty list. func TestWebGroupShowsInheritedAccess(t *testing.T) { h := newHarness(t) b := newBrowser(t, h) csrf := b.signIn() if _, p := b.post("/web/vaults", url.Values{ "csrf": {csrf}, "name": {"All Hands"}, "group": {"everyone"}, "strategy": {"server-wins"}, "password": {vaultPassword}, "password2": {vaultPassword}, }); !strings.Contains(p, "Created All Hands") { t.Fatalf("vault not created:\n%s", p) } var dbID string h.srv.Store().Read(func(st *store.State) { for id, d := range st.Databases { if d.Name == "All Hands" { dbID = id } } }) if _, p := b.post("/web/groups/everyone", url.Values{ "csrf": {csrf}, "action": {"grant"}, "database": {dbID}, "perm": {"read"}, }); !strings.Contains(p, "read access to All Hands") { t.Fatalf("the grant was not made:\n%s", p) } _, page := b.post("/web/groups", url.Values{ "csrf": {csrf}, "name": {"Below"}, "parent": {"everyone"}}) m := regexp.MustCompile(`/web/groups/([0-9a-f]{16})`).FindStringSubmatch(page) if m == nil { t.Fatalf("no group link:\n%s", page) } _, page = b.get("/web/groups/" + m[1]) if !strings.Contains(page, "granted to everyone") { t.Fatalf("the inherited grant is not explained:\n%s", page) } } func TestWebInviteCanBeRevoked(t *testing.T) { h := newHarness(t) b := newBrowser(t, h) csrf := b.signIn() token := b.invite(csrf, "everyone", "admin") if status, page := b.get("/invite/" + token); status != http.StatusOK || !strings.Contains(page, "Create your account") { t.Fatalf("the invite page = %d\n%s", status, page) } _, page := b.post("/web/invites", url.Values{ "csrf": {csrf}, "action": {"revoke"}, "token": {token}}) if !strings.Contains(page, "no longer works") { t.Fatalf("revoke:\n%s", page) } if _, page := b.get("/invite/" + token); !strings.Contains(page, "Invite not valid") { t.Fatalf("a revoked invite still opens:\n%s", page) } } // The role on the invite is the role the new account gets, which is the only // way a group administrator can hand their group to somebody else. func TestWebInviteCarriesItsRole(t *testing.T) { h := newHarness(t) b := newBrowser(t, h) csrf := b.signIn() h.join(b.invite(csrf, "everyone", "admin"), "lead", "lead-password-1") var admin bool h.srv.Store().Read(func(st *store.State) { if u := st.UserByName("lead"); u != nil { admin = st.IsGroupAdmin(u.ID, "everyone") } }) if !admin { t.Fatal("the invited account did not become an administrator of the group") } } func TestWebAccountActionsAreServerAdminOnly(t *testing.T) { h := newHarness(t) b := newBrowser(t, h) csrf := b.signIn() // An administrator of a group, which is not the same as administering // the people in it. h.join(b.invite(csrf, "everyone", "admin"), "lead", "lead-password-1") h.join(b.invite(csrf, "everyone", "member"), "victim", "victim-password-1") lead := newBrowser(t, h) leadCSRF := lead.signInAs("lead", "lead-password-1") var victim string h.srv.Store().Read(func(st *store.State) { victim = st.UserByName("victim").ID }) _, page := lead.post("/web/users", url.Values{ "csrf": {leadCSRF}, "user": {victim}, "action": {"disable"}}) if !strings.Contains(page, "do not administer") { t.Fatalf("a group administrator was allowed to disable an account:\n%s", page) } var disabled bool h.srv.Store().Read(func(st *store.State) { disabled = st.Users[victim].Disabled }) if disabled { t.Fatal("the account was disabled anyway") } } func TestWebDisableSignsTheAccountOutEverywhere(t *testing.T) { h := newHarness(t) b := newBrowser(t, h) csrf := b.signIn() h.join(b.invite(csrf, "everyone", "member"), "gone", "gone-password-1") token, _, _ := h.login("gone", "gone-password-1", "") if status, _ := h.call("GET", "/api/v1/databases", token, nil); status != http.StatusOK { t.Fatalf("the new account cannot list databases: %d", status) } var id string h.srv.Store().Read(func(st *store.State) { id = st.UserByName("gone").ID }) _, page := b.post("/web/users", url.Values{ "csrf": {csrf}, "user": {id}, "action": {"disable"}}) if !strings.Contains(page, "signed out everywhere") { t.Fatalf("disable:\n%s", page) } if status, _ := h.call("GET", "/api/v1/databases", token, nil); status != http.StatusUnauthorized { t.Fatalf("the disabled account's token still works: %d", status) } if _, body := h.call("POST", "/api/v1/auth/login", "", map[string]string{"username": "gone", "password": "gone-password-1"}); body["token"] != nil { t.Fatal("a disabled account signed in again") } // Enabling restores the account, and deleting it takes the memberships. if _, page := b.post("/web/users", url.Values{ "csrf": {csrf}, "user": {id}, "action": {"enable"}}); !strings.Contains(page, "can sign in again") { t.Fatalf("enable:\n%s", page) } if _, page := b.post("/web/users", url.Values{ "csrf": {csrf}, "user": {id}, "action": {"delete"}}); !strings.Contains(page, "was deleted") { t.Fatalf("delete:\n%s", page) } h.srv.Store().Read(func(st *store.State) { if st.Users[id] != nil { t.Fatal("the account is still there") } for _, m := range st.Memberships { if m.UserID == id { t.Fatal("a membership outlived the account") } } }) } // Locking the last administrator out of a server is only repairable from a // shell on the host, so the three ways to do it are refused. func TestWebRefusesToLockYouOutOfYourOwnAccount(t *testing.T) { h := newHarness(t) b := newBrowser(t, h) csrf := b.signIn() for _, act := range []string{"disable", "demote", "delete"} { _, page := b.post("/web/users", url.Values{ "csrf": {csrf}, "user": {"admin"}, "action": {act}}) if !strings.Contains(page, "lock you out") { t.Fatalf("%s on your own account was not refused:\n%s", act, page) } } h.srv.Store().Read(func(st *store.State) { u := st.Users["admin"] if u == nil || u.Disabled || !u.ServerAdmin { t.Fatalf("the administrator's own account was changed: %+v", u) } }) } func TestWebAdminFormsNeedTheirToken(t *testing.T) { h := newHarness(t) b := newBrowser(t, h) b.signIn() for _, path := range []string{"/web/groups", "/web/users", "/web/invites"} { if status, _ := b.post(path, url.Values{"action": {"create"}}); status != http.StatusForbidden { t.Fatalf("POST %s without a CSRF token returned %d", path, status) } } } // A signed-out browser is offered the sign-in form, not an error. func TestWebAdminPagesNeedASession(t *testing.T) { b := newBrowser(t, newHarness(t)) for _, path := range []string{"/web/groups", "/web/users", "/web/invites"} { status, page := b.get(path) if status != http.StatusOK || !strings.Contains(page, "Sign in") { t.Fatalf("GET %s while signed out = %d\n%s", path, status, page) } } }