package server import ( "bufio" "bytes" "encoding/json" "io" "log" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "time" "cc.tiredsysadmin.keeporg/internal/secret" "cc.tiredsysadmin.keeporg/internal/store" "cc.tiredsysadmin.keeporg/internal/totp" ) type harness struct { t *testing.T srv *Server http *httptest.Server } const adminPassword = "admin-password-1" func newHarness(t *testing.T) *harness { t.Helper() dir := t.TempDir() srv, err := New(Config{DataDir: dir, ExternalURL: "https://keeporg.test"}, log.New(io.Discard, "", 0)) if err != nil { t.Fatal(err) } hash, err := secret.Hash(adminPassword) if err != nil { t.Fatal(err) } if err := srv.Store().Update(func(st *store.State) error { st.Users["admin"] = &store.User{ID: "admin", Username: "admin", PassHash: hash, ServerAdmin: true} st.Groups["everyone"] = &store.Group{ID: "everyone", Name: "everyone"} st.Memberships = append(st.Memberships, store.Membership{UserID: "admin", GroupID: "everyone", Role: store.RoleAdmin}) return nil }); err != nil { t.Fatal(err) } ts := httptest.NewServer(srv.Handler()) t.Cleanup(ts.Close) _ = filepath.Join(dir, "state.json") return &harness{t: t, srv: srv, http: ts} } // call sends a JSON request and decodes the JSON response. func (h *harness) call(method, path, token string, body any) (int, map[string]any) { h.t.Helper() var rdr io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { h.t.Fatal(err) } rdr = bytes.NewReader(b) } req, err := http.NewRequest(method, h.http.URL+path, rdr) if err != nil { h.t.Fatal(err) } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := h.http.Client().Do(req) if err != nil { h.t.Fatal(err) } defer resp.Body.Close() out := map[string]any{} raw, _ := io.ReadAll(resp.Body) if len(raw) > 0 { _ = json.Unmarshal(raw, &out) } return resp.StatusCode, out } func (h *harness) login(user, password, code string) (string, int, map[string]any) { h.t.Helper() status, body := h.call("POST", "/api/v1/auth/login", "", map[string]string{"username": user, "password": password, "totp": code, "label": "test"}) tok, _ := body["token"].(string) return tok, status, body } // push uploads bytes with an explicit If-Match and returns the response. func (h *harness) push(token, dbID, ifMatch string, data []byte) (int, map[string]any, http.Header) { h.t.Helper() req, err := http.NewRequest("PUT", h.http.URL+"/api/v1/databases/"+dbID+"/blob", bytes.NewReader(data)) if err != nil { h.t.Fatal(err) } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("If-Match", ifMatch) resp, err := h.http.Client().Do(req) if err != nil { h.t.Fatal(err) } defer resp.Body.Close() out := map[string]any{} raw, _ := io.ReadAll(resp.Body) _ = json.Unmarshal(raw, &out) return resp.StatusCode, out, resp.Header } // makeDB creates a database owned by the "everyone" group. func (h *harness) makeDB(token, name string) string { h.t.Helper() status, body := h.call("POST", "/api/v1/databases", token, map[string]string{"name": name, "owner_group_id": "everyone"}) if status != http.StatusCreated { h.t.Fatalf("create database: %d %v", status, body) } return body["id"].(string) } func TestLoginRejectsWrongPassword(t *testing.T) { h := newHarness(t) if _, status, _ := h.login("admin", "nope", ""); status != http.StatusUnauthorized { t.Fatalf("status = %d", status) } tok, status, _ := h.login("admin", adminPassword, "") if status != http.StatusOK || tok == "" { t.Fatalf("status = %d token = %q", status, tok) } } func TestPushPullAndServerWinsConflict(t *testing.T) { h := newHarness(t) tok, _, _ := h.login("admin", adminPassword, "") db := h.makeDB(tok, "Team") status, body, hdr := h.push(tok, db, `"0"`, []byte("kdbx-v1")) if status != http.StatusOK || body["version"].(float64) != 1 { t.Fatalf("first push: %d %v", status, body) } if hdr.Get("ETag") != `"1"` { t.Fatalf("etag = %q", hdr.Get("ETag")) } // A second client that still believes it is on version 0 is refused, and // learns both the current version and the policy to apply. status, body, hdr = h.push(tok, db, `"0"`, []byte("kdbx-other")) if status != http.StatusConflict || body["code"] != "conflict" { t.Fatalf("stale push: %d %v", status, body) } if body["current_version"].(float64) != 1 || body["merge_strategy"] != "server-wins" { t.Fatalf("conflict body = %v", body) } if hdr.Get("X-KeepOrg-Strategy") != "server-wins" { t.Fatalf("strategy header = %q", hdr.Get("X-KeepOrg-Strategy")) } // Forcing is refused while the strategy is server-wins. if status, _, _ = h.push(tok, db, "*", []byte("forced")); status != http.StatusConflict { t.Fatalf("force under server-wins: %d", status) } // Re-pushing identical bytes is a no-op, so a save that changed nothing // does not burn a version. status, body, _ = h.push(tok, db, `"1"`, []byte("kdbx-v1")) if status != http.StatusOK || body["unchanged"] != true || body["version"].(float64) != 1 { t.Fatalf("idempotent push: %d %v", status, body) } // Download returns exactly what was stored. req, _ := http.NewRequest("GET", h.http.URL+"/api/v1/databases/"+db+"/blob", nil) req.Header.Set("Authorization", "Bearer "+tok) resp, err := h.http.Client().Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() got, _ := io.ReadAll(resp.Body) if string(got) != "kdbx-v1" || resp.Header.Get("ETag") != `"1"` { t.Fatalf("download = %q etag %q", got, resp.Header.Get("ETag")) } } func TestClientWinsAllowsForce(t *testing.T) { h := newHarness(t) tok, _, _ := h.login("admin", adminPassword, "") db := h.makeDB(tok, "Team") h.push(tok, db, `"0"`, []byte("v1")) if status, body := h.call("PATCH", "/api/v1/databases/"+db, tok, map[string]string{"merge_strategy": "client-wins"}); status != http.StatusOK { t.Fatalf("patch strategy: %d %v", status, body) } status, body, _ := h.push(tok, db, "*", []byte("v2-forced")) if status != http.StatusOK || body["version"].(float64) != 2 { t.Fatalf("forced push: %d %v", status, body) } } func TestReadOnlyGrantCannotPush(t *testing.T) { h := newHarness(t) admin, _, _ := h.login("admin", adminPassword, "") db := h.makeDB(admin, "Team") // A child group with a read-only grant, and a member invited into it. status, g := h.call("POST", "/api/v1/groups", admin, map[string]string{"name": "readers", "parent_id": "everyone"}) if status != http.StatusCreated { t.Fatalf("create group: %d %v", status, g) } readers := g["id"].(string) if status, body := h.call("POST", "/api/v1/grants", admin, map[string]string{ "group_id": readers, "database_id": db, "perm": "read"}); status != http.StatusOK { t.Fatalf("grant: %d %v", status, body) } status, inv := h.call("POST", "/api/v1/invites", admin, map[string]any{"group_id": readers, "role": "member", "ttl_hours": 1, "max_uses": 1}) if status != http.StatusCreated { t.Fatalf("invite: %d %v", status, inv) } token := inv["token"].(string) form := url.Values{"username": {"reader"}, "password": {"reader-password"}, "password2": {"reader-password"}} resp, err := h.http.Client().PostForm(h.http.URL+"/invite/"+token, form) if err != nil { t.Fatal(err) } page, _ := io.ReadAll(resp.Body) resp.Body.Close() if !strings.Contains(string(page), "Account created") { t.Fatalf("invite redemption page: %s", page) } rtok, status, _ := h.login("reader", "reader-password", "") if status != http.StatusOK { t.Fatalf("reader login: %d", status) } // The reader sees the database through the grant on its group... status, list := h.call("GET", "/api/v1/databases", rtok, nil) dbs := list["databases"].([]any) if status != http.StatusOK || len(dbs) != 1 { t.Fatalf("reader database list: %d %v", status, list) } if dbs[0].(map[string]any)["perm"] != "read" { t.Fatalf("reader perm = %v", dbs[0]) } // ...but may not write it. if status, body, _ := h.push(rtok, db, `"0"`, []byte("nope")); status != http.StatusForbidden { t.Fatalf("reader push: %d %v", status, body) } // A second redemption of a single-use invite fails. resp2, err := h.http.Client().PostForm(h.http.URL+"/invite/"+token, form) if err != nil { t.Fatal(err) } page2, _ := io.ReadAll(resp2.Body) resp2.Body.Close() if !strings.Contains(string(page2), "already been used") { t.Fatalf("reused invite page: %s", page2) } } func TestEventStreamDeliversPush(t *testing.T) { h := newHarness(t) tok, _, _ := h.login("admin", adminPassword, "") db := h.makeDB(tok, "Team") req, _ := http.NewRequest("GET", h.http.URL+"/api/v1/events?access_token="+tok, nil) resp, err := h.http.Client().Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") { t.Fatalf("content type = %q", ct) } lines := make(chan string, 8) go func() { sc := bufio.NewScanner(resp.Body) for sc.Scan() { lines <- sc.Text() } close(lines) }() // Wait for the stream to register before pushing. deadline := time.After(3 * time.Second) for h.srv.hub.Count() == 0 { select { case <-deadline: t.Fatal("stream never registered") default: time.Sleep(5 * time.Millisecond) } } if status, body, _ := h.push(tok, db, `"0"`, []byte("v1")); status != http.StatusOK { t.Fatalf("push: %d %v", status, body) } for { select { case l, open := <-lines: if !open { t.Fatal("stream closed before the event arrived") } if strings.HasPrefix(l, "data: ") { var ev Event if err := json.Unmarshal([]byte(strings.TrimPrefix(l, "data: ")), &ev); err != nil { t.Fatal(err) } if ev.Type != EventUpdated || ev.DatabaseID != db || ev.Version != 1 { t.Fatalf("event = %+v", ev) } return } case <-time.After(5 * time.Second): t.Fatal("timed out waiting for the push event") } } } func TestTOTPEnrollmentThenRequiredAtLogin(t *testing.T) { h := newHarness(t) tok, _, _ := h.login("admin", adminPassword, "") status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil) if status != http.StatusOK { t.Fatalf("enroll: %d %v", status, body) } sec := body["secret"].(string) if !strings.HasPrefix(body["uri"].(string), "otpauth://totp/") { t.Fatalf("uri = %v", body["uri"]) } code, err := totp.Code(sec, time.Now()) if err != nil { t.Fatal(err) } if status, body = h.call("POST", "/api/v1/auth/totp/confirm", tok, map[string]string{"code": code}); status != http.StatusOK { t.Fatalf("confirm: %d %v", status, body) } if _, status, body = h.login("admin", adminPassword, ""); status != http.StatusUnauthorized || body["code"] != "totp_required" { t.Fatalf("login without code: %d %v", status, body) } code, _ = totp.Code(sec, time.Now()) if _, status, body = h.login("admin", adminPassword, code); status != http.StatusOK { t.Fatalf("login with code: %d %v", status, body) } } func TestDeviceLoginApprovedInBrowser(t *testing.T) { h := newHarness(t) status, start := h.call("POST", "/api/v1/auth/device/start", "", map[string]string{"label": "laptop"}) if status != http.StatusOK { t.Fatalf("device start: %d %v", status, start) } deviceCode := start["device_code"].(string) userCode := start["user_code"].(string) if !strings.Contains(start["verification_url"].(string), userCode) { t.Fatalf("verification url = %v", start["verification_url"]) } if status, body := h.call("POST", "/api/v1/auth/device/poll", "", map[string]string{"device_code": deviceCode}); status != http.StatusAccepted { t.Fatalf("poll before approval: %d %v", status, body) } resp, err := h.http.Client().PostForm(h.http.URL+"/device", url.Values{ "code": {userCode}, "username": {"admin"}, "password": {adminPassword}}) if err != nil { t.Fatal(err) } page, _ := io.ReadAll(resp.Body) resp.Body.Close() if !strings.Contains(string(page), "Device approved") { t.Fatalf("approval page: %s", page) } status, body := h.call("POST", "/api/v1/auth/device/poll", "", map[string]string{"device_code": deviceCode}) if status != http.StatusOK || body["token"] == "" { t.Fatalf("poll after approval: %d %v", status, body) } // The issued token works, and the device code is single use. if status, _ := h.call("GET", "/api/v1/auth/whoami", body["token"].(string), nil); status != http.StatusOK { t.Fatalf("whoami with device token: %d", status) } if status, _ := h.call("POST", "/api/v1/auth/device/poll", "", map[string]string{"device_code": deviceCode}); status != http.StatusGone { t.Fatalf("second poll: %d", status) } } func TestNonAdminCannotAdminister(t *testing.T) { h := newHarness(t) admin, _, _ := h.login("admin", adminPassword, "") hash, _ := secret.Hash("outsider-password") if err := h.srv.Store().Update(func(st *store.State) error { st.Users["out"] = &store.User{ID: "out", Username: "outsider", PassHash: hash} return nil }); err != nil { t.Fatal(err) } db := h.makeDB(admin, "Team") otok, _, _ := h.login("outsider", "outsider-password", "") if status, _ := h.call("POST", "/api/v1/databases", otok, map[string]string{"name": "x", "owner_group_id": "everyone"}); status != http.StatusForbidden { t.Fatalf("outsider created a database: %d", status) } if status, _ := h.call("PATCH", "/api/v1/databases/"+db, otok, map[string]string{"merge_strategy": "client-wins"}); status != http.StatusForbidden { t.Fatalf("outsider changed the strategy: %d", status) } // Without any grant the database is invisible, not merely unwritable. if status, _ := h.call("GET", "/api/v1/databases/"+db+"/blob", otok, nil); status != http.StatusNotFound { t.Fatalf("outsider read the blob: %d", status) } if status, _ := h.call("GET", "/api/v1/databases", "", nil); status != http.StatusUnauthorized { t.Fatalf("anonymous list: %d", status) } }