Files
keeporg/internal/server/integration_test.go
T
ReactorcoremeltdownandClaude Opus 5.5 318bd68908 Collect Android browser sign-ins after the app was stopped
The Android app kept a pending browser sign-in in a thread inside the
login screen. Android often kills the process while the browser is in
front, so the approval reached the server but nothing collected it, and
the app came back signed out with nothing saved.

The pending device code is now kept in Prefs and collected whenever the
login screen resumes. The app passes return_url=keeporg://device-approved
when it starts the sign-in, and the server's approval page, after a
password or single sign-on, links back to it. Only the keeporg: scheme is
accepted. Against a server older than 0.9, which rejects the field, the
app retries without it.

Verified on an Android 15 emulator: approval collected after the process
was killed, and the fallback against the 0.8 server.

Server 0.9.0, Android 0.3.0 (versionCode 3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:16:23 +01:00

491 lines
16 KiB
Go

package server
import (
"bufio"
"bytes"
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"net/url"
"os"
"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
dataDir string
}
const adminPassword = "admin-password-1"
func newHarness(t *testing.T) *harness {
return newHarnessWith(t, nil)
}
// newHarnessWith builds a server whose configuration the caller may adjust,
// for the cases that need OIDC or a different work factor.
func newHarnessWith(t *testing.T, adjust func(*Config)) *harness {
t.Helper()
dir := t.TempDir()
// The listener is created first so the server's external URL is the one
// it actually answers on, which is what an OIDC redirect needs.
ts := httptest.NewUnstartedServer(nil)
cfg := Config{
DataDir: dir,
ExternalURL: "http://" + ts.Listener.Addr().String(),
MasterKeyFile: filepath.Join(t.TempDir(), "master.key"),
VaultKDF: testKDF,
}
if adjust != nil {
adjust(&cfg)
}
srv, err := New(cfg, 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.Config = &http.Server{Handler: srv.Handler()}
ts.Start()
t.Cleanup(ts.Close)
return &harness{t: t, srv: srv, http: ts, dataDir: dir}
}
// stateBytes is the server's whole metadata document as stored on disk.
func (h *harness) stateBytes() []byte {
h.t.Helper()
b, err := os.ReadFile(filepath.Join(h.dataDir, "state.json"))
if err != nil {
h.t.Fatal(err)
}
return b
}
// 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)
}
}
func TestDeviceApprovalLinksBackToTheApp(t *testing.T) {
h := newHarness(t)
if status, body := h.call("POST", "/api/v1/auth/device/start", "",
map[string]string{"label": "phone", "return_url": "https://evil.example/"}); status != http.StatusBadRequest {
t.Fatalf("foreign return_url accepted: %d %v", status, body)
}
status, start := h.call("POST", "/api/v1/auth/device/start", "",
map[string]string{"label": "phone", "return_url": "keeporg://device-approved"})
if status != http.StatusOK {
t.Fatalf("device start: %d %v", status, start)
}
resp, err := h.http.Client().PostForm(h.http.URL+"/device", url.Values{
"code": {start["user_code"].(string)}, "username": {"admin"}, "password": {adminPassword}})
if err != nil {
t.Fatal(err)
}
page, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if !strings.Contains(string(page), `href="keeporg://device-approved"`) {
t.Fatalf("approval page has no link back to the app: %s", page)
}
// A client that asked for nothing gets no link.
_, start = h.call("POST", "/api/v1/auth/device/start", "", map[string]string{"label": "laptop"})
resp, err = h.http.Client().PostForm(h.http.URL+"/device", url.Values{
"code": {start["user_code"].(string)}, "username": {"admin"}, "password": {adminPassword}})
if err != nil {
t.Fatal(err)
}
page, _ = io.ReadAll(resp.Body)
resp.Body.Close()
if strings.Contains(string(page), "Return to KeepOrg") {
t.Fatalf("link shown without a return_url: %s", page)
}
}