package httpserver import ( "bytes" "context" "encoding/json" "io" "log/slog" "mime/multipart" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "testing" "github.com/tim/cairnquire/apps/server/internal/auth" "github.com/tim/cairnquire/apps/server/internal/config" "github.com/tim/cairnquire/apps/server/internal/database" "github.com/tim/cairnquire/apps/server/internal/docs" "github.com/tim/cairnquire/apps/server/internal/markdown" "github.com/tim/cairnquire/apps/server/internal/realtime" "github.com/tim/cairnquire/apps/server/internal/store" "github.com/tim/cairnquire/apps/server/internal/sync" ) type testEmailSender struct { body string to []string } func (s *testEmailSender) Send(ctx context.Context, to []string, subject, body string) error { s.to = append([]string(nil), to...) s.body = body return nil } type apiTestServer struct { handler http.Handler auth *auth.Service docs *docs.Service userID string root string } func newAPITestServer(t *testing.T) *apiTestServer { return newAPITestServerWithSetup(t, true) } func newAPITestServerWithSetup(t *testing.T, setupComplete bool) *apiTestServer { return newAPITestServerWithSetupAndDevMode(t, setupComplete, false) } func newAPITestServerWithSetupAndDevMode(t *testing.T, setupComplete bool, devMode bool) *apiTestServer { t.Helper() ctx := context.Background() root := t.TempDir() sourceDir := filepath.Join(root, "content") storeDir := filepath.Join(root, "files") if err := os.MkdirAll(sourceDir, 0o755); err != nil { t.Fatalf("create source dir: %v", err) } if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte("# Hello\n\nInitial"), 0o644); err != nil { t.Fatalf("write source fixture: %v", err) } db, err := database.Open(ctx, config.DatabaseConfig{Path: filepath.Join(root, "db.sqlite")}) if err != nil { t.Fatalf("open test database: %v", err) } t.Cleanup(func() { _ = db.Close() }) if err := database.ApplyMigrations(ctx, db.SQL()); err != nil { t.Fatalf("apply migrations: %v", err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) contentStore, err := store.New(storeDir) if err != nil { t.Fatalf("create content store: %v", err) } docRepo := docs.NewRepository(db.SQL()) docService := docs.NewService(sourceDir, contentStore, markdown.NewRenderer(), docRepo, logger) if _, err := docService.SyncSourceDir(ctx); err != nil { t.Fatalf("sync source fixture: %v", err) } authService, err := auth.NewService(auth.NewRepository(db.SQL()), "http://localhost:8080") if err != nil { t.Fatalf("create auth service: %v", err) } var user auth.User if setupComplete { user, err = authService.CompleteInitialSetup(ctx, "editor@example.com", "Editor", "very secure password", false) if err != nil { t.Fatalf("create test user: %v", err) } } syncRepo := sync.NewRepository(db.SQL()) handler, err := New(Dependencies{ Config: config.Config{ Content: config.ContentConfig{ SourceDir: sourceDir, StoreDir: storeDir, }, Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"}, DevMode: devMode, }, Logger: logger, Documents: docService, Repository: docRepo, ContentStore: contentStore, Hub: realtime.NewHub(logger), SyncService: sync.NewService(syncRepo, docService, contentStore, sourceDir, logger), SyncRepo: syncRepo, Auth: authService, }) if err != nil { t.Fatalf("create http server: %v", err) } return &apiTestServer{ handler: handler, auth: authService, docs: docService, userID: user.ID, root: root, } } func TestDevModeLogoutSuppressesAutomaticBrowserPrincipal(t *testing.T) { server := newAPITestServerWithSetupAndDevMode(t, true, true) devMe := httptest.NewRecorder() server.handler.ServeHTTP(devMe, httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)) if devMe.Code != http.StatusOK || !bytes.Contains(devMe.Body.Bytes(), []byte(`"method":"dev"`)) { t.Fatalf("dev me response = %d %q, want dev principal", devMe.Code, devMe.Body.String()) } logout := httptest.NewRecorder() server.handler.ServeHTTP(logout, jsonRequest(http.MethodPost, "/api/auth/logout", map[string]any{})) if logout.Code != http.StatusOK { t.Fatalf("logout response = %d %q, want OK", logout.Code, logout.Body.String()) } devLogoutCookie := cookieByName(t, logout.Result().Cookies(), devLogoutCookieName) if devLogoutCookie.Value != "1" { t.Fatalf("dev logout cookie = %#v, want opt-out marker", devLogoutCookie) } loggedOutMeRequest := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) loggedOutMeRequest.AddCookie(devLogoutCookie) loggedOutMe := httptest.NewRecorder() server.handler.ServeHTTP(loggedOutMe, loggedOutMeRequest) if loggedOutMe.Code != http.StatusUnauthorized { t.Fatalf("logged out me response = %d %q, want unauthorized", loggedOutMe.Code, loggedOutMe.Body.String()) } loginPageRequest := httptest.NewRequest(http.MethodGet, "/login", nil) loginPageRequest.AddCookie(devLogoutCookie) loginPage := httptest.NewRecorder() server.handler.ServeHTTP(loginPage, loginPageRequest) if loginPage.Code != http.StatusOK || !bytes.Contains(loginPage.Body.Bytes(), []byte("data-dev-login")) { t.Fatalf("login page response = %d %q, want dev login button", loginPage.Code, loginPage.Body.String()) } devLoginRequest := jsonRequest(http.MethodPost, "/api/auth/login/dev", map[string]any{}) devLoginRequest.AddCookie(devLogoutCookie) devLogin := httptest.NewRecorder() server.handler.ServeHTTP(devLogin, devLoginRequest) if devLogin.Code != http.StatusOK || !bytes.Contains(devLogin.Body.Bytes(), []byte(`"method":"dev"`)) { t.Fatalf("dev login response = %d %q, want dev principal", devLogin.Code, devLogin.Body.String()) } clearedByDevLogin := cookieByName(t, devLogin.Result().Cookies(), devLogoutCookieName) if clearedByDevLogin.MaxAge != -1 { t.Fatalf("dev login cookie = %#v, want MaxAge -1", clearedByDevLogin) } devTokenRequest := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) devTokenRequest.AddCookie(devLogoutCookie) devTokenRequest.Header.Set("Authorization", "Bearer dev") devToken := httptest.NewRecorder() server.handler.ServeHTTP(devToken, devTokenRequest) if devToken.Code != http.StatusOK || !bytes.Contains(devToken.Body.Bytes(), []byte(`"method":"dev"`)) { t.Fatalf("dev token response = %d %q, want dev principal", devToken.Code, devToken.Body.String()) } loginRequest := jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{ "email": "editor@example.com", "password": "very secure password", }) loginRequest.AddCookie(devLogoutCookie) login := httptest.NewRecorder() server.handler.ServeHTTP(login, loginRequest) if login.Code != http.StatusOK { t.Fatalf("login response = %d %q, want OK", login.Code, login.Body.String()) } sessionCookie := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName) if sessionCookie.Value == "" { t.Fatalf("session cookie = %#v, want value", sessionCookie) } clearedDevLogoutCookie := cookieByName(t, login.Result().Cookies(), devLogoutCookieName) if clearedDevLogoutCookie.MaxAge != -1 { t.Fatalf("cleared dev logout cookie = %#v, want MaxAge -1", clearedDevLogoutCookie) } } func cookieByName(t *testing.T, cookies []*http.Cookie, name string) *http.Cookie { t.Helper() for _, cookie := range cookies { if cookie.Name == name { return cookie } } t.Fatalf("response did not include cookie %q; cookies=%#v", name, cookies) return nil } func TestInitialSetupWizardCreatesAdminAndPersistsSignupPolicy(t *testing.T) { server := newAPITestServerWithSetup(t, false) index := httptest.NewRecorder() server.handler.ServeHTTP(index, httptest.NewRequest(http.MethodGet, "/", nil)) if index.Code != http.StatusSeeOther || index.Header().Get("Location") != "/setup" { t.Fatalf("initial index response = %d %q, want redirect to /setup", index.Code, index.Header().Get("Location")) } setupPage := httptest.NewRecorder() server.handler.ServeHTTP(setupPage, httptest.NewRequest(http.MethodGet, "/setup", nil)) if setupPage.Code != http.StatusOK || !bytes.Contains(setupPage.Body.Bytes(), []byte("Initial setup")) { t.Fatalf("setup page response = %d %q", setupPage.Code, setupPage.Body.String()) } setup := httptest.NewRecorder() server.handler.ServeHTTP(setup, jsonRequest(http.MethodPost, "/api/setup", map[string]any{ "email": "owner@example.com", "displayName": "Owner", "password": "correct horse battery staple", "signupsEnabled": false, })) if setup.Code != http.StatusCreated { t.Fatalf("setup status = %d, want %d; body=%s", setup.Code, http.StatusCreated, setup.Body.String()) } if len(setup.Result().Cookies()) == 0 { t.Fatal("expected setup to create a browser session") } settings, err := server.auth.GetInstanceSettings(context.Background()) if err != nil { t.Fatalf("GetInstanceSettings() error = %v", err) } if !settings.SetupComplete || settings.SignupsEnabled { t.Fatalf("settings = %#v, want setup complete with signups disabled", settings) } register := httptest.NewRecorder() server.handler.ServeHTTP(register, jsonRequest(http.MethodPost, "/api/auth/register/password", map[string]any{ "email": "viewer@example.com", "displayName": "Viewer", "password": "correct horse battery staple", })) if register.Code != http.StatusBadRequest || !bytes.Contains(register.Body.Bytes(), []byte("signups are disabled")) { t.Fatalf("register response = %d %q, want disabled signup error", register.Code, register.Body.String()) } } func TestTokenCreationUsesDedicatedAccountPage(t *testing.T) { server := newAPITestServer(t) login := httptest.NewRecorder() server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{ "email": "editor@example.com", "password": "very secure password", })) if login.Code != http.StatusOK || len(login.Result().Cookies()) == 0 { t.Fatalf("login response = %d %q, want session cookie", login.Code, login.Body.String()) } session := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName) accountRequest := httptest.NewRequest(http.MethodGet, "/account", nil) accountRequest.AddCookie(session) account := httptest.NewRecorder() server.handler.ServeHTTP(account, accountRequest) if account.Code != http.StatusOK { t.Fatalf("account status = %d, want %d", account.Code, http.StatusOK) } if bytes.Contains(account.Body.Bytes(), []byte("data-token-create")) { t.Fatal("account page should not render the token creation form") } if !bytes.Contains(account.Body.Bytes(), []byte(`href="/account/tokens/new"`)) { t.Fatal("account page should link to the token creation page") } createRequest := httptest.NewRequest(http.MethodGet, "/account/tokens/new", nil) createRequest.AddCookie(session) create := httptest.NewRecorder() server.handler.ServeHTTP(create, createRequest) if create.Code != http.StatusOK || !bytes.Contains(create.Body.Bytes(), []byte("data-token-create")) { t.Fatalf("token creation page response = %d %q", create.Code, create.Body.String()) } } func TestAdminUserAccessAndPasswordResetHTTPFlow(t *testing.T) { server := newAPITestServer(t) sender := &testEmailSender{} server.auth.SetEmailSender(sender) login := httptest.NewRecorder() server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{ "email": "editor@example.com", "password": "very secure password", })) if login.Code != http.StatusOK || len(login.Result().Cookies()) == 0 { t.Fatalf("login response = %d %q, want session cookie", login.Code, login.Body.String()) } session := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName) updateRequest := jsonRequest(http.MethodPatch, "/api/admin/auth-users", map[string]any{ "users": []map[string]any{{ "id": server.userID, "role": "admin", "disabled": false, }}, }) updateRequest.AddCookie(session) update := httptest.NewRecorder() server.handler.ServeHTTP(update, updateRequest) if update.Code != http.StatusOK { t.Fatalf("bulk user update response = %d %q", update.Code, update.Body.String()) } sendRequest := jsonRequest(http.MethodPost, "/api/admin/auth-users/"+url.PathEscape(server.userID)+"/password-reset", map[string]any{}) sendRequest.AddCookie(session) send := httptest.NewRecorder() server.handler.ServeHTTP(send, sendRequest) if send.Code != http.StatusOK { t.Fatalf("send password reset response = %d %q", send.Code, send.Body.String()) } token := passwordResetTokenFromEmail(t, sender.body) page := httptest.NewRecorder() server.handler.ServeHTTP(page, httptest.NewRequest(http.MethodGet, "/password-reset?token="+url.QueryEscape(token), nil)) if page.Code != http.StatusOK || !bytes.Contains(page.Body.Bytes(), []byte("data-password-reset")) { t.Fatalf("password reset page response = %d %q", page.Code, page.Body.String()) } reset := httptest.NewRecorder() server.handler.ServeHTTP(reset, jsonRequest(http.MethodPost, "/api/auth/password/reset", map[string]string{ "token": token, "newPassword": "new very secure password", })) if reset.Code != http.StatusOK { t.Fatalf("password reset response = %d %q", reset.Code, reset.Body.String()) } newLogin := httptest.NewRecorder() server.handler.ServeHTTP(newLogin, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{ "email": "editor@example.com", "password": "new very secure password", })) if newLogin.Code != http.StatusOK { t.Fatalf("new password login response = %d %q", newLogin.Code, newLogin.Body.String()) } } func passwordResetTokenFromEmail(t *testing.T, body string) string { t.Helper() for _, line := range strings.Split(body, "\n") { if !strings.HasPrefix(line, "http") { continue } parsed, err := url.Parse(line) if err != nil { t.Fatalf("parse password reset URL: %v", err) } if token := parsed.Query().Get("token"); token != "" { return token } } t.Fatalf("password reset body does not contain a link: %q", body) return "" } func (s *apiTestServer) token(t *testing.T, scopes ...auth.Scope) string { t.Helper() return s.tokenForUser(t, s.userID, scopes...) } func (s *apiTestServer) tokenForUser(t *testing.T, userID string, scopes ...auth.Scope) string { t.Helper() created, err := s.auth.CreateAPIKey(context.Background(), userID, "test token", scopes, nil) if err != nil { t.Fatalf("create api token: %v", err) } return created.Token } func (s *apiTestServer) viewerUser(t *testing.T) auth.User { t.Helper() ctx := context.Background() admin := auth.Principal{UserID: s.userID, Role: auth.RoleAdmin} if _, err := s.auth.UpdateSignupsEnabled(ctx, admin, true); err != nil { t.Fatalf("enable signups: %v", err) } user, err := s.auth.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer") if err != nil { t.Fatalf("create viewer: %v", err) } return user } func TestAPIUploadStoresAndServesAttachment(t *testing.T) { server := newAPITestServer(t) token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite) body, contentType := multipartBody(t, "file", `unsafe"name.pdf`, []byte("%PDF-1.4\n")) request := httptest.NewRequest(http.MethodPost, "/api/uploads", body) request.Header.Set("Content-Type", contentType) request.Header.Set("Authorization", "Bearer "+token) recorder := httptest.NewRecorder() server.handler.ServeHTTP(recorder, request) response := recorder.Result() if response.StatusCode != http.StatusCreated { t.Fatalf("upload status = %d, want %d; body=%s", response.StatusCode, http.StatusCreated, recorder.Body.String()) } var payload struct { Hash string `json:"hash"` ContentType string `json:"contentType"` AttachmentURL string `json:"attachmentUrl"` } if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { t.Fatalf("decode upload response: %v", err) } if payload.Hash == "" { t.Fatal("expected upload response hash") } if payload.ContentType != "application/pdf" { t.Fatalf("contentType = %q, want application/pdf", payload.ContentType) } if payload.AttachmentURL != "/attachments/"+payload.Hash { t.Fatalf("attachmentUrl = %q, want /attachments/%s", payload.AttachmentURL, payload.Hash) } download := httptest.NewRequest(http.MethodGet, payload.AttachmentURL, nil) downloadRecorder := httptest.NewRecorder() server.handler.ServeHTTP(downloadRecorder, download) downloadResponse := downloadRecorder.Result() if downloadResponse.StatusCode != http.StatusOK { t.Fatalf("download status = %d, want %d", downloadResponse.StatusCode, http.StatusOK) } if got := downloadResponse.Header.Get("Content-Type"); got != payload.ContentType { t.Fatalf("download content-type = %q, want %q", got, payload.ContentType) } if got := downloadResponse.Header.Get("Content-Disposition"); got != `inline; filename="unsafename.pdf"` { t.Fatalf("content-disposition = %q, want sanitized filename", got) } if downloadRecorder.Body.String() != "%PDF-1.4\n" { t.Fatalf("download body = %q", downloadRecorder.Body.String()) } } func TestAPIDocumentsArePrivateUntilResourceGrant(t *testing.T) { server := newAPITestServer(t) viewer := server.viewerUser(t) token := server.tokenForUser(t, viewer.ID, auth.ScopeDocsRead) listRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil) listRequest.Header.Set("Authorization", "Bearer "+token) listRecorder := httptest.NewRecorder() server.handler.ServeHTTP(listRecorder, listRequest) if listRecorder.Code != http.StatusOK { t.Fatalf("documents status = %d, want %d; body=%s", listRecorder.Code, http.StatusOK, listRecorder.Body.String()) } if strings.Contains(listRecorder.Body.String(), "hello.md") { t.Fatalf("private document leaked before grant: %s", listRecorder.Body.String()) } admin := auth.Principal{UserID: server.userID, Role: auth.RoleAdmin} if _, err := server.auth.UpdateResourcePermissions(context.Background(), admin, auth.ResourceFolder, "", []auth.ResourcePermissionUpdate{ {UserID: viewer.ID, Permission: auth.PermissionRead}, }); err != nil { t.Fatalf("grant root folder read: %v", err) } listRequest = httptest.NewRequest(http.MethodGet, "/api/documents", nil) listRequest.Header.Set("Authorization", "Bearer "+token) listRecorder = httptest.NewRecorder() server.handler.ServeHTTP(listRecorder, listRequest) if listRecorder.Code != http.StatusOK { t.Fatalf("documents after grant status = %d, want %d; body=%s", listRecorder.Code, http.StatusOK, listRecorder.Body.String()) } if !strings.Contains(listRecorder.Body.String(), "hello.md") { t.Fatalf("document missing after folder grant: %s", listRecorder.Body.String()) } } func TestAPIUploadRejectsTokenWithoutDocsWriteScope(t *testing.T) { server := newAPITestServer(t) token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite) body, contentType := multipartBody(t, "file", "note.txt", []byte("plain attachment\n")) request := httptest.NewRequest(http.MethodPost, "/api/uploads", body) request.Header.Set("Content-Type", contentType) request.Header.Set("Authorization", "Bearer "+token) recorder := httptest.NewRecorder() server.handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusForbidden { t.Fatalf("status = %d, want %d", recorder.Code, http.StatusForbidden) } } func TestAPISyncSnapshotInheritsResourcePermissions(t *testing.T) { server := newAPITestServer(t) viewer := server.viewerUser(t) token := server.tokenForUser(t, viewer.ID, auth.ScopeSyncRead, auth.ScopeSyncWrite) initRequest := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-1"}) initRequest.Header.Set("Authorization", "Bearer "+token) initRecorder := httptest.NewRecorder() server.handler.ServeHTTP(initRecorder, initRequest) if initRecorder.Code != http.StatusOK { t.Fatalf("sync init status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String()) } if strings.Contains(initRecorder.Body.String(), "hello.md") { t.Fatalf("private sync snapshot leaked before grant: %s", initRecorder.Body.String()) } admin := auth.Principal{UserID: server.userID, Role: auth.RoleAdmin} if _, err := server.auth.UpdateResourcePermissions(context.Background(), admin, auth.ResourceFolder, "", []auth.ResourcePermissionUpdate{ {UserID: viewer.ID, Permission: auth.PermissionRead}, }); err != nil { t.Fatalf("grant root folder read: %v", err) } initRequest = jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-2"}) initRequest.Header.Set("Authorization", "Bearer "+token) initRecorder = httptest.NewRecorder() server.handler.ServeHTTP(initRecorder, initRequest) if initRecorder.Code != http.StatusOK { t.Fatalf("sync init after grant status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String()) } if !strings.Contains(initRecorder.Body.String(), "hello.md") { t.Fatalf("sync snapshot missing document after folder grant: %s", initRecorder.Body.String()) } } func TestAPISyncInitAndContentFetchUseBearerScopes(t *testing.T) { server := newAPITestServer(t) token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite) request := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-1"}) request.Header.Set("Authorization", "Bearer "+token) recorder := httptest.NewRecorder() server.handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("sync init status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) } var payload struct { SnapshotID string `json:"snapshotId"` ServerSnapshot []sync.FileEntry `json:"serverSnapshot"` } if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { t.Fatalf("decode sync init response: %v", err) } if payload.SnapshotID == "" { t.Fatal("expected snapshot id") } if len(payload.ServerSnapshot) != 1 || payload.ServerSnapshot[0].Path != "hello.md" { t.Fatalf("server snapshot = %#v, want hello.md entry", payload.ServerSnapshot) } fetch := httptest.NewRequest(http.MethodGet, "/api/content/"+payload.ServerSnapshot[0].Hash, nil) fetch.Header.Set("Authorization", "Bearer "+token) fetchRecorder := httptest.NewRecorder() server.handler.ServeHTTP(fetchRecorder, fetch) if fetchRecorder.Code != http.StatusOK { t.Fatalf("content fetch status = %d, want %d", fetchRecorder.Code, http.StatusOK) } if fetchRecorder.Body.String() != "# Hello\n\nInitial" { t.Fatalf("content fetch body = %q", fetchRecorder.Body.String()) } } func TestAPISyncInitRejectsTokenWithoutSyncWriteScope(t *testing.T) { server := newAPITestServer(t) token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite) request := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-1"}) request.Header.Set("Authorization", "Bearer "+token) recorder := httptest.NewRecorder() server.handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusForbidden { t.Fatalf("status = %d, want %d", recorder.Code, http.StatusForbidden) } } func TestAPISyncDeltaReturnsServerChanges(t *testing.T) { server := newAPITestServer(t) token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite) initRequest := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-1"}) initRequest.Header.Set("Authorization", "Bearer "+token) initRecorder := httptest.NewRecorder() server.handler.ServeHTTP(initRecorder, initRequest) if initRecorder.Code != http.StatusOK { t.Fatalf("sync init status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String()) } var initPayload struct { SnapshotID string `json:"snapshotId"` } if err := json.Unmarshal(initRecorder.Body.Bytes(), &initPayload); err != nil { t.Fatalf("decode sync init response: %v", err) } if err := os.WriteFile(filepath.Join(server.root, "content", "hello.md"), []byte("# Hello\n\nServer update"), 0o644); err != nil { t.Fatalf("write server update: %v", err) } deltaRequest := jsonRequest(http.MethodPost, "/api/sync/delta", map[string]any{ "snapshotId": initPayload.SnapshotID, "clientDelta": []sync.Change{}, }) deltaRequest.Header.Set("Authorization", "Bearer "+token) deltaRecorder := httptest.NewRecorder() server.handler.ServeHTTP(deltaRecorder, deltaRequest) if deltaRecorder.Code != http.StatusOK { t.Fatalf("sync delta status = %d, want %d; body=%s", deltaRecorder.Code, http.StatusOK, deltaRecorder.Body.String()) } var deltaPayload struct { ServerDelta []sync.Change `json:"serverDelta"` Conflicts []sync.Conflict `json:"conflicts"` } if err := json.Unmarshal(deltaRecorder.Body.Bytes(), &deltaPayload); err != nil { t.Fatalf("decode sync delta response: %v", err) } if len(deltaPayload.ServerDelta) != 1 { t.Fatalf("serverDelta = %#v, want one update", deltaPayload.ServerDelta) } change := deltaPayload.ServerDelta[0] if change.Type != sync.ChangeUpdate || change.Path != "hello.md" || change.Hash == "" { t.Fatalf("server delta change = %#v, want update for hello.md with hash", change) } if len(deltaPayload.Conflicts) != 0 { t.Fatalf("conflicts = %#v, want none", deltaPayload.Conflicts) } } func TestAPIDocumentSaveReturnsConflictForStaleBaseHash(t *testing.T) { server := newAPITestServer(t) token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite) documentsRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil) documentsRequest.Header.Set("Authorization", "Bearer "+token) documents := httptest.NewRecorder() server.handler.ServeHTTP(documents, documentsRequest) if documents.Code != http.StatusOK { t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK) } var list struct { Documents []struct { Path string `json:"path"` Hash string `json:"hash"` } `json:"documents"` } if err := json.Unmarshal(documents.Body.Bytes(), &list); err != nil { t.Fatalf("decode documents: %v", err) } if len(list.Documents) != 1 { t.Fatalf("documents = %#v, want one document", list.Documents) } if err := os.WriteFile(filepath.Join(server.root, "content", "hello.md"), []byte("# Hello\n\nServer edit"), 0o644); err != nil { t.Fatalf("write server edit: %v", err) } save := jsonRequest(http.MethodPost, "/api/documents/hello", map[string]string{ "content": "# Hello\n\nClient edit", "baseHash": list.Documents[0].Hash, }) save.Header.Set("Authorization", "Bearer "+token) saveRecorder := httptest.NewRecorder() server.handler.ServeHTTP(saveRecorder, save) if saveRecorder.Code != http.StatusConflict { t.Fatalf("save status = %d, want %d; body=%s", saveRecorder.Code, http.StatusConflict, saveRecorder.Body.String()) } var conflict struct { Status string `json:"status"` Path string `json:"path"` BaseHash string `json:"baseHash"` CurrentHash string `json:"currentHash"` CurrentContent string `json:"currentContent"` } if err := json.Unmarshal(saveRecorder.Body.Bytes(), &conflict); err != nil { t.Fatalf("decode conflict response: %v", err) } if conflict.Status != "conflict" || conflict.Path != "hello.md" { t.Fatalf("conflict payload = %#v, want conflict for hello.md", conflict) } if conflict.BaseHash != list.Documents[0].Hash { t.Fatalf("baseHash = %q, want %q", conflict.BaseHash, list.Documents[0].Hash) } if conflict.CurrentHash == "" || conflict.CurrentHash == conflict.BaseHash { t.Fatalf("currentHash = %q, baseHash = %q", conflict.CurrentHash, conflict.BaseHash) } if conflict.CurrentContent != "# Hello\n\nServer edit" { t.Fatalf("currentContent = %q", conflict.CurrentContent) } } func TestAPIDocumentCreateGrantsCreatorAdminPermission(t *testing.T) { server := newAPITestServer(t) token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite) save := jsonRequest(http.MethodPost, "/api/documents/inbox/new-note", map[string]string{ "content": "# New Note\n\nCreated in browser", }) save.Header.Set("Authorization", "Bearer "+token) saveRecorder := httptest.NewRecorder() server.handler.ServeHTTP(saveRecorder, save) if saveRecorder.Code != http.StatusOK { t.Fatalf("save status = %d, want %d; body=%s", saveRecorder.Code, http.StatusOK, saveRecorder.Body.String()) } permissions := httptest.NewRequest(http.MethodGet, "/api/permissions?resourceType=document&resourceId=inbox/new-note.md", nil) permissions.Header.Set("Authorization", "Bearer "+token) permissionsRecorder := httptest.NewRecorder() server.handler.ServeHTTP(permissionsRecorder, permissions) if permissionsRecorder.Code != http.StatusOK { t.Fatalf("permissions status = %d, want %d; body=%s", permissionsRecorder.Code, http.StatusOK, permissionsRecorder.Body.String()) } var payload struct { Permissions []auth.ResourcePermission `json:"permissions"` } if err := json.Unmarshal(permissionsRecorder.Body.Bytes(), &payload); err != nil { t.Fatalf("decode permissions: %v", err) } if len(payload.Permissions) != 1 || payload.Permissions[0].UserID != server.userID || payload.Permissions[0].Permission != auth.PermissionAdmin { t.Fatalf("permissions = %#v, want creator admin grant", payload.Permissions) } } func TestAPIDocumentArchiveSupportsNestedPaths(t *testing.T) { server := newAPITestServer(t) token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite) ctx := context.Background() nestedDir := filepath.Join(server.root, "content", "guide") if err := os.MkdirAll(nestedDir, 0o755); err != nil { t.Fatalf("create nested source dir: %v", err) } if err := os.WriteFile(filepath.Join(nestedDir, "setup.md"), []byte("# Setup\n\nNested"), 0o644); err != nil { t.Fatalf("write nested source fixture: %v", err) } if _, err := server.docs.SyncSourceDir(ctx); err != nil { t.Fatalf("sync nested source fixture: %v", err) } request := httptest.NewRequest(http.MethodPost, "/api/documents/guide/setup.md/archive", nil) request.Header.Set("Authorization", "Bearer "+token) recorder := httptest.NewRecorder() server.handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("archive status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) } documentsRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil) documentsRequest.Header.Set("Authorization", "Bearer "+token) documents := httptest.NewRecorder() server.handler.ServeHTTP(documents, documentsRequest) if documents.Code != http.StatusOK { t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK) } if strings.Contains(documents.Body.String(), "guide/setup.md") { t.Fatalf("archived document still appears in active document list: %s", documents.Body.String()) } } func TestAPIDocumentArchiveUnescapesSpecialCharacters(t *testing.T) { server := newAPITestServer(t) token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite) ctx := context.Background() if err := os.WriteFile(filepath.Join(server.root, "content", "@dani.md"), []byte("# @dani\n\nProfile"), 0o644); err != nil { t.Fatalf("write special source fixture: %v", err) } if _, err := server.docs.SyncSourceDir(ctx); err != nil { t.Fatalf("sync special source fixture: %v", err) } request := httptest.NewRequest(http.MethodPost, "/api/documents/"+url.PathEscape("@dani.md")+"/archive", nil) request.Header.Set("Authorization", "Bearer "+token) recorder := httptest.NewRecorder() server.handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("archive status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) } documentsRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil) documentsRequest.Header.Set("Authorization", "Bearer "+token) documents := httptest.NewRecorder() server.handler.ServeHTTP(documents, documentsRequest) if documents.Code != http.StatusOK { t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK) } if strings.Contains(documents.Body.String(), "@dani.md") { t.Fatalf("archived document still appears in active document list: %s", documents.Body.String()) } } func multipartBody(t *testing.T, fieldName, filename string, content []byte) (*bytes.Buffer, string) { t.Helper() body := &bytes.Buffer{} writer := multipart.NewWriter(body) part, err := writer.CreateFormFile(fieldName, filename) if err != nil { t.Fatalf("create multipart file: %v", err) } if _, err := part.Write(content); err != nil { t.Fatalf("write multipart file: %v", err) } if err := writer.Close(); err != nil { t.Fatalf("close multipart writer: %v", err) } return body, writer.FormDataContentType() } func jsonRequest(method, path string, payload any) *http.Request { body, err := json.Marshal(payload) if err != nil { panic(err) } request := httptest.NewRequest(method, path, bytes.NewReader(body)) request.Header.Set("Content-Type", "application/json") return request }