package server import ( "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "cc.tiredsysadmin.keeporg/internal/secret" "cc.tiredsysadmin.keeporg/internal/store" ) // blobsExist reports whether the server still keeps any version of a // database. func (h *harness) blobsExist(dbID string) bool { h.t.Helper() return len(h.srv.blobs.Versions(dbID)) > 0 } func TestWebDeletesAVaultOnlyWithTheAdminsPassword(t *testing.T) { h := newHarness(t) b := newBrowser(t, h) csrf := b.signIn() admin, _, _ := h.login("admin", adminPassword, "") id := h.makeVault(admin, "Doomed") if !h.blobsExist(id) { t.Fatal("the new vault has no stored version") } // A grant so the delete has something to clean up besides the record. if status, body := h.call("POST", "/api/v1/grants", admin, map[string]any{ "group_id": "everyone", "database_id": id, "perm": "read", }); status != http.StatusOK { t.Fatalf("grant: %d %v", status, body) } status, page := b.get("/web/vaults/" + id + "/delete") if status != http.StatusOK || !strings.Contains(page, "Delete Doomed.kdbx?") { t.Fatalf("the confirmation page = %d\n%s", status, page) } if !strings.Contains(page, "Your account password") { t.Fatalf("the page does not ask for a password:\n%s", page) } _, page = b.post("/web/vaults/"+id+"/delete", url.Values{ "csrf": {csrf}, "password": {"not-the-password"}}) if !strings.Contains(page, "password or one-time code is wrong") { t.Fatalf("a wrong password was not reported:\n%s", page) } h.srv.Store().Read(func(st *store.State) { if st.Databases[id] == nil { t.Fatal("the vault was deleted on a wrong password") } }) if !h.blobsExist(id) { t.Fatal("the stored versions went on a wrong password") } _, page = b.post("/web/vaults/"+id+"/delete", url.Values{ "csrf": {csrf}, "password": {adminPassword}}) if !strings.Contains(page, "Deleted Doomed") { t.Fatalf("delete:\n%s", page) } if strings.Contains(page, "Doomed.kdbx") { t.Fatal("the deleted vault is still listed") } h.srv.Store().Read(func(st *store.State) { if st.Databases[id] != nil { t.Fatal("the record survived") } for _, g := range st.Grants { if g.DatabaseID == id { t.Fatal("a grant outlived the vault") } } }) if h.blobsExist(id) { t.Fatal("the stored versions outlived the vault") } } // Write access is not administration: it must not reach the page, and must // not delete even with the right password. func TestWebDeleteIsRefusedWithoutAdminRights(t *testing.T) { h := newHarness(t) b := newBrowser(t, h) csrf := b.signIn() admin, _, _ := h.login("admin", adminPassword, "") id := h.makeVault(admin, "Theirs") h.join(b.invite(csrf, "everyone", "member"), "wally", "wally-password-1") if status, body := h.call("POST", "/api/v1/grants", admin, map[string]any{ "group_id": "everyone", "database_id": id, "perm": "write", }); status != http.StatusOK { t.Fatalf("grant: %d %v", status, body) } w := newBrowser(t, h) wCSRF := w.signInAs("wally", "wally-password-1") _, page := w.get("/web/vaults/" + id + "/delete") if !strings.Contains(page, "you do not administer it") { t.Fatalf("a writer reached the delete page:\n%s", page) } _, page = w.post("/web/vaults/"+id+"/delete", url.Values{ "csrf": {wCSRF}, "password": {"wally-password-1"}}) if !strings.Contains(page, "you do not administer it") { t.Fatalf("a writer's delete was not refused:\n%s", page) } h.srv.Store().Read(func(st *store.State) { if st.Databases[id] == nil { t.Fatal("a writer deleted the vault") } }) // The vault list offers them no way in either. _, page = w.get("/web/vaults") if strings.Contains(page, "/delete") { t.Fatalf("a writer was offered a delete link:\n%s", page) } } func TestAPIDeleteNeedsThePassword(t *testing.T) { h := newHarness(t) admin, _, _ := h.login("admin", adminPassword, "") id := h.makeVault(admin, "By API") status, body := h.call("DELETE", "/api/v1/databases/"+id, admin, nil) if status != http.StatusUnauthorized || body["code"] != "bad_credentials" { t.Fatalf("delete with no password: %d %v", status, body) } status, body = h.call("DELETE", "/api/v1/databases/"+id, admin, map[string]string{"password": "wrong"}) if status != http.StatusUnauthorized { t.Fatalf("delete with a wrong password: %d %v", status, body) } h.srv.Store().Read(func(st *store.State) { if st.Databases[id] == nil { t.Fatal("the vault went without a password") } }) if status, body = h.call("DELETE", "/api/v1/databases/"+id, admin, map[string]string{"password": adminPassword}); status != http.StatusOK { t.Fatalf("delete with the password: %d %v", status, body) } if h.blobsExist(id) { t.Fatal("the stored versions outlived the vault") } } func TestDeleteAsksForTheSecondFactorToo(t *testing.T) { h := newHarness(t) admin, _, _ := h.login("admin", adminPassword, "") id := h.makeVault(admin, "Guarded") sec := h.enrollTOTP(t, admin, adminPassword) if status, body := h.call("DELETE", "/api/v1/databases/"+id, admin, map[string]string{"password": adminPassword}); status != http.StatusUnauthorized || body["code"] != "totp_required" { t.Fatalf("delete without a code: %d %v", status, body) } if status, body := h.call("DELETE", "/api/v1/databases/"+id, admin, map[string]string{"password": adminPassword, "totp": codeAt(t, sec, 0)}); status != http.StatusOK { t.Fatalf("delete with the code: %d %v", status, body) } } // An account that only ever signs in through an identity provider has no // password to re-type, so it confirms with the vault's name instead. func TestSingleSignOnAccountConfirmsByName(t *testing.T) { h := newHarness(t) admin, _, _ := h.login("admin", adminPassword, "") id := h.makeVault(admin, "Federated") sso := &store.User{ID: secret.ID(), Username: "sso", OIDCIssuer: "https://id.example.com", OIDCSubject: "abc", ServerAdmin: true, CreatedAt: time.Now()} if err := h.srv.Store().Update(func(st *store.State) error { st.Users[sso.ID] = sso return nil }); err != nil { t.Fatal(err) } if _, err := h.srv.deleteDatabase(httptest.NewRequest("POST", "/", nil), sso, id, "", "", ""); err != errConfirmName { t.Fatalf("delete with nothing typed = %v", err) } if _, err := h.srv.deleteDatabase(httptest.NewRequest("POST", "/", nil), sso, id, "", "", "federated"); err != errConfirmName { t.Fatalf("delete with the wrong case = %v", err) } if _, err := h.srv.deleteDatabase(httptest.NewRequest("POST", "/", nil), sso, id, "", "", "Federated"); err != nil { t.Fatalf("delete with the name typed = %v", err) } h.srv.Store().Read(func(st *store.State) { if st.Databases[id] != nil { t.Fatal("the vault survived a confirmed delete") } }) } // Deleting a vault tells everyone who could reach it, so their client stops // expecting the file rather than finding out at the next save. func TestDeleteNotifiesTheWatchers(t *testing.T) { h := newHarness(t) admin, _, _ := h.login("admin", adminPassword, "") id := h.makeVault(admin, "Watched") sub, events := h.srv.hub.Subscribe("admin") defer h.srv.hub.Unsubscribe(sub) if _, err := h.srv.deleteDatabase(httptest.NewRequest("POST", "/", nil), &store.User{ID: "admin", Username: "admin"}, id, adminPassword, "", ""); err != nil { t.Fatal(err) } select { case ev := <-events: if ev.DatabaseID != id || ev.Type != EventChanged { t.Fatalf("event = %+v", ev) } case <-time.After(2 * time.Second): t.Fatal("no event reached a watcher") } }