diff --git a/README.md b/README.md index 98b737a..ed8c969 100644 --- a/README.md +++ b/README.md @@ -31,4 +31,5 @@ make check-style ## Documentation + See the [Mattermost plugin development guide](https://developers.mattermost.com/integrate/plugins/) for plugin structure, server/webapp hooks, and the release process. diff --git a/assets/i18n/en.json b/assets/i18n/en.json index a18af74..7112364 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -43,10 +43,6 @@ "id": "app.page.create.invalid_user_id.app_error", "translation": "Invalid user ID." }, - { - "id": "app.page.create.search_text_without_content.app_error", - "translation": "Search text cannot be set without page content." - }, { "id": "app.page.create.space_not_found.app_error", "translation": "The space could not be found." @@ -103,6 +99,10 @@ "id": "app.page.get_children.invalid_id.app_error", "translation": "Invalid page ID." }, + { + "id": "app.page.invalid_content.app_error", + "translation": "The page body is not valid content." + }, { "id": "app.page.invalid_parent.app_error", "translation": "The destination parent page does not exist." @@ -151,6 +151,14 @@ "id": "app.page.not_found.app_error", "translation": "The page could not be found." }, + { + "id": "app.page.presence.invalid_page_id.app_error", + "translation": "Invalid page ID." + }, + { + "id": "app.page.presence.invalid_space_id.app_error", + "translation": "Invalid space ID." + }, { "id": "app.page.restore.invalid_id.app_error", "translation": "Invalid page ID." @@ -199,6 +207,158 @@ "id": "app.page.update.store_error.app_error", "translation": "An error occurred while updating the page." }, + { + "id": "app.page_draft.create.invalid_parent.app_error", + "translation": "The parent page or draft was not found." + }, + { + "id": "app.page_draft.create.invalid_parent_id.app_error", + "translation": "The parent ID is not a valid ID." + }, + { + "id": "app.page_draft.create.invalid_space_id.app_error", + "translation": "Invalid space ID." + }, + { + "id": "app.page_draft.create.invalid_user_id.app_error", + "translation": "Invalid user ID." + }, + { + "id": "app.page_draft.create.parent_cycle.app_error", + "translation": "Setting this parent would create a cycle in the draft hierarchy." + }, + { + "id": "app.page_draft.create.parent_too_deep.app_error", + "translation": "The draft hierarchy is too deep to add another level." + }, + { + "id": "app.page_draft.delete.invalid_page_id.app_error", + "translation": "Invalid page ID." + }, + { + "id": "app.page_draft.delete.invalid_space_id.app_error", + "translation": "Invalid space ID." + }, + { + "id": "app.page_draft.delete.invalid_user_id.app_error", + "translation": "Invalid user ID." + }, + { + "id": "app.page_draft.delete.not_found.app_error", + "translation": "Draft not found." + }, + { + "id": "app.page_draft.get.invalid_page_id.app_error", + "translation": "Invalid page ID." + }, + { + "id": "app.page_draft.get.invalid_space_id.app_error", + "translation": "Invalid space ID." + }, + { + "id": "app.page_draft.get.invalid_user_id.app_error", + "translation": "Invalid user ID." + }, + { + "id": "app.page_draft.get.not_found.app_error", + "translation": "Draft not found." + }, + { + "id": "app.page_draft.list.invalid_space_id.app_error", + "translation": "Invalid space ID." + }, + { + "id": "app.page_draft.list.invalid_user_id.app_error", + "translation": "Invalid user ID." + }, + { + "id": "app.page_draft.publish.baseline_required.app_error", + "translation": "Cannot publish: the draft is missing its edit baseline. Reopen the page and try again." + }, + { + "id": "app.page_draft.publish.conflict.app_error", + "translation": "The page was modified by another writer. Refresh and try again." + }, + { + "id": "app.page_draft.publish.draft_changed.app_error", + "translation": "This draft was saved again while it was being published. Nothing was published; try publishing again to include your latest changes." + }, + { + "id": "app.page_draft.publish.draft_not_found.app_error", + "translation": "The draft could not be found; it may have already been published or discarded." + }, + { + "id": "app.page_draft.publish.edit_conflict.app_error", + "translation": "Someone else edited this page while you were writing. Reopen the page to see their changes, then publish again." + }, + { + "id": "app.page_draft.publish.invalid_page_id.app_error", + "translation": "Invalid page ID." + }, + { + "id": "app.page_draft.publish.invalid_space_id.app_error", + "translation": "Invalid space ID." + }, + { + "id": "app.page_draft.publish.invalid_user_id.app_error", + "translation": "Invalid user ID." + }, + { + "id": "app.page_draft.publish.page_deleted.app_error", + "translation": "The page was deleted and can no longer be published." + }, + { + "id": "app.page_draft.publish.page_not_found.app_error", + "translation": "The page does not exist or is not accessible in this space." + }, + { + "id": "app.page_draft.publish.parent_unpublished.app_error", + "translation": "The parent page must be published before this page can be published." + }, + { + "id": "app.page_draft.quota_exceeded.app_error", + "translation": "Draft limit reached. Publish or discard an existing draft to free up space." + }, + { + "id": "app.page_draft.update.draft_changed.app_error", + "translation": "A concurrent autosave updated the draft. Reload it and try saving again." + }, + { + "id": "app.page_draft.update.edit_conflict.app_error", + "translation": "A concurrent edit has been published; please reload the page to continue editing." + }, + { + "id": "app.page_draft.update.invalid_page_id.app_error", + "translation": "Invalid page ID." + }, + { + "id": "app.page_draft.update.invalid_parent_id.app_error", + "translation": "Invalid parent ID." + }, + { + "id": "app.page_draft.update.invalid_space_id.app_error", + "translation": "Invalid space ID." + }, + { + "id": "app.page_draft.update.invalid_user_id.app_error", + "translation": "Invalid user ID." + }, + { + "id": "app.page_draft.update.nil_draft.app_error", + "translation": "Draft is required." + }, + { + "id": "app.page_draft.update.page_not_found.app_error", + "translation": "The page does not exist or is not accessible in this space." + }, + { + "id": "app.page_draft.update.parent_cycle.app_error", + "translation": "Setting this parent would create a cycle in the draft hierarchy." + }, + { + "id": "app.page_draft.update.parent_too_deep.app_error", + "translation": "The draft hierarchy is too deep to add another level." + }, { "id": "app.shared.description_too_long.app_error", "translation": "The description exceeds the maximum length of {{.MaxLength}} characters." @@ -367,6 +527,10 @@ "id": "app.store.too_large.app_error", "translation": "The result set is too large; narrow your request (limit {{.Limit}})." }, + { + "id": "model.draft.is_valid.base_edit_at.app_error", + "translation": "Invalid draft baseline edit time." + }, { "id": "model.draft.is_valid.body_size.app_error", "translation": "The draft body is too long." @@ -375,10 +539,18 @@ "id": "model.draft.is_valid.create_at.app_error", "translation": "Invalid draft creation time." }, + { + "id": "model.draft.is_valid.file_id.app_error", + "translation": "One or more file IDs are invalid." + }, { "id": "model.draft.is_valid.file_ids.app_error", "translation": "The draft has too many file attachments." }, + { + "id": "model.draft.is_valid.last_active_at.app_error", + "translation": "Invalid draft last-active time." + }, { "id": "model.draft.is_valid.page_id.app_error", "translation": "Invalid page ID for the draft." diff --git a/go.mod b/go.mod index 4dca5b5..0b5ee62 100644 --- a/go.mod +++ b/go.mod @@ -2,19 +2,19 @@ module github.com/mattermost/mattermost-plugin-docs go 1.26.4 -// Dev-only pins: server/public is pinned to the head of the paired core branch that adds the -// Space backing-channel type — ChannelTypeSpace ("S"), pluginapi Channel.GetChannelOfType, and -// pluginapi Channel.Restore are not yet in a released server/public. +// Dev-only pins: server/public is pinned to the master commit that merged the Space +// backing-channel type — ChannelTypeSpace ("S"), pluginapi Channel.GetChannelOfType, and +// pluginapi Channel.Restore — which is not yet in a released server/public. // server/v8 is the test harness only (storetest helpers); it does not contribute any runtime // symbols and is pinned independently to an older commit. The two modules live in the same // monorepo but are versioned independently, so their pseudo-version timestamps will always // differ; what matters is that server/public has the APIs this plugin calls. -// Bump both to a release tag once the core space-channel changes merge and ship. +// Bump both to a release tag once the core space-channel changes ship. require ( github.com/gorilla/mux v1.8.1 github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.12.3 - github.com/mattermost/mattermost/server/public v0.4.4-0.20260713131524-80d5b7966dc5 + github.com/mattermost/mattermost/server/public v0.4.4-0.20260716203457-5f7f967a7dbf github.com/mattermost/mattermost/server/v8 v8.0.0-20260623200446-ba033eae4704 github.com/mattermost/morph v1.1.0 github.com/mattermost/squirrel v0.5.0 diff --git a/go.sum b/go.sum index 3a0cdde..5b7f020 100644 --- a/go.sum +++ b/go.sum @@ -140,8 +140,8 @@ github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 h1:Y1Tu/swM31pVwwb github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956/go.mod h1:SRl30Lb7/QoYyohYeVBuqYvvmXSZJxZgiV3Zf6VbxjI= github.com/mattermost/logr/v2 v2.0.22 h1:npFkXlkAWR9J8payh8ftPcCZvLbHSI125mAM5/r/lP4= github.com/mattermost/logr/v2 v2.0.22/go.mod h1:0sUKpO+XNMZApeumaid7PYaUZPBIydfuWZ0dqixXo+s= -github.com/mattermost/mattermost/server/public v0.4.4-0.20260713131524-80d5b7966dc5 h1:L/o7nmoq4fv/wfcaen7tr85GxZ/V05hpzfpI3/TuOto= -github.com/mattermost/mattermost/server/public v0.4.4-0.20260713131524-80d5b7966dc5/go.mod h1:rHFKFSnyNmyk1qieL00Fv+YuLb093Q7y8VTwBu/43ic= +github.com/mattermost/mattermost/server/public v0.4.4-0.20260716203457-5f7f967a7dbf h1:Elv/2xLhHNauIlgA3EajAQYRzpF2OfCF77UTCmQdhX4= +github.com/mattermost/mattermost/server/public v0.4.4-0.20260716203457-5f7f967a7dbf/go.mod h1:rHFKFSnyNmyk1qieL00Fv+YuLb093Q7y8VTwBu/43ic= github.com/mattermost/mattermost/server/v8 v8.0.0-20260623200446-ba033eae4704 h1:vEw+u4m6mUrjHgpvDLXfCkuYcpUk8Q5CorgPRsqjW74= github.com/mattermost/mattermost/server/v8 v8.0.0-20260623200446-ba033eae4704/go.mod h1:RBaqawSPsPB76XA4hfrIRFvLS0QWF1qJWHr2+w34+6s= github.com/mattermost/morph v1.1.0 h1:Q9vrJbeM3s2jfweGheq12EFIzdNp9a/6IovcbvOQ6Cw= diff --git a/server/api.go b/server/api.go index 287f347..c128f36 100644 --- a/server/api.go +++ b/server/api.go @@ -23,8 +23,8 @@ import ( // // Authorization: every route requires an authenticated user via MattermostAuthorizationRequired. // All space- and page-scoped handlers additionally gate on backing-channel membership via -// CheckSpaceMembership (implemented). Per-page role ACLs (author vs. editor within a space) -// are not yet implemented and are deferred to a follow-up. +// CheckSpaceMembership. Per-page role ACLs (author vs. editor within a space) are not yet +// implemented. func (p *Plugin) initRouter() *mux.Router { router := mux.NewRouter() router.Use(p.MattermostAuthorizationRequired) @@ -60,6 +60,17 @@ func (p *Plugin) initRouter() *mux.Router { api.HandleFunc("/spaces/{space_id}/pages/{page_id}/move-to-space", p.handleMovePageToSpace).Methods(http.MethodPatch) api.HandleFunc("/spaces/{space_id}/pages/{page_id}/duplicate", p.handleDuplicatePage).Methods(http.MethodPost) + // Draft CRUD + publish. + api.HandleFunc("/spaces/{space_id}/drafts", p.handleCreateSpaceDraft).Methods(http.MethodPost) + api.HandleFunc("/spaces/{space_id}/drafts", p.handleGetPageDraftsForSpace).Methods(http.MethodGet) + api.HandleFunc("/spaces/{space_id}/pages/{page_id}/draft", p.handleUpdatePageDraft).Methods(http.MethodPatch) + api.HandleFunc("/spaces/{space_id}/pages/{page_id}/draft", p.handleGetPageDraft).Methods(http.MethodGet) + api.HandleFunc("/spaces/{space_id}/pages/{page_id}/draft", p.handleDeletePageDraft).Methods(http.MethodDelete) + api.HandleFunc("/spaces/{space_id}/pages/{page_id}/draft/publish", p.handlePublishPageDraft).Methods(http.MethodPost) + + // Presence. + api.HandleFunc("/spaces/{space_id}/pages/{page_id}/active-editors", p.handleGetPageActiveEditors).Methods(http.MethodGet) + return router } @@ -118,11 +129,38 @@ func (p *Plugin) writeAppError(w http.ResponseWriter, appErr *mmmodel.AppError) if appErr.StatusCode >= http.StatusInternalServerError { p.API.LogError("Docs API request failed", "where", appErr.Where, "id", appErr.Id, "status_code", appErr.StatusCode, "err", appErr.Error()) } + if appErr.StatusCode == http.StatusConflict { + p.writeConflictWithPage(w, appErr, nil) + return + } safe := *appErr safe.WipeDetailed() writeJSON(w, appErr.StatusCode, &safe) } +// conflictResponse is the body every 409 carries: the scrubbed AppError plus the current server +// page. One shape across all conflicts means a client parses a 409 the same way whichever endpoint +// produced it, rather than branching on the route. +// +// current_page is null when the handler has no page to offer — the conflict was not about a page, or +// the re-read that would have produced it failed — so a client treats it as an optional shortcut and +// falls back to a GET. Where it is populated (publish and page-update conflicts, which already read +// the live page to build the error) it saves that round-trip: the client diffs and re-baselines +// against the returned EditAt directly. The whole page is returned rather than a curated snapshot, +// so the client renders whatever it needs. +type conflictResponse struct { + Error *mmmodel.AppError `json:"error"` + CurrentPage *model.Page `json:"current_page"` +} + +// writeConflictWithPage writes a conflictResponse using the AppError's own StatusCode (409) as the +// HTTP status. DetailedError is scrubbed first, matching writeAppError. +func (p *Plugin) writeConflictWithPage(w http.ResponseWriter, appErr *mmmodel.AppError, current *model.Page) { + safe := *appErr + safe.WipeDetailed() + writeJSON(w, appErr.StatusCode, conflictResponse{Error: &safe, CurrentPage: current}) +} + // writeJSON serialises v as a JSON body with the given status. func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") diff --git a/server/api_handler_test.go b/server/api_handler_test.go index 3b558ef..fc0d569 100644 --- a/server/api_handler_test.go +++ b/server/api_handler_test.go @@ -110,7 +110,6 @@ func seedSpace(t *testing.T, s *store.Store, channelID string) *model.Space { return seedSpaceInTeam(t, s, channelID, mmmodel.NewId()) } -// seedSpaceInTeam mirrors testutil.MustCreateSpace's (channelID, teamID) parameter order. func seedSpaceInTeam(t *testing.T, s *store.Store, channelID, teamID string) *model.Space { t.Helper() return testutil.MustCreateSpace(t, s, channelID, teamID) @@ -224,16 +223,16 @@ func TestHandler_SpaceAndPageRoundTrip(t *testing.T) { require.Equal(t, space.Id, page.SpaceId) }) - t.Run("create page with content and search_text", func(t *testing.T) { + t.Run("create page derives search_text from body", func(t *testing.T) { rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/pages", user, map[string]any{ "title": "Page B", - "body": `{"type":"doc","content":[]}`, - "search_text": "plain text projection", + "body": `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"searchable body"}]}]}`, + "search_text": "ignored client value", }) require.Equal(t, http.StatusCreated, rec.Code) var page model.Page require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &page)) - require.Equal(t, "plain text projection", page.SearchText) + require.Equal(t, "searchable body", page.SearchText, "SearchText is derived from the body, not the caller-supplied value") }) t.Run("get page in wrong space is 404", func(t *testing.T) { @@ -545,11 +544,10 @@ func TestHandler_UpdatePage(t *testing.T) { space := seedSpace(t, h.store, channelID) page := seedPage(t, h.store, space.Id, channelID, "") - // Body and search text must be patched together (search text is the body's plain-text - // projection), so both are supplied. + // SearchText is derived from the body server-side, so only the body is supplied; a + // caller-supplied search_text is ignored. body := map[string]any{ - "body": `{"type":"doc","content":[{"type":"paragraph"}]}`, - "search_text": "updated text", + "body": `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"updated text"}]}]}`, "base_edit_at": page.EditAt, } rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id, user, body) @@ -557,12 +555,24 @@ func TestHandler_UpdatePage(t *testing.T) { var updated model.Page require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated)) - require.Equal(t, `{"type":"doc","content":[{"type":"paragraph"}]}`, updated.Body) + require.Contains(t, updated.Body, "updated text") require.Equal(t, "updated text", updated.SearchText) // The first update bumped EditAt, so the same baseline is now stale. rec = h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id, user, body) require.Equal(t, http.StatusConflict, rec.Code) + + // Every 409 carries the same shape, so a client parses it without branching on the route. This + // one populates current_page, letting the caller re-baseline without a follow-up read. + var conflict struct { + Error *mmmodel.AppError `json:"error"` + CurrentPage *model.Page `json:"current_page"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &conflict)) + require.NotNil(t, conflict.Error) + require.Empty(t, conflict.Error.DetailedError, "the conflict body must not carry internal error detail") + require.NotNil(t, conflict.CurrentPage, "the update conflict must carry the current server page") + require.Greater(t, conflict.CurrentPage.EditAt, page.EditAt, "current page must carry the advanced baseline") } // TestHandler_UpdatePage_BaselineRequired verifies a PATCH that omits base_edit_at without force @@ -767,7 +777,7 @@ func TestHandler_MovePageToSpace_DepthExceeded(t *testing.T) { require.NoError(t, err) parentID := "" - for range app.MaxPageDepth { + for range model.MaxPageDepth { p := seedPage(t, h.store, spaceB.Id, channelB, parentID) parentID = p.Id } @@ -837,7 +847,7 @@ func TestHandler_MovePage_MaxDepthExceeded(t *testing.T) { space := seedSpace(t, h.store, channelID) parentID := "" - for range app.MaxPageDepth { + for range model.MaxPageDepth { p := seedPage(t, h.store, space.Id, channelID, parentID) parentID = p.Id } @@ -1097,6 +1107,14 @@ func TestHandler_SpaceMembershipRequired(t *testing.T) { {http.MethodPatch, "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/move", nil}, {http.MethodPatch, "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/move-to-space", nil}, {http.MethodPost, "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/duplicate", nil}, + // Draft + presence handlers. + {http.MethodPost, "/api/v1/spaces/" + space.Id + "/drafts", map[string]any{"title": "D"}}, + {http.MethodGet, "/api/v1/spaces/" + space.Id + "/drafts", nil}, + {http.MethodPatch, "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/draft", map[string]any{"title": "D"}}, + {http.MethodGet, "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/draft", nil}, + {http.MethodDelete, "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/draft", nil}, + {http.MethodPost, "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/draft/publish", nil}, + {http.MethodGet, "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/active-editors", nil}, } for _, tc := range cases { rec := h.do(t, tc.method, tc.path, stranger, tc.body) diff --git a/server/api_page.go b/server/api_page.go index d7f27f5..0328908 100644 --- a/server/api_page.go +++ b/server/api_page.go @@ -44,16 +44,16 @@ func (p *Plugin) handleCreatePage(w http.ResponseWriter, r *http.Request) { return } + // SearchText is not accepted: it is derived server-side from Body. var req struct { - Title string `json:"title"` - ParentId *string `json:"parent_id,omitempty"` - Body string `json:"body,omitempty"` - SearchText string `json:"search_text,omitempty"` + Title string `json:"title"` + ParentId *string `json:"parent_id,omitempty"` + Body string `json:"body,omitempty"` } if !p.decodeJSONBody(w, r, maxPageBodyBytes, &req, "handleCreatePage", false) { return } - page, appErr := p.service.CreatePage(vars["space_id"], mmmodel.SafeDereference(req.ParentId), req.Title, req.Body, req.SearchText, userID) + page, appErr := p.service.CreatePage(vars["space_id"], mmmodel.SafeDereference(req.ParentId), req.Title, req.Body, userID) if appErr != nil { p.writeAppError(w, appErr) return @@ -85,10 +85,10 @@ func (p *Plugin) handleUpdatePage(w http.ResponseWriter, r *http.Request) { return } + // SearchText is not accepted: it is derived server-side from Body (matches the create path). var req struct { Title *string `json:"title"` Body *string `json:"body"` - SearchText *string `json:"search_text"` Props *mmmodel.StringInterface `json:"props"` BaseEditAt *int64 `json:"base_edit_at"` Force bool `json:"force"` @@ -96,10 +96,14 @@ func (p *Plugin) handleUpdatePage(w http.ResponseWriter, r *http.Request) { if !p.decodeJSONBody(w, r, maxPageBodyBytes, &req, "handleUpdatePage", false) { return } - patch := &model.PagePatch{Title: req.Title, Body: req.Body, SearchText: req.SearchText, Props: req.Props} + patch := &model.PagePatch{Title: req.Title, Body: req.Body, Props: req.Props} updated, appErr := p.service.UpdatePage(vars["page_id"], vars["space_id"], patch, req.BaseEditAt, req.Force, userID) if appErr != nil { + if updated != nil { + p.writeConflictWithPage(w, appErr, updated) + return + } p.writeAppError(w, appErr) return } diff --git a/server/api_page_drafts.go b/server/api_page_drafts.go new file mode 100644 index 0000000..38441e0 --- /dev/null +++ b/server/api_page_drafts.go @@ -0,0 +1,234 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package main + +import ( + "net/http" + + "github.com/gorilla/mux" + mmmodel "github.com/mattermost/mattermost/server/public/model" + + "github.com/mattermost/mattermost-plugin-docs/server/model" +) + +const ( + // maxDraftBodyBytes caps the autosave request, which carries the full document body. Body is JSON + // nested inside the request JSON, so its transport form can grow far beyond its decoded size once + // quotes, backslashes, and control characters are escaped (worst case ~6x for all-control-char + // input). Size the transport cap for that worst case plus headroom for the title/props/file-ids + // and JSON envelope; the decoded body stays capped at model.PageBodyMaxBytes during normalization. + draftBodyHeadroomBytes = 64 * 1024 + maxDraftBodyBytes = 6*model.PageBodyMaxBytes + draftBodyHeadroomBytes +) + +// handleUpdatePageDraft handles PATCH /api/v1/spaces/{space_id}/pages/{page_id}/draft +// It upserts the calling user's draft for the page. PATCH is deliberate: the request merges rather +// than replaces, so an omitted field means "unchanged", not "cleared" (autosave heartbeats carry +// only the fields the editor touched). parent_id: null omits the field; parent_id: "" explicitly +// clears it. +// +// For existing published pages, the first request creates the draft (open an edit session). The client +// must send the top-level base_edit_at field — the page's EditAt at the moment the user opened it — on +// every autosave, so a subsequent publish can detect a concurrent edit. It is not a props key. The +// stored baseline is write-once: whichever request establishes the draft row freezes it, and values +// sent on later autosaves are ignored (re-baselining requires discarding the draft and reopening). +// +// For new-page drafts (no page row yet), the draft must already exist via POST +// /spaces/{space_id}/drafts. This prevents a space member who learns another user's pending page +// ID from squatting on it via the autosave endpoint. +func (p *Plugin) handleUpdatePageDraft(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + spaceID := vars["space_id"] + pageID := vars["page_id"] + userID := userIDFromRequest(r) + + space, ok := p.requireSpaceMembership(w, spaceID, userID, false) + if !ok { + return + } + + var req struct { + ParentId *string `json:"parent_id"` + Title string `json:"title"` + Body string `json:"body"` + FileIds *mmmodel.StringArray `json:"file_ids"` + Props *mmmodel.StringInterface `json:"props"` + BaseEditAt *int64 `json:"base_edit_at"` + } + if !p.decodeJSONBody(w, r, maxDraftBodyBytes, &req, "handleUpdatePageDraft", false) { + return + } + + // base_edit_at nil → 0 (no baseline). A new-page draft legitimately has no baseline. For an + // existing published page the client must send base_edit_at on every autosave (see the handler doc + // above): omitting it on the first autosave of an edit session is rejected with 409 — an + // edit-session draft cannot be opened against a live page without a baseline. Props flows via + // the pointer below (like file_ids), so it is not set on the struct here. + var baseEditAt int64 + if req.BaseEditAt != nil { + baseEditAt = *req.BaseEditAt + } + draft := &model.Draft{ + UserId: userID, + SpaceId: spaceID, + PageId: pageID, + Title: req.Title, + Body: req.Body, + BaseEditAt: baseEditAt, + } + + // req.ParentId nil → preserve; pointer to "" → clear to root; pointer to ID → set parent. + // req.FileIds nil → preserve; pointer to [] → clear; pointer to [...] → replace. + // req.Props nil → preserve; non-nil → replace the whole map (an empty map clears all keys). + saved, appErr := p.service.UpdatePageDraft(draft, req.ParentId, req.FileIds, req.Props, space.ChannelId) + if appErr != nil { + p.writeAppError(w, appErr) + return + } + + writeJSON(w, http.StatusOK, saved) +} + +// handleGetPageDraft handles GET /api/v1/spaces/{space_id}/pages/{page_id}/draft +func (p *Plugin) handleGetPageDraft(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + spaceID := vars["space_id"] + pageID := vars["page_id"] + userID := userIDFromRequest(r) + + if _, ok := p.requireSpaceMembership(w, spaceID, userID, false); !ok { + return + } + + draft, appErr := p.service.GetPageDraft(userID, spaceID, pageID) + if appErr != nil { + p.writeAppError(w, appErr) + return + } + + writeJSON(w, http.StatusOK, draft) +} + +// handleDeletePageDraft handles DELETE /api/v1/spaces/{space_id}/pages/{page_id}/draft +func (p *Plugin) handleDeletePageDraft(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + spaceID := vars["space_id"] + pageID := vars["page_id"] + userID := userIDFromRequest(r) + + space, ok := p.requireSpaceMembership(w, spaceID, userID, false) + if !ok { + return + } + + if appErr := p.service.DeletePageDraft(userID, spaceID, pageID, space.ChannelId); appErr != nil { + p.writeAppError(w, appErr) + return + } + + writeStatusOK(w) +} + +// handleCreateSpaceDraft handles POST /api/v1/spaces/{space_id}/drafts +// It creates a new-page draft (no Pages row) with a server-generated page id, reserving the id +// before the page is published so a new page has a stable link from the start. +// +// The page id is generated server-side and returned only in the response body, so the caller +// cannot address the page until this call completes: subsequent requests (autosave to +// .../pages/{page_id}/draft, publish) all key off the returned id. A new page therefore costs +// one round-trip before it is editable; the id is not caller-supplied. +func (p *Plugin) handleCreateSpaceDraft(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + spaceID := vars["space_id"] + userID := userIDFromRequest(r) + + if _, ok := p.requireSpaceMembership(w, spaceID, userID, false); !ok { + return + } + + var req struct { + Title string `json:"title"` + ParentId string `json:"parent_id"` + } + if !p.decodeJSONBody(w, r, maxPageStructBodyBytes, &req, "handleCreateSpaceDraft", false) { + return + } + + saved, appErr := p.service.CreateSpaceDraft(userID, spaceID, req.Title, req.ParentId) + if appErr != nil { + p.writeAppError(w, appErr) + return + } + + writeJSON(w, http.StatusCreated, saved) +} + +// handlePublishPageDraft handles POST /api/v1/spaces/{space_id}/pages/{page_id}/draft/publish +// It publishes the calling user's draft as a page, creating it on first publish and updating it +// otherwise. +// +// The optimistic-lock baseline for an edit-publish is not a field on this request: it travels with +// the draft, stored in its write-once BaseEditAt column (sent as the top-level base_edit_at field on +// the autosave requests). This differs from the per-request base_edit_at on handleUpdatePage (and +// expected_update_at on handleMovePage) because a publish ships whatever the draft already holds +// rather than re-supplying a freshly-read baseline. Because that baseline is write-once, a 409 +// edit-conflict cannot be resolved by autosaving a newer base_edit_at: the client recovers by +// republishing with force, or by discarding the draft and reopening the edit session. +func (p *Plugin) handlePublishPageDraft(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + spaceID := vars["space_id"] + pageID := vars["page_id"] + userID := userIDFromRequest(r) + + if _, ok := p.requireSpaceMembership(w, spaceID, userID, false); !ok { + return + } + + // Optional body: {force: bool}. force=true overrides first-write-wins. + var req struct { + Force bool `json:"force"` + } + // allowEmptyBody=true: an absent body means force=false. A present-but-malformed body is still + // an error, so the return value must be checked. + if !p.decodeJSONBody(w, r, maxPageStructBodyBytes, &req, "handlePublishPageDraft", true) { + return + } + + page, wasCreated, appErr := p.service.PublishPageDraft(userID, spaceID, pageID, req.Force) + if appErr != nil { + if page != nil { + p.writeConflictWithPage(w, appErr, page) + return + } + p.writeAppError(w, appErr) + return + } + + status := http.StatusOK + if wasCreated { + status = http.StatusCreated + } + writeJSON(w, status, page) +} + +// handleGetPageDraftsForSpace handles GET /api/v1/spaces/{space_id}/drafts +// It lists the calling user's unpublished page drafts for the space. +func (p *Plugin) handleGetPageDraftsForSpace(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + spaceID := vars["space_id"] + userID := userIDFromRequest(r) + + if _, ok := p.requireSpaceMembership(w, spaceID, userID, false); !ok { + return + } + + page, perPage := pageParam(r), perPageParam(r) + drafts, hasMore, appErr := p.service.GetPageDraftsForSpace(userID, spaceID, page, perPage) + if appErr != nil { + p.writeAppError(w, appErr) + return + } + + writePaginatedJSON(w, drafts, page, perPage, hasMore) +} diff --git a/server/api_page_drafts_test.go b/server/api_page_drafts_test.go new file mode 100644 index 0000000..ccc765a --- /dev/null +++ b/server/api_page_drafts_test.go @@ -0,0 +1,534 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost/server/public/plugin" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + + "github.com/mattermost/mattermost-plugin-docs/server/app" + "github.com/mattermost/mattermost-plugin-docs/server/model" +) + +// TestHandler_DraftLifecycle drives the draft routes end to end over the real router: create a +// new-page draft, autosave it, read it back, publish it (201, reserved id preserved), and confirm +// the draft is gone afterwards. +func TestHandler_DraftLifecycle(t *testing.T) { + h := openTestPlugin(t, nil) + channelID := mmmodel.NewId() + space := seedSpace(t, h.store, channelID) + userID := mmmodel.NewId() + base := "/api/v1/spaces/" + space.Id + + rec := h.do(t, http.MethodPost, base+"/drafts", userID, map[string]any{"title": "New Doc"}) + require.Equal(t, http.StatusCreated, rec.Code) + var draft model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &draft)) + pageID := draft.PageId + require.True(t, mmmodel.IsValidId(pageID)) + + rec = h.do(t, http.MethodPatch, base+"/pages/"+pageID+"/draft", userID, map[string]any{ + "title": "New Doc", + "body": `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}`, + }) + require.Equal(t, http.StatusOK, rec.Code) + + rec = h.do(t, http.MethodGet, base+"/pages/"+pageID+"/draft", userID, nil) + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, rec.Body.String(), "hello") + + rec = h.do(t, http.MethodPost, base+"/pages/"+pageID+"/draft/publish", userID, nil) + require.Equal(t, http.StatusCreated, rec.Code) + var page model.Page + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &page)) + require.Equal(t, pageID, page.Id, "publish preserves the reserved draft id") + + rec = h.do(t, http.MethodGet, base+"/pages/"+pageID+"/draft", userID, nil) + require.Equal(t, http.StatusNotFound, rec.Code, "draft is deleted on publish") +} + +// TestHandler_ListSpaceDrafts covers the drafts listing: it is paginated like every other list +// endpoint, and it omits draft bodies so the listing does not ship whole documents. +func TestHandler_ListSpaceDrafts(t *testing.T) { + h := openTestPlugin(t, nil) + space := seedSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + base := "/api/v1/spaces/" + space.Id + + for _, title := range []string{"First", "Second", "Third"} { + rec := h.do(t, http.MethodPost, base+"/drafts", userID, map[string]any{"title": title}) + require.Equal(t, http.StatusCreated, rec.Code) + } + + var listed struct { + Items []map[string]any `json:"items"` + Page int `json:"page"` + PerPage int `json:"per_page"` + HasMore bool `json:"has_more"` + } + + rec := h.do(t, http.MethodGet, base+"/drafts?page=0&per_page=2", userID, nil) + require.Equal(t, http.StatusOK, rec.Code) + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &listed)) + require.Len(t, listed.Items, 2) + require.True(t, listed.HasMore, "a third draft remains") + require.NotContains(t, listed.Items[0], "body", "the listing must not ship draft bodies") + + rec = h.do(t, http.MethodGet, base+"/drafts?page=1&per_page=2", userID, nil) + require.Equal(t, http.StatusOK, rec.Code) + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &listed)) + require.Len(t, listed.Items, 1) + require.False(t, listed.HasMore, "the last page reports no more") +} + +// TestHandler_PublishMalformedBodyReturns400 is the regression for the unchecked-decode bug: a +// malformed publish body must be rejected with 400 and must NOT publish the draft. +func TestHandler_PublishMalformedBodyReturns400(t *testing.T) { + h := openTestPlugin(t, nil) + channelID := mmmodel.NewId() + space := seedSpace(t, h.store, channelID) + userID := mmmodel.NewId() + base := "/api/v1/spaces/" + space.Id + + rec := h.do(t, http.MethodPost, base+"/drafts", userID, map[string]any{"title": "Doc"}) + require.Equal(t, http.StatusCreated, rec.Code) + var draft model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &draft)) + + // Raw, truncated JSON body — h.do would marshal a value, so issue the request directly. + req := httptest.NewRequest(http.MethodPost, base+"/pages/"+draft.PageId+"/draft/publish", bytes.NewReader([]byte(`{"force":tru`))) + req.Header.Set("Mattermost-User-ID", userID) + malformed := httptest.NewRecorder() + h.plugin.ServeHTTP(&plugin.Context{}, malformed, req) + require.Equal(t, http.StatusBadRequest, malformed.Code) + + // The draft must still exist (publish did not run). + rec = h.do(t, http.MethodGet, base+"/pages/"+draft.PageId+"/draft", userID, nil) + require.Equal(t, http.StatusOK, rec.Code, "a malformed publish body must not publish or delete the draft") +} + +// TestHandler_UpdatePageDraftRequiresExistingDraft confirms the update-only guard: PATCH on a page id +// that has no existing draft must return 404 rather than silently creating one. +func TestHandler_UpdatePageDraftRequiresExistingDraft(t *testing.T) { + h := openTestPlugin(t, nil) + space := seedSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + // No draft has been created for this page id — the PATCH must be rejected. + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/pages/"+mmmodel.NewId()+"/draft", userID, map[string]any{ + "title": "ghost", + }) + require.Equal(t, http.StatusNotFound, rec.Code) +} + +// TestHandler_UpdatePageDraftClearsParentWhenParentIdIsEmptyString covers the null-vs-empty +// distinction: sending parent_id: "" in the PATCH body must clear an existing parent (set it to +// root), while omitting parent_id entirely must leave the parent unchanged. +func TestHandler_UpdatePageDraftClearsParentWhenParentIdIsEmptyString(t *testing.T) { + h := openTestPlugin(t, nil) + space := seedSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + base := "/api/v1/spaces/" + space.Id + + // Create the parent draft. + rec := h.do(t, http.MethodPost, base+"/drafts", userID, map[string]any{"title": "Parent"}) + require.Equal(t, http.StatusCreated, rec.Code) + var parentDraft model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &parentDraft)) + + // Create the child draft under the parent. + rec = h.do(t, http.MethodPost, base+"/drafts", userID, map[string]any{ + "title": "Child", + "parent_id": parentDraft.PageId, + }) + require.Equal(t, http.StatusCreated, rec.Code) + var childDraft model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &childDraft)) + require.Equal(t, parentDraft.PageId, childDraft.ParentId) + + // PATCH with parent_id omitted must preserve the existing parent. + rec = h.do(t, http.MethodPatch, base+"/pages/"+childDraft.PageId+"/draft", userID, map[string]any{ + "title": "Child updated", + }) + require.Equal(t, http.StatusOK, rec.Code) + var saved model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &saved)) + require.Equal(t, parentDraft.PageId, saved.ParentId, "omitting parent_id must preserve the existing parent") + + // PATCH with parent_id: "" must clear the parent to root. + rec = h.do(t, http.MethodPatch, base+"/pages/"+childDraft.PageId+"/draft", userID, map[string]any{ + "parent_id": "", + }) + require.Equal(t, http.StatusOK, rec.Code) + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &saved)) + require.Empty(t, saved.ParentId, `parent_id: "" must clear the parent`) +} + +// TestHandler_UpdatePageDraftCreatesForExistingPage drives the existing-page edit flow: publish a +// new-page draft to get a live page, then open an edit session by PATCH .../draft with the page's +// EditAt baseline in the top-level base_edit_at field. Verifies the draft is created, autosave +// updates it, publish succeeds (edit path → 200), and the draft is gone afterwards. +func TestHandler_UpdatePageDraftCreatesForExistingPage(t *testing.T) { + h := openTestPlugin(t, nil) + channelID := mmmodel.NewId() + space := seedSpace(t, h.store, channelID) + userID := mmmodel.NewId() + base := "/api/v1/spaces/" + space.Id + + // Step 1: create a new-page draft and publish it to get a live page. + rec := h.do(t, http.MethodPost, base+"/drafts", userID, map[string]any{"title": "Original"}) + require.Equal(t, http.StatusCreated, rec.Code) + var draft model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &draft)) + pageID := draft.PageId + + rec = h.do(t, http.MethodPost, base+"/pages/"+pageID+"/draft/publish", userID, nil) + require.Equal(t, http.StatusCreated, rec.Code) + var page model.Page + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &page)) + require.Equal(t, pageID, page.Id) + + // Step 2: open an edit session — first PATCH creates the draft for an existing page. + rec = h.do(t, http.MethodPatch, base+"/pages/"+pageID+"/draft", userID, map[string]any{ + "title": "Original", + "base_edit_at": page.EditAt, + }) + require.Equal(t, http.StatusOK, rec.Code) + var editDraft model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &editDraft)) + require.Equal(t, pageID, editDraft.PageId) + require.Equal(t, page.EditAt, editDraft.BaseEditAt, "draft must carry the base_edit_at baseline so publish can detect conflicts") + + // GET must round-trip the same baseline. + rec = h.do(t, http.MethodGet, base+"/pages/"+pageID+"/draft", userID, nil) + require.Equal(t, http.StatusOK, rec.Code) + var fetched model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &fetched)) + require.Equal(t, page.EditAt, fetched.BaseEditAt, "GET must return the base_edit_at baseline that was set via PATCH") + + // Step 3: autosave new content. + rec = h.do(t, http.MethodPatch, base+"/pages/"+pageID+"/draft", userID, map[string]any{ + "title": "Edited", + "body": `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"updated"}]}]}`, + }) + require.Equal(t, http.StatusOK, rec.Code) + + // Step 4: publish the edit — edit path returns 200 (not 201). + rec = h.do(t, http.MethodPost, base+"/pages/"+pageID+"/draft/publish", userID, nil) + require.Equal(t, http.StatusOK, rec.Code, "edit-path publish must return 200") + var updated model.Page + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated)) + require.Equal(t, pageID, updated.Id) + require.Equal(t, "Edited", updated.Title) + + // Step 5: the draft must be gone after publish. + rec = h.do(t, http.MethodGet, base+"/pages/"+pageID+"/draft", userID, nil) + require.Equal(t, http.StatusNotFound, rec.Code, "draft must be deleted on publish") +} + +// TestHandler_PublishConflict409 covers the stale-baseline 409: when user B publishes an edit +// that advances the page's EditAt, user A's publish against the original baseline must return 409. +func TestHandler_PublishConflict409(t *testing.T) { + h := openTestPlugin(t, nil) + channelID := mmmodel.NewId() + space := seedSpace(t, h.store, channelID) + userA := mmmodel.NewId() + userB := mmmodel.NewId() + base := "/api/v1/spaces/" + space.Id + + // Create a new-page draft and publish it to get a live page. + rec := h.do(t, http.MethodPost, base+"/drafts", userA, map[string]any{"title": "Shared Doc"}) + require.Equal(t, http.StatusCreated, rec.Code) + var draft model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &draft)) + pageID := draft.PageId + + rec = h.do(t, http.MethodPost, base+"/pages/"+pageID+"/draft/publish", userA, nil) + require.Equal(t, http.StatusCreated, rec.Code) + var page model.Page + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &page)) + editAt := page.EditAt + + // User A and user B both open edit sessions against the same baseline. + rec = h.do(t, http.MethodPatch, base+"/pages/"+pageID+"/draft", userA, map[string]any{ + "title": "Edit by A", + "base_edit_at": editAt, + }) + require.Equal(t, http.StatusOK, rec.Code) + + rec = h.do(t, http.MethodPatch, base+"/pages/"+pageID+"/draft", userB, map[string]any{ + "title": "Edit by B", + "base_edit_at": editAt, + }) + require.Equal(t, http.StatusOK, rec.Code) + + // User B publishes first, advancing the page's EditAt. + rec = h.do(t, http.MethodPost, base+"/pages/"+pageID+"/draft/publish", userB, nil) + require.Equal(t, http.StatusOK, rec.Code, "user B edit-path publish must succeed") + + // User A publishes against the now-stale baseline — must get 409. + rec = h.do(t, http.MethodPost, base+"/pages/"+pageID+"/draft/publish", userA, nil) + require.Equal(t, http.StatusConflict, rec.Code, "stale baseline must return 409 Conflict") + + // The 409 body carries the current server page so the client can diff and re-baseline without a + // follow-up read: it reflects user B's winning edit and the advanced EditAt. + var conflict struct { + Error *mmmodel.AppError `json:"error"` + CurrentPage *model.Page `json:"current_page"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &conflict)) + require.NotNil(t, conflict.Error, "conflict body must include the error") + require.Empty(t, conflict.Error.DetailedError, "the conflict body must not carry internal error detail") + require.NotNil(t, conflict.CurrentPage, "conflict body must include the current server page") + require.Equal(t, "Edit by B", conflict.CurrentPage.Title, "current page must reflect the winning edit") + require.Greater(t, conflict.CurrentPage.EditAt, editAt, "current page must carry the advanced baseline") +} + +// TestHandler_DeletePageDraft verifies the DELETE endpoint: 200 on success, draft is gone +// afterwards, and 404 when no draft exists. +func TestHandler_DeletePageDraft(t *testing.T) { + h := openTestPlugin(t, nil) + space := seedSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + base := "/api/v1/spaces/" + space.Id + + // Create a new-page draft. + rec := h.do(t, http.MethodPost, base+"/drafts", userID, map[string]any{"title": "To Delete"}) + require.Equal(t, http.StatusCreated, rec.Code) + var draft model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &draft)) + pageID := draft.PageId + + // DELETE the draft — must return 200. + rec = h.do(t, http.MethodDelete, base+"/pages/"+pageID+"/draft", userID, nil) + require.Equal(t, http.StatusOK, rec.Code) + + // GET after delete must return 404. + rec = h.do(t, http.MethodGet, base+"/pages/"+pageID+"/draft", userID, nil) + require.Equal(t, http.StatusNotFound, rec.Code, "draft must be gone after delete") + + // DELETE again on a missing draft must return 404. + rec = h.do(t, http.MethodDelete, base+"/pages/"+pageID+"/draft", userID, nil) + require.Equal(t, http.StatusNotFound, rec.Code, "deleting a non-existent draft must return 404") +} + +// TestHandler_ActiveEditorsWrongSpaceReturns404 is the regression for the cross-space presence +// leak: a page in one space must not be reachable through another space's active-editors route. +func TestHandler_ActiveEditorsWrongSpaceReturns404(t *testing.T) { + h := openTestPlugin(t, nil) + spaceA := seedSpace(t, h.store, mmmodel.NewId()) + spaceB := seedSpace(t, h.store, mmmodel.NewId()) + pageInB := seedPage(t, h.store, spaceB.Id, spaceB.ChannelId, "") + userID := mmmodel.NewId() + + rec := h.do(t, http.MethodGet, "/api/v1/spaces/"+spaceA.Id+"/pages/"+pageInB.Id+"/active-editors", userID, nil) + require.Equal(t, http.StatusNotFound, rec.Code, "a page must not resolve through another space's route") + + rec = h.do(t, http.MethodGet, "/api/v1/spaces/"+spaceB.Id+"/pages/"+pageInB.Id+"/active-editors", userID, nil) + require.Equal(t, http.StatusOK, rec.Code) +} + +// TestHandler_ActiveEditorsResponseBody verifies the JSON payload of the active-editors endpoint: +// an empty list when no draft is open, and the editor's user ID when they have an active draft. +func TestHandler_ActiveEditorsResponseBody(t *testing.T) { + h := openTestPlugin(t, nil) + space := seedSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + base := "/api/v1/spaces/" + space.Id + + // Publish a new-page draft to get a live page. + rec := h.do(t, http.MethodPost, base+"/drafts", userID, map[string]any{"title": "Presence Test"}) + require.Equal(t, http.StatusCreated, rec.Code) + var draft model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &draft)) + pageID := draft.PageId + + rec = h.do(t, http.MethodPost, base+"/pages/"+pageID+"/draft/publish", userID, nil) + require.Equal(t, http.StatusCreated, rec.Code) + var page model.Page + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &page)) + + // No edit draft open yet — active_editors must be an empty list, not null. The response also + // carries snapshot_at and active_timeout_ms, mirroring the page_presence_updated WS payload so a client + // resyncing over REST can reason about snapshot staleness the same way. + rec = h.do(t, http.MethodGet, base+"/pages/"+pageID+"/active-editors", userID, nil) + require.Equal(t, http.StatusOK, rec.Code) + var resp struct { + ActiveEditors []string `json:"active_editors"` + SnapshotAt int64 `json:"snapshot_at"` + ActiveTimeoutMs int64 `json:"active_timeout_ms"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.NotNil(t, resp.ActiveEditors) + require.Empty(t, resp.ActiveEditors) + require.Positive(t, resp.SnapshotAt, "response must carry the snapshot timestamp") + require.Equal(t, app.ActiveEditorTimeoutMs, resp.ActiveTimeoutMs, "response must carry the active-editor window") + + // Open an edit draft — the user must now appear as an active editor. + rec = h.do(t, http.MethodPatch, base+"/pages/"+pageID+"/draft", userID, map[string]any{ + "title": "Presence Test", + "base_edit_at": page.EditAt, + }) + require.Equal(t, http.StatusOK, rec.Code) + + rec = h.do(t, http.MethodGet, base+"/pages/"+pageID+"/active-editors", userID, nil) + require.Equal(t, http.StatusOK, rec.Code) + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Contains(t, resp.ActiveEditors, userID, "user with an open edit draft must appear as an active editor") +} + +// TestHandler_UpdatePageDraftPropsPointerIntent covers the props pointer-intent semantics over the +// HTTP boundary: an absent props field preserves the stored map, an explicit null behaves the same +// as absent, an empty object clears all keys, and a populated object replaces the whole map. +func TestHandler_UpdatePageDraftPropsPointerIntent(t *testing.T) { + h := openTestPlugin(t, nil) + space := seedSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + base := "/api/v1/spaces/" + space.Id + + rec := h.do(t, http.MethodPost, base+"/drafts", userID, map[string]any{"title": "Props Test"}) + require.Equal(t, http.StatusCreated, rec.Code) + var draft model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &draft)) + draftPath := base + "/pages/" + draft.PageId + "/draft" + + // Populate props. + rec = h.do(t, http.MethodPatch, draftPath, userID, map[string]any{ + "title": "Props Test", + "props": map[string]any{"color": "red"}, + }) + require.Equal(t, http.StatusOK, rec.Code) + var populated model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &populated)) + require.Equal(t, "red", populated.Props["color"]) + + // props absent must preserve the existing map. Each step below unmarshals into a fresh Draft + // value: reusing one across json.Unmarshal calls would merge into the existing non-nil map + // instead of replacing it, masking whether the server actually cleared/replaced the props. + rec = h.do(t, http.MethodPatch, draftPath, userID, map[string]any{ + "title": "Props Test Updated", + }) + require.Equal(t, http.StatusOK, rec.Code) + var afterAbsent model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &afterAbsent)) + require.Equal(t, "red", afterAbsent.Props["color"], "omitting props must preserve the existing map") + + // props: null must also preserve the existing map — it unmarshals to a nil pointer, same as + // omitting the field entirely. + rec = h.do(t, http.MethodPatch, draftPath, userID, map[string]any{ + "props": nil, + }) + require.Equal(t, http.StatusOK, rec.Code) + var afterNull model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &afterNull)) + require.Equal(t, "red", afterNull.Props["color"], "props: null must preserve the existing map, same as omitting it") + + // props: {} must clear all keys. + rec = h.do(t, http.MethodPatch, draftPath, userID, map[string]any{ + "props": map[string]any{}, + }) + require.Equal(t, http.StatusOK, rec.Code) + var afterClear model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &afterClear)) + require.Empty(t, afterClear.Props, "props: {} must clear all keys") + + // props populated again must replace the (now-empty) map. + rec = h.do(t, http.MethodPatch, draftPath, userID, map[string]any{ + "props": map[string]any{"size": "large"}, + }) + require.Equal(t, http.StatusOK, rec.Code) + var afterReplace model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &afterReplace)) + require.Equal(t, "large", afterReplace.Props["size"]) + require.NotContains(t, afterReplace.Props, "color", "props replace must drop keys not present in the new map") +} + +// TestHandler_UpdatePageDraftPropsBaselineNoLongerHonored covers the removal of the old +// props.original_page_edit_at baseline convention: placing the value only under that props key must +// NOT establish an optimistic-lock baseline. Since a baseline-less edit-open against a live page is +// itself rejected (the store refuses to create a baseline-less edit draft for an existing page — see +// TestPublishRejectsNoBaselineOnExistingPageEdit in server/app/page_draft_test.go), the PATCH must +// fail with the same conflict the client would get if it had supplied no baseline at all. +func TestHandler_UpdatePageDraftPropsBaselineNoLongerHonored(t *testing.T) { + h := openTestPlugin(t, nil) + channelID := mmmodel.NewId() + space := seedSpace(t, h.store, channelID) + userID := mmmodel.NewId() + base := "/api/v1/spaces/" + space.Id + + rec := h.do(t, http.MethodPost, base+"/drafts", userID, map[string]any{"title": "Doc"}) + require.Equal(t, http.StatusCreated, rec.Code) + var draft model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &draft)) + pageID := draft.PageId + + rec = h.do(t, http.MethodPost, base+"/pages/"+pageID+"/draft/publish", userID, nil) + require.Equal(t, http.StatusCreated, rec.Code) + var page model.Page + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &page)) + + // Open an edit session but put the baseline only under the old props key, not the top-level + // base_edit_at field. This must be rejected with 409, exactly as if base_edit_at had been + // omitted entirely (proving the props key carries no special meaning any more). + rec = h.do(t, http.MethodPatch, base+"/pages/"+pageID+"/draft", userID, map[string]any{ + "title": "Edited", + "props": map[string]any{"original_page_edit_at": page.EditAt}, + }) + require.Equal(t, http.StatusConflict, rec.Code, "props.original_page_edit_at must not be honored as a baseline") +} + +// TestHandler_PublishForceOverHTTP exercises the {"force": true} publish body through the real +// router: a stale-baseline publish that 409s without it succeeds when the request carries force. +func TestHandler_PublishForceOverHTTP(t *testing.T) { + h := openTestPlugin(t, nil) + space := seedSpace(t, h.store, mmmodel.NewId()) + userA := mmmodel.NewId() + userB := mmmodel.NewId() + base := "/api/v1/spaces/" + space.Id + + // Create a new-page draft and publish it to get a live page. + rec := h.do(t, http.MethodPost, base+"/drafts", userA, map[string]any{"title": "Shared Doc"}) + require.Equal(t, http.StatusCreated, rec.Code) + var draft model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &draft)) + pageID := draft.PageId + + rec = h.do(t, http.MethodPost, base+"/pages/"+pageID+"/draft/publish", userA, nil) + require.Equal(t, http.StatusCreated, rec.Code) + var page model.Page + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &page)) + editAt := page.EditAt + + // Both users open edit sessions at the same baseline; B publishes first, staling A's baseline. + rec = h.do(t, http.MethodPatch, base+"/pages/"+pageID+"/draft", userA, map[string]any{ + "title": "Edit by A", + "base_edit_at": editAt, + }) + require.Equal(t, http.StatusOK, rec.Code) + rec = h.do(t, http.MethodPatch, base+"/pages/"+pageID+"/draft", userB, map[string]any{ + "title": "Edit by B", + "base_edit_at": editAt, + }) + require.Equal(t, http.StatusOK, rec.Code) + rec = h.do(t, http.MethodPost, base+"/pages/"+pageID+"/draft/publish", userB, nil) + require.Equal(t, http.StatusOK, rec.Code) + + // Without force the stale baseline 409s; with {"force": true} in the HTTP body it publishes. + rec = h.do(t, http.MethodPost, base+"/pages/"+pageID+"/draft/publish", userA, nil) + require.Equal(t, http.StatusConflict, rec.Code) + rec = h.do(t, http.MethodPost, base+"/pages/"+pageID+"/draft/publish", userA, map[string]any{"force": true}) + require.Equal(t, http.StatusOK, rec.Code) + var published model.Page + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &published)) + require.Equal(t, "Edit by A", published.Title) +} diff --git a/server/api_page_presence.go b/server/api_page_presence.go new file mode 100644 index 0000000..a04c050 --- /dev/null +++ b/server/api_page_presence.go @@ -0,0 +1,32 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package main + +import ( + "net/http" + + "github.com/gorilla/mux" +) + +// handleGetPageActiveEditors handles +// GET /api/v1/spaces/{space_id}/pages/{page_id}/active-editors +// Returns the active-editors snapshot for the page (active_editors, snapshot_at, active_timeout_ms). +func (p *Plugin) handleGetPageActiveEditors(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + spaceID := vars["space_id"] + pageID := vars["page_id"] + userID := userIDFromRequest(r) + + if _, ok := p.requireSpaceMembership(w, spaceID, userID, false); !ok { + return + } + + snapshot, appErr := p.service.GetPageActiveEditors(pageID, spaceID) + if appErr != nil { + p.writeAppError(w, appErr) + return + } + + writeJSON(w, http.StatusOK, snapshot) +} diff --git a/server/app/page.go b/server/app/page.go index c4173d2..77c703d 100644 --- a/server/app/page.go +++ b/server/app/page.go @@ -14,13 +14,9 @@ import ( "github.com/mattermost/mattermost-plugin-docs/server/store" ) -// MaxPageDepth is the page hierarchy depth limit (root is depth 1). -// store.MaxPageHierarchyDepth (50) is a separate, larger bound used by descendant/ancestor reads. -const MaxPageDepth = 10 - // CreatePage creates a new page in spaceID. ChannelId is derived from the space, not supplied by the caller. // The page ID is always server-generated; callers must not supply one. -func (s *Service) CreatePage(spaceID, parentID, title, body, searchText, userID string) (*model.Page, *mmmodel.AppError) { +func (s *Service) CreatePage(spaceID, parentID, title, body, userID string) (*model.Page, *mmmodel.AppError) { if !mmmodel.IsValidId(spaceID) { return nil, mmmodel.NewAppError("CreatePage", "app.page.create.invalid_space_id.app_error", nil, "", http.StatusBadRequest) } @@ -31,10 +27,11 @@ func (s *Service) CreatePage(spaceID, parentID, title, body, searchText, userID if titleErr != nil { return nil, titleErr } - // SearchText is the body's plain-text projection, so it makes no sense without a body - // (matches the update path's rule). - if searchText != "" && body == "" { - return nil, mmmodel.NewAppError("CreatePage", "app.page.create.search_text_without_content.app_error", nil, "", http.StatusBadRequest) + // Validate and normalize the TipTap body and derive SearchText from it. SearchText is the body's + // server-derived projection, so it is never taken from the caller. + normBody, normSearch, contentErr := normalizePageContent("CreatePage", body) + if contentErr != nil { + return nil, contentErr } // Space existence and liveness are validated by store.CreatePage itself (surfaced as @@ -49,21 +46,20 @@ func (s *Service) CreatePage(spaceID, parentID, title, body, searchText, userID } } - // Body is stored as-is (TipTap validation/normalization and SearchText deferred). page := &model.Page{ SpaceId: spaceID, ParentId: parentID, Type: model.PageTypePage, Title: title, - Body: body, - SearchText: searchText, + Body: normBody, + SearchText: normSearch, UserId: userID, LastModifiedBy: userID, } s.log.Debug("Creating page", "space_id", spaceID, "parent_id", parentID, "user_id", userID) - created, storeErr := s.store.CreatePage(page, MaxPageDepth) + created, storeErr := s.store.CreatePage(page, model.MaxPageDepth) if storeErr != nil { if store.IsErrNotFound(storeErr) { // The space is missing or soft-deleted. @@ -99,6 +95,8 @@ func (s *Service) GetPage(pageID string) (*model.Page, *mmmodel.AppError) { // UpdatePage patches a page, optimistic-locked on baseEditAt; a nil baseEditAt without force is // rejected. spaceID scopes the write: a page moved to another space since the caller's last // check returns not-found instead of updating the wrong copy. +// On a 409 the returned page is the current server page (nil if the re-read failed), so the caller +// can re-baseline without a follow-up read; on every other error it is nil. func (s *Service) UpdatePage(pageID, spaceID string, patch *model.PagePatch, baseEditAt *int64, force bool, userID string) (*model.Page, *mmmodel.AppError) { if !mmmodel.IsValidId(pageID) { return nil, mmmodel.NewAppError("UpdatePage", "app.page.update.invalid_id.app_error", nil, "", http.StatusBadRequest) @@ -112,6 +110,11 @@ func (s *Service) UpdatePage(pageID, spaceID string, patch *model.PagePatch, bas if appErr := requireBaseline("UpdatePage", "base_edit_at", baseEditAt, force); appErr != nil { return nil, appErr } + // Validate/normalize the TipTap body (and recompute SearchText) before patch validation, so a + // body-only patch is valid and a direct edit is sanitized on the same content path as publish. + if contentErr := normalizePatchContent("UpdatePage", patch); contentErr != nil { + return nil, contentErr + } if validErr := normalizeAndValidatePagePatch("UpdatePage", patch); validErr != nil { return nil, validErr } @@ -137,7 +140,9 @@ func (s *Service) UpdatePage(pageID, spaceID string, patch *model.PagePatch, bas return nil, mmmodel.NewAppError("UpdatePage", "app.page.update.conflict.app_error", nil, "conflict", http.StatusConflict).Wrap(storeErr) } - return nil, mmmodel.NewAppError("UpdatePage", "app.page.update.conflict.app_error", + // The page travels back alongside the conflict so the caller can re-baseline without a + // follow-up read, matching PublishPageDraft's edit-conflict contract. + return fresh, mmmodel.NewAppError("UpdatePage", "app.page.update.conflict.app_error", map[string]any{"ModifiedBy": fresh.LastModifiedBy, "ModifiedAt": fresh.EditAt}, "conflict", http.StatusConflict).Wrap(storeErr) } @@ -197,7 +202,7 @@ func (s *Service) RestorePage(pageID, spaceID, userID string) (*model.Page, *mmm return nil, mmmodel.NewAppError("RestorePage", "app.page.restore.invalid_user_id.app_error", nil, "", http.StatusBadRequest) } s.log.Debug("Restoring page", "page_id", pageID, "user_id", userID) - restored, restoreErr := s.store.RestorePage(pageID, spaceID, userID, MaxPageDepth) + restored, restoreErr := s.store.RestorePage(pageID, spaceID, userID, model.MaxPageDepth) if restoreErr != nil { if appErr := restoreReasonAppError(restoreErr, map[string]*mmmodel.AppError{ store.ReasonNotRestorable: mmmodel.NewAppError("RestorePage", "app.page.restore.not_restorable.app_error", nil, "", http.StatusBadRequest), @@ -268,7 +273,7 @@ func (s *Service) DuplicatePage(pageID string, sourceSpace *model.Space, userID s.log.Debug("Duplicating page", "page_id", pageID, "source_space_id", sourceSpace.Id, "user_id", userID) - created, createErr := s.store.CreatePageSubtree(pages, MaxPageDepth) + created, createErr := s.store.CreatePageSubtree(pages, model.MaxPageDepth) if createErr != nil { if store.IsErrNotFound(createErr) { return nil, mmmodel.NewAppError("DuplicatePage", "app.page.duplicate.dest_not_found.app_error", nil, "", http.StatusNotFound).Wrap(createErr) @@ -277,7 +282,7 @@ func (s *Service) DuplicatePage(pageID string, sourceSpace *model.Space, userID // plain placement-depth breach uses storeAppError's operation-neutral key. var limErr *store.ErrLimitExceeded if errors.As(createErr, &limErr) && limErr.Reason == store.ReasonSubtreeMaxDepthExceeded { - return nil, mmmodel.NewAppError("DuplicatePage", "app.page.duplicate.subtree_max_depth_exceeded.app_error", map[string]any{"MaxDepth": MaxPageDepth}, "", http.StatusBadRequest).Wrap(createErr) + return nil, mmmodel.NewAppError("DuplicatePage", "app.page.duplicate.subtree_max_depth_exceeded.app_error", map[string]any{"MaxDepth": model.MaxPageDepth}, "", http.StatusBadRequest).Wrap(createErr) } return nil, storeAppError("DuplicatePage", createErr) } @@ -299,37 +304,30 @@ func buildDuplicatePages(source *model.Page, descendants []*model.Page, destSpac rootID := mmmodel.NewId() idMap := map[string]string{source.Id: rootID} pages := make([]*model.Page, 0, 1+len(descendants)) - pages = append(pages, &model.Page{ - Id: rootID, - SpaceId: destSpaceID, - ParentId: destParentID, - Type: source.Type, - Title: copyTitle(source.Title), - Body: source.Body, - SearchText: source.SearchText, - Props: maps.Clone(source.Props), - UserId: userID, - LastModifiedBy: userID, - }) + pages = append(pages, clonePageFields(source, rootID, destSpaceID, destParentID, copyTitle(source.Title), userID)) for _, d := range descendants { newID := mmmodel.NewId() - pages = append(pages, &model.Page{ - Id: newID, - SpaceId: destSpaceID, - ParentId: idMap[d.ParentId], - Type: d.Type, - Title: d.Title, - Body: d.Body, - SearchText: d.SearchText, - Props: maps.Clone(d.Props), - UserId: userID, - LastModifiedBy: userID, - }) + pages = append(pages, clonePageFields(d, newID, destSpaceID, idMap[d.ParentId], d.Title, userID)) idMap[d.Id] = newID } return pages } +func clonePageFields(src *model.Page, id, spaceID, parentID, title, userID string) *model.Page { + return &model.Page{ + Id: id, + SpaceId: spaceID, + ParentId: parentID, + Type: src.Type, + Title: title, + Body: src.Body, + SearchText: src.SearchText, + Props: maps.Clone(src.Props), + UserId: userID, + LastModifiedBy: userID, + } +} + // copyTitle prefixes "Copy of " and truncates to the page-title cap so the duplicate's title // always passes CreatePage validation. func copyTitle(original string) string { diff --git a/server/app/page_content.go b/server/app/page_content.go new file mode 100644 index 0000000..56f4ca6 --- /dev/null +++ b/server/app/page_content.go @@ -0,0 +1,160 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "encoding/json" + "net/http" + "strings" + "unicode" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-plugin-docs/server/model" +) + +// normalizePageContent normalizes a page body — validating it as TipTap content and deriving +// SearchText from it. Returns a 400 AppError when the body is not valid TipTap content. +// +// An empty body ("") is returned as-is, representing "no content"; the draft path instead seeds new +// pages with model.EmptyTipTapJSON (a rendered-empty document). These are two DISTINCT empty +// representations, and consumers may assign them different meaning: the publish path treats "" as +// "field not sent, preserve the existing page body" and EmptyTipTapJSON as "explicitly cleared". +func normalizePageContent(where, body string) (normBody, searchText string, appErr *mmmodel.AppError) { + normBody, doc, empty, err := normalizeContent(body) + if err != nil { + return "", "", wrapContentError(where, err) + } + if !empty { + searchText = model.BuildSearchText(doc) + } + return normBody, searchText, nil +} + +// wrapContentError renders a content validation/normalization failure as the shared invalid-content +// AppError, so the error key and status stay defined in one place across content callers. +func wrapContentError(where string, err error) *mmmodel.AppError { + return mmmodel.NewAppError(where, "app.page.invalid_content.app_error", nil, "", http.StatusBadRequest).Wrap(err) +} + +// normalizePatchContent normalizes a page patch's Body in place when present, recomputing +// SearchText from it (SearchText is the body's server-derived projection). A patch that sets Body +// has its caller-supplied SearchText overwritten by the derived value; a patch that does not touch +// Body is left unchanged (a SearchText-without-Body patch is rejected later by PagePatch.IsValid). A +// nil patch is a no-op, matching PagePatch's own contract. +func normalizePatchContent(where string, patch *model.PagePatch) *mmmodel.AppError { + if patch == nil || patch.Body == nil { + return nil + } + normBody, searchText, appErr := normalizePageContent(where, *patch.Body) + if appErr != nil { + return appErr + } + patch.Body = &normBody + patch.SearchText = &searchText + return nil +} + +// normalizeContentBody normalizes a body without deriving SearchText, for callers (draft autosave) +// that store only the body. It shares normalizeContent with normalizePageContent but discards the +// parsed doc. +func normalizeContentBody(where, body string) (string, *mmmodel.AppError) { + normBody, _, _, err := normalizeContent(body) + if err != nil { + return "", wrapContentError(where, err) + } + return normBody, nil +} + +// normalizeContent normalizes TipTap/plain-text page content to its stored body form, returning the +// parsed doc and empty flag so callers decide whether to derive SearchText (normalizePageContent +// does; normalizeContentBody skips it). Returns a raw error; callers wrap it via wrapContentError. +// An empty content string ("") is returned as-is (no-op), with a zero-value doc. +func normalizeContent(content string) (normBody string, doc model.TipTapDocument, empty bool, err error) { + doc, empty, err = normalizeContentToDoc(content) + if err != nil { + return "", model.TipTapDocument{}, false, err + } + if empty { + return content, doc, true, nil + } + normBody, err = marshalTipTapDoc(doc) + if err != nil { + return "", model.TipTapDocument{}, false, err + } + return normBody, doc, false, nil +} + +// normalizeContentToDoc normalizes content and returns its TipTap document, validating it and +// rejecting invalid TipTap. empty is true for an empty content string ("") — a no-op the caller +// returns as-is. +func normalizeContentToDoc(content string) (doc model.TipTapDocument, empty bool, err error) { + if content == "" { + return model.TipTapDocument{}, true, nil + } + // Reject over-limit content up front for both input forms: ParseTipTapDocument enforces the + // same cap only on the JSON path. + if len(content) > model.PageBodyMaxBytes { + return model.TipTapDocument{}, false, errors.New("content exceeds the maximum body size") + } + // Treat the body as TipTap only when it is actually valid JSON: a plain-text body that merely + // starts with "{" (e.g. "{shrug}") is not JSON and must be wrapped, not rejected. A body that is + // valid JSON but not a "doc" is a genuine content error and ParseTipTapDocument rejects it. + idx := strings.IndexFunc(content, func(r rune) bool { return !unicode.IsSpace(r) }) + if idx >= 0 && content[idx] == '{' { + parsed, parseErr := model.ParseTipTapDocument(content) + if parseErr == nil { + return parsed, false, nil + } + var syntaxErr *json.SyntaxError + if !errors.As(parseErr, &syntaxErr) { + return model.TipTapDocument{}, false, parseErr + } + // SyntaxError → not valid JSON → fall through to plain-text wrapping. + } + // Non-JSON content: wrap in a minimal TipTap doc. + wrapped, err := convertPlainTextToTipTap(content) + if err != nil { + return model.TipTapDocument{}, false, err + } + return wrapped, false, nil +} + +// marshalTipTapDoc serializes a TipTapDocument to its stored JSON form. +func marshalTipTapDoc(doc model.TipTapDocument) (string, error) { + sanitized, err := json.Marshal(doc) + if err != nil { + return "", err + } + return string(sanitized), nil +} + +// maxPlainTextParagraphs caps the number of paragraph nodes produced when converting plain text +// to a TipTap document. convertPlainTextToTipTap splits on newlines and turns each line into its +// own paragraph node (a map[string]any), so a newline-only body at PageBodyMaxBytes would +// otherwise build ~2M such maps before the post-normalization body-size check could reject it. +const maxPlainTextParagraphs = 10_000 + +// convertPlainTextToTipTap wraps plain text in a minimal TipTap document. Returns an error when +// the input has more than maxPlainTextParagraphs newlines so that over-limit bodies are rejected +// with a clear error rather than silently truncated. +func convertPlainTextToTipTap(plainText string) (model.TipTapDocument, error) { + paragraphs := strings.SplitN(plainText, "\n", maxPlainTextParagraphs+1) + if len(paragraphs) > maxPlainTextParagraphs { + return model.TipTapDocument{}, errors.New("plain text body has too many paragraphs") + } + nodes := make([]map[string]any, 0, len(paragraphs)) + for _, para := range paragraphs { + node := map[string]any{"type": "paragraph"} + if strings.TrimSpace(para) != "" { + node["content"] = []any{map[string]any{"type": "text", "text": para}} + } + nodes = append(nodes, node) + } + return model.TipTapDocument{ + Type: model.TipTapDocType, + Content: nodes, + }, nil +} diff --git a/server/app/page_content_test.go b/server/app/page_content_test.go new file mode 100644 index 0000000..6c8da30 --- /dev/null +++ b/server/app/page_content_test.go @@ -0,0 +1,168 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "net/http" + "strings" + "testing" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-plugin-docs/server/model" +) + +func TestNormalizePageContent(t *testing.T) { + t.Run("empty content is a no-op", func(t *testing.T) { + body, search, appErr := normalizePageContent("Test", "") + require.Nil(t, appErr) + require.Equal(t, "", body) + require.Equal(t, "", search) + }) + + t.Run("TipTap JSON is normalized and search text derived", func(t *testing.T) { + body, search, appErr := normalizePageContent("Test", `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello world"}]}]}`) + require.Nil(t, appErr) + require.Contains(t, body, "hello world") + require.Equal(t, "hello world", search) + }) + + t.Run("plain text is wrapped into a TipTap doc", func(t *testing.T) { + body, search, appErr := normalizePageContent("Test", "just text") + require.Nil(t, appErr) + require.True(t, strings.HasPrefix(body, `{"type":"doc"`), "plain text should be wrapped: %s", body) + require.Contains(t, body, "just text") + require.Equal(t, "just text", search) + }) + + t.Run("malformed TipTap JSON is rejected", func(t *testing.T) { + _, _, appErr := normalizePageContent("Test", `{"type":"bogus"}`) + require.NotNil(t, appErr) + }) + + t.Run("javascript URL is stripped on normalization", func(t *testing.T) { + body, _, appErr := normalizePageContent("Test", `{"type":"doc","content":[{"type":"text","text":"x","marks":[{"type":"link","attrs":{"href":"javascript:alert(1)"}}]}]}`) + require.Nil(t, appErr) + require.NotContains(t, body, "javascript:alert") + }) + + t.Run("string starting with { but not valid JSON is wrapped as plain text", func(t *testing.T) { + body, search, appErr := normalizePageContent("Test", "{shrug}") + require.Nil(t, appErr) + require.True(t, strings.HasPrefix(body, `{"type":"doc"`), "brace-leading non-JSON must be wrapped as plain text: %s", body) + require.Equal(t, "{shrug}", search) + }) + + t.Run("multiline plain text becomes multiple paragraphs", func(t *testing.T) { + body, _, appErr := normalizePageContent("Test", "line one\nline two") + require.Nil(t, appErr) + require.Contains(t, body, "line one") + require.Contains(t, body, "line two") + }) + + t.Run("plain text preserves leading whitespace within lines", func(t *testing.T) { + body, _, appErr := normalizePageContent("Test", " indented line") + require.Nil(t, appErr) + require.Contains(t, body, `" indented line"`, "leading spaces must not be stripped from paragraph text") + }) +} + +// TestNormalizePageContentRejectsTooManyParagraphs verifies that a plain-text body exceeding +// maxPlainTextParagraphs newlines is rejected with 400, rather than producing an oversized TipTap +// document. +func TestNormalizePageContentRejectsTooManyParagraphs(t *testing.T) { + body := strings.Repeat("x\n", maxPlainTextParagraphs+1) + + _, _, appErr := normalizePageContent("test", body) + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) + require.Contains(t, appErr.Error(), "too many paragraphs") +} + +func TestNormalizePatchContent(t *testing.T) { + t.Run("nil patch is a no-op", func(t *testing.T) { + require.Nil(t, normalizePatchContent("test", nil)) + }) + + t.Run("patch with nil Body is a no-op", func(t *testing.T) { + patch := &model.PagePatch{Title: mmmodel.NewPointer("title")} + require.Nil(t, normalizePatchContent("test", patch)) + require.Nil(t, patch.SearchText, "SearchText must remain nil when Body is unset") + }) + + t.Run("patch with valid Body normalizes and derives SearchText", func(t *testing.T) { + body := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}` + patch := &model.PagePatch{Body: mmmodel.NewPointer(body)} + require.Nil(t, normalizePatchContent("test", patch)) + require.NotNil(t, patch.SearchText) + require.Equal(t, "hello", *patch.SearchText) + }) + + t.Run("patch with invalid Body returns 400 AppError", func(t *testing.T) { + patch := &model.PagePatch{Body: mmmodel.NewPointer(`{"type":"bogus"}`)} + appErr := normalizePatchContent("test", patch) + require.NotNil(t, appErr) + require.Equal(t, 400, appErr.StatusCode) + }) + + t.Run("patch with Body strips dangerous URLs", func(t *testing.T) { + body := `{"type":"doc","content":[{"type":"text","text":"x","marks":[{"type":"link","attrs":{"href":"javascript:alert(1)"}}]}]}` + patch := &model.PagePatch{Body: mmmodel.NewPointer(body)} + require.Nil(t, normalizePatchContent("test", patch)) + require.NotContains(t, *patch.Body, "javascript:alert") + }) +} + +// TestNormalizeContentBody covers the draft autosave entry point, the only one that sanitizes a body +// without deriving SearchText — publish derives it later from the same content. +func TestNormalizeContentBody(t *testing.T) { + t.Run("an empty body is left alone", func(t *testing.T) { + body, appErr := normalizeContentBody("test", "") + require.Nil(t, appErr) + require.Empty(t, body) + }) + + t.Run("valid TipTap content is normalized", func(t *testing.T) { + body, appErr := normalizeContentBody("test", + `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}`) + require.Nil(t, appErr) + require.Contains(t, body, "hello") + }) + + t.Run("dangerous URLs are stripped", func(t *testing.T) { + body, appErr := normalizeContentBody("test", + `{"type":"doc","content":[{"type":"text","text":"x","marks":[{"type":"link","attrs":{"href":"javascript:alert(1)"}}]}]}`) + require.Nil(t, appErr) + require.NotContains(t, body, "javascript:alert") + }) + + t.Run("an unlisted node type is rejected", func(t *testing.T) { + _, appErr := normalizeContentBody("test", `{"type":"doc","content":[{"type":"script"}]}`) + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) + }) +} + +func TestNormalizeContentRejectsOversizedBodyBeforeParsing(t *testing.T) { + // The size gate must run before json.Unmarshal, so the stored-body limit — not the larger + // request-transport cap — bounds the parse allocation. + oversized := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"` + + strings.Repeat("a", model.PageBodyMaxBytes) + `"}]}]}` + _, _, appErr := normalizePageContent("TestNormalizeContent", oversized) + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +// TestNormalizePageContentAcceptsMaxParagraphs pins the paragraph cap's boundary: a body producing +// exactly maxPlainTextParagraphs paragraphs is accepted — the cap rejects strictly more than the +// documented limit, not "the limit or more". +func TestNormalizePageContentAcceptsMaxParagraphs(t *testing.T) { + // "x\n" repeated N-1 times splits into N-1 "x" paragraphs plus one trailing empty paragraph: + // exactly maxPlainTextParagraphs. + body := strings.Repeat("x\n", maxPlainTextParagraphs-1) + + _, _, appErr := normalizePageContent("test", body) + require.Nil(t, appErr) +} diff --git a/server/app/page_draft.go b/server/app/page_draft.go new file mode 100644 index 0000000..c963d3b --- /dev/null +++ b/server/app/page_draft.go @@ -0,0 +1,606 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "errors" + "maps" + "net/http" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + + "github.com/mattermost/mattermost-plugin-docs/server/model" + "github.com/mattermost/mattermost-plugin-docs/server/store" +) + +// UpdatePageDraft upserts the calling user's autosave draft for a page in a space. channelID is +// the space's backing channel, used to scope the presence broadcast. +// +// draft.PageId is the unified page id, allocated up front and stable across the draft → publish +// lifecycle. It is reserved before any published page row exists, so carrying a valid page id does +// not imply the page is published — a draft may exist first. The space must exist and be live. The +// caller owns the draft: draft.UserId is always sourced from the request, never the request body. +// +// An autosave may omit fields the editor didn't change, and an omitted field keeps its stored +// value. parentID, fileIDs, and props signal omission with a nil pointer; the draft struct's own +// fields signal it by being empty. +func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs *mmmodel.StringArray, props *mmmodel.StringInterface, channelID string) (*model.Draft, *mmmodel.AppError) { + if draft == nil { + return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.nil_draft.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(draft.UserId) { + return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.invalid_user_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(draft.SpaceId) { + return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.invalid_space_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(draft.PageId) { + return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.invalid_page_id.app_error", nil, "", http.StatusBadRequest) + } + if parentID != nil && *parentID != "" && !mmmodel.IsValidId(*parentID) { + return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.invalid_parent_id.app_error", nil, "", http.StatusBadRequest) + } + if draft.Title != "" { + title, titleErr := validateTitle("UpdatePageDraft", draft.Title, model.PageTitleMaxRunes) + if titleErr != nil { + return nil, titleErr + } + draft.Title = title + } + + // Normalize the draft body on the same content path as publish, so a stored draft never holds + // unsanitized markup (normalization parses the body through the TipTap sanitizer). Defense-in-depth: + // only the author can read a draft back today, but any future reader of Draft.Body inherits a + // sanitized value. + if draft.Body != "" { + normalizedBody, contentErr := normalizeContentBody("UpdatePageDraft", draft.Body) + if contentErr != nil { + return nil, contentErr + } + draft.Body = normalizedBody + } + + // AutosaveDraft enforces the autosave-path guards itself (see its godoc), so no separate + // pre-check reads are needed here. + saved, savedPageWasLive, err := s.store.AutosaveDraft(draft, parentID, fileIDs, props) + if err != nil { + switch store.ConflictReason(err) { + case store.ReasonConcurrentEdit: + return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.edit_conflict.app_error", + nil, "", http.StatusConflict).Wrap(err) + case store.ReasonConcurrentAutosave: + return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.draft_changed.app_error", + nil, "", http.StatusConflict).Wrap(err) + } + if store.InvalidInputReason(err) == store.ReasonPageNotLive { + // The page is not addressable in this space — deleted, snapshotted, moved away, held as + // a draft in another space, or never reserved. All of these read as "no such page here", + // so report 404 rather than a generic 400. + return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.page_not_found.app_error", nil, "", http.StatusNotFound).Wrap(err) + } + return nil, storeAppError("UpdatePageDraft", err) + } + + // AutosaveDraft determined page liveness as part of the same call, so trust its result here. + s.maybeBroadcastDraftPresence(savedPageWasLive, saved.PageId, saved.UserId, saved.SpaceId, channelID) + + return saved, nil +} + +// CreateSpaceDraft creates a new-page draft with a server-generated, reserved page id, so a new +// page has a stable link before it is published. title is required; pageParentID, when set, must be +// a published page in the space or an existing draft of the caller. +// The per-user-per-space draft quota is enforced atomically inside the store. +func (s *Service) CreateSpaceDraft(userID, spaceID, title, pageParentID string) (*model.Draft, *mmmodel.AppError) { + if !mmmodel.IsValidId(userID) { + return nil, mmmodel.NewAppError("CreateSpaceDraft", "app.page_draft.create.invalid_user_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(spaceID) { + return nil, mmmodel.NewAppError("CreateSpaceDraft", "app.page_draft.create.invalid_space_id.app_error", nil, "", http.StatusBadRequest) + } + s.log.Debug("Creating space draft", "space_id", spaceID, "parent_id", pageParentID, "user_id", userID) + + title, titleErr := validateTitle("CreateSpaceDraft", title, model.PageTitleMaxRunes) + if titleErr != nil { + return nil, titleErr + } + + if pageParentID != "" { + if parentErr := s.validateDraftParent(userID, spaceID, pageParentID); parentErr != nil { + return nil, parentErr + } + } + + draft := &model.Draft{ + UserId: userID, + SpaceId: spaceID, + PageId: mmmodel.NewId(), + Title: title, + Body: model.EmptyTipTapJSON, + } + var parentPtr *string + if pageParentID != "" { + parentPtr = &pageParentID + } + + // Use UpsertDraft directly: the page row does not exist yet (new-page draft), so + // UpdatePageDraft's guard — which rejects drafts for non-existent pages on the autosave + // path — would incorrectly block this call. The page is never live here, so the liveness + // flag is discarded. + saved, _, err := s.store.UpsertDraft(draft, parentPtr, nil, nil) + if err != nil { + // Translate hierarchy errors with create-specific keys so the client receives an + // appropriate message. invalidInputAppError maps these to update.* keys, which don't + // apply to a create operation. + var invErr *store.ErrInvalidInput + if errors.As(err, &invErr) { + switch invErr.Reason { + case store.ReasonParentNotLive: + // The parent validated in validateDraftParent can disappear before UpsertDraft's + // locked check; surface the create-specific key rather than storeAppError's page.* key. + return nil, mmmodel.NewAppError("CreateSpaceDraft", "app.page_draft.create.invalid_parent.app_error", nil, "", http.StatusBadRequest).Wrap(err) + case store.ReasonDraftCycle: + return nil, mmmodel.NewAppError("CreateSpaceDraft", "app.page_draft.create.parent_cycle.app_error", nil, "", http.StatusBadRequest).Wrap(err) + case store.ReasonDraftTooDeep: + return nil, mmmodel.NewAppError("CreateSpaceDraft", "app.page_draft.create.parent_too_deep.app_error", nil, "", http.StatusBadRequest).Wrap(err) + } + } + return nil, storeAppError("CreateSpaceDraft", err) + } + + return saved, nil +} + +// validateDraftParent accepts a parent that is either a published page in spaceID or an existing +// draft of the caller in spaceID (which allows child drafts under not-yet-published parents). +func (s *Service) validateDraftParent(userID, spaceID, parentID string) *mmmodel.AppError { + if !mmmodel.IsValidId(parentID) { + return mmmodel.NewAppError("validateDraftParent", "app.page_draft.create.invalid_parent_id.app_error", nil, "", http.StatusBadRequest) + } + // Probe the parent space-scoped and collapse "not found" and "exists in another space" into one + // error, so a caller cannot use a distinct response to probe page ids in spaces it cannot read + // (matches validateParentExists). + pageExists, existsErr := s.store.PageExistsInSpace(parentID, spaceID) + if existsErr != nil { + return storeAppError("validateDraftParent", existsErr) + } + if pageExists { + return nil + } + // Not a published page in this space — accept only the caller's own draft in this space. + // GetDraftSpaceID probes existence (space-checked below) without hauling the draft body. + draftSpaceID, draftErr := s.store.GetDraftSpaceID(userID, parentID) + if draftErr == nil && draftSpaceID == spaceID { + return nil + } + if draftErr != nil && !store.IsErrNotFound(draftErr) { + return storeAppError("validateDraftParent", draftErr) + } + return mmmodel.NewAppError("validateDraftParent", "app.page_draft.create.invalid_parent.app_error", nil, "", http.StatusBadRequest) +} + +// GetPageDraft returns the calling user's draft for the given page in the given space. Returns not-found +// when no draft exists for that user. +func (s *Service) GetPageDraft(userID, spaceID, pageID string) (*model.Draft, *mmmodel.AppError) { + if !mmmodel.IsValidId(userID) { + return nil, mmmodel.NewAppError("GetPageDraft", "app.page_draft.get.invalid_user_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(spaceID) { + return nil, mmmodel.NewAppError("GetPageDraft", "app.page_draft.get.invalid_space_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(pageID) { + return nil, mmmodel.NewAppError("GetPageDraft", "app.page_draft.get.invalid_page_id.app_error", nil, "", http.StatusBadRequest) + } + + draft, err := s.store.GetDraft(userID, pageID) + if err != nil { + if store.IsErrNotFound(err) { + return nil, mmmodel.NewAppError("GetPageDraft", "app.page_draft.get.not_found.app_error", nil, "", http.StatusNotFound).Wrap(err) + } + return nil, storeAppError("GetPageDraft", err) + } + + // Defend against a draft key collision across spaces: a draft is keyed by (UserId, PageId), + // so confirm it belongs to the space named in the request. + if draft.SpaceId != spaceID { + return nil, mmmodel.NewAppError("GetPageDraft", "app.page_draft.get.not_found.app_error", nil, "", http.StatusNotFound) + } + + return draft, nil +} + +// DeletePageDraft removes the calling user's draft for the given page — the discard path. (A +// publish deletes the draft inside the store publish transaction without calling here.) +// Returns not-found when no draft exists. channelID is the space's backing channel, used to scope +// the presence broadcast. +func (s *Service) DeletePageDraft(userID, spaceID, pageID, channelID string) *mmmodel.AppError { + if !mmmodel.IsValidId(userID) { + return mmmodel.NewAppError("DeletePageDraft", "app.page_draft.delete.invalid_user_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(spaceID) { + return mmmodel.NewAppError("DeletePageDraft", "app.page_draft.delete.invalid_space_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(pageID) { + return mmmodel.NewAppError("DeletePageDraft", "app.page_draft.delete.invalid_page_id.app_error", nil, "", http.StatusBadRequest) + } + s.log.Debug("Deleting page draft", "space_id", spaceID, "page_id", pageID, "user_id", userID) + + // Discard is unconditional. Autosave and discard are separate HTTP requests in separate + // transactions, so an autosave request the same user dispatched just before the discard can + // still be in flight when the discard commits and then re-insert (resurrect) this draft — the + // server does not guarantee the autosave commits before the later-issued delete. + // + // UpsertDraft's staleness guard cannot catch this case: an unpublished new-page draft has no + // page row for the guard to key on (a published page is protected; only this case is not). + // + // The resulting zombie draft is harmless — it is visible only to its owning user (drafts are + // keyed by (UserId, PageId)), and that user removes it by discarding again — so a deletion + // tombstone to permanently block re-insertion is not warranted. + // A draft is keyed by (UserId, PageId) without SpaceId; DeleteDraftReparenting scopes its + // delete by (UserId, SpaceId, PageId) and requires a live space, so a member of another space + // cannot delete a draft here by passing this space's id with a foreign page id — no separate + // pre-check read is needed. + pageWasLive, delErr := s.store.DeleteDraftReparenting(userID, spaceID, pageID) + if delErr != nil { + // No matching draft — never existed, wrong space, dead space, or removed by a concurrent + // publish/delete. All read as "nothing to discard", so report 404 rather than a 500 that + // would also emit a spurious server-side error log. + if store.IsErrNotFound(delErr) { + return mmmodel.NewAppError("DeletePageDraft", "app.page_draft.delete.not_found.app_error", nil, "", http.StatusNotFound).Wrap(delErr) + } + return storeAppError("DeletePageDraft", delErr) + } + + s.endDraftPresenceSession(pageWasLive, pageID, userID, spaceID, channelID) + + return nil +} + +// GetPageDraftsForSpace lists the user's unpublished drafts in a space, most-recently-updated +// first, one pagination page at a time; the second return reports whether further pages exist. +// Draft bodies are omitted from the listing. +func (s *Service) GetPageDraftsForSpace(userID, spaceID string, page, perPage int) ([]*model.DraftSummary, bool, *mmmodel.AppError) { + if !mmmodel.IsValidId(userID) { + return nil, false, mmmodel.NewAppError("GetPageDraftsForSpace", "app.page_draft.list.invalid_user_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(spaceID) { + return nil, false, mmmodel.NewAppError("GetPageDraftsForSpace", "app.page_draft.list.invalid_space_id.app_error", nil, "", http.StatusBadRequest) + } + + // The store already excludes drafts in a soft-deleted space, so this need not re-check liveness. + offset, limit := paginationOffsetLimit(page, perPage) + drafts, err := s.store.GetDraftsForSpace(userID, spaceID, offset, limit) + if err != nil { + return nil, false, storeAppError("GetPageDraftsForSpace", err) + } + drafts, hasMore := trimPage(drafts, limit) + return drafts, hasMore, nil +} + +// PublishPageDraft publishes the calling user's draft for pageID in spaceID as a page, creating the +// page on first publish or updating it if it already exists. pageID is the id reserved when editing +// began (see CreateSpaceDraft) and is stable across the draft → publish lifecycle, so its presence +// does not imply a published page yet: whether this is a create or an edit is re-derived from the +// database (no client trust). The draft is validated, and the page write and the draft's removal +// either both take effect or neither does. +// Returns (page, wasCreated, appErr): +// - wasCreated=true → a new page was inserted by this call (handler should return 201) +// - wasCreated=false → an existing page was updated, or a concurrent create was adopted (return 200) +// - appErr is a 409 edit conflict → page is the current server page (or nil if the re-read failed), +// so the caller can surface a diff without a follow-up read; on every other error page is nil. +func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) (*model.Page, bool, *mmmodel.AppError) { + if !mmmodel.IsValidId(userID) { + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.invalid_user_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(spaceID) { + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.invalid_space_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(pageID) { + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.invalid_page_id.app_error", nil, "", http.StatusBadRequest) + } + s.log.Debug("Publishing page draft", "space_id", spaceID, "page_id", pageID, "user_id", userID, "force", force) + // 1. Fetch draft (idempotency guard: 404 = draft already published or discarded). + draft, appErr := s.GetPageDraft(userID, spaceID, pageID) + if appErr != nil { + if appErr.StatusCode == http.StatusNotFound { + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.draft_not_found.app_error", + nil, "", http.StatusNotFound).Wrap(appErr) + } + return nil, false, appErr + } + + // 2. Derive isNewPage from the database; never trust client state. + existing, existingErr := s.GetPageWithDeleted(pageID) + isNewPage, targetErr := derivePublishTarget(existing, existingErr, spaceID) + if targetErr != nil { + return nil, false, targetErr + } + + // 3. Parent guard (new-page path only): a new page's parent must be a published live page; a + // draft-only or non-live parent returns 409. The edit path never reparents, so a stale ParentId + // carried on the draft must not block a content-only edit-publish. + if isNewPage && draft.ParentId != "" { + // Existence-only probe, space-scoped: collapse "not a live page" and "lives in another space" + // into one error so the response can't be used to probe page ids in spaces the caller cannot + // read (matches validateDraftParent / validateParentExists). + parentExists, parentErr := s.store.PageExistsInSpace(draft.ParentId, spaceID) + if parentErr != nil { + return nil, false, storeAppError("PublishPageDraft", parentErr) + } + if !parentExists { + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.parent_unpublished.app_error", + nil, "", http.StatusConflict) + } + } + + // 4/5/6. Validate & normalise the draft, build the store write (new-page insert vs edit patch; + // see helpers), and hand it to the store. draft.UpdateAt is passed through so a concurrent + // autosave rolls this publish back as a conflict rather than shipping older content — see + // store.deletePublishedDraftTx. + var page *model.Page + var storeErr error + if isNewPage { + pageForWrite, buildErr := s.buildNewPageForPublish(pageID, spaceID, userID, draft) + if buildErr != nil { + return nil, false, buildErr + } + page, storeErr = s.store.PublishNewPageDraft(pageForWrite, userID, spaceID, model.MaxPageDepth, draft.UpdateAt) + } else { + patch, buildErr := s.buildEditPatchForPublish(draft, force) + if buildErr != nil { + return nil, false, buildErr + } + // A "baseline-only" edit draft carries an optimistic-lock baseline but no populated field, + // so there is no page change to write; discard it rather than bumping EditAt for nothing + // (see helper). + if patch.Title == nil && patch.Body == nil && patch.Props == nil { + return s.discardBaselineOnlyDraft(userID, pageID, spaceID, draft.UpdateAt) + } + page, storeErr = s.store.PublishPageEditDraft(pageID, spaceID, patch, draft.BaseEditAt, force, userID, draft.UpdateAt) + } + if storeErr != nil { + return s.translatePublishStoreError(storeErr, isNewPage, userID, spaceID, pageID, draft.UpdateAt) + } + + // 7. Broadcast the write with the same shape and channel scope as the direct-CRUD page events. + // A publish-via-draft edit reuses page_updated — a client handles it identically to a direct PATCH. + // page_created includes parent_id so clients can place the new node in the tree without a fetch. + if isNewPage { + s.publishToChannels(wsEventPageCreated, map[string]any{ + "page_id": page.Id, + "space_id": page.SpaceId, + "parent_id": page.ParentId, + }, page.ChannelId) + } else { + s.publishToChannels(wsEventPageUpdated, map[string]any{ + "page_id": page.Id, + "space_id": page.SpaceId, + }, page.ChannelId) + } + // The publish deleted the draft inside the store publish transaction (bypassing the app-level DeletePageDraft + // that normally broadcasts presence), so end the presence session now so the active-editors + // indicator clears on other clients. The page is live by definition at this point. + s.endDraftPresenceSession(true, pageID, userID, spaceID, page.ChannelId) + + return page, isNewPage, nil +} + +// translatePublishStoreError maps a failed publish write to the caller-facing result, so +// PublishPageDraft stays a sequence of steps and every publish-failure condition lands in one place. +// It returns the same triple as PublishPageDraft: the adopted page with a nil error when a concurrent +// publish already created this page, the current server page alongside a 409 on an edit conflict, and +// a nil page otherwise. +func (s *Service) translatePublishStoreError(storeErr error, isNewPage bool, userID, spaceID, pageID string, draftUpdateAt int64) (*model.Page, bool, *mmmodel.AppError) { + switch { + // The draft moved under this publish: the caller's own editor autosaved after this call read it, + // so the whole write was rolled back rather than committing the older content. Distinct from the + // conflicts below — the client republishes to ship the newer draft, it does not re-baseline. + case store.ConflictReason(storeErr) == store.ReasonConcurrentAutosave: + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.draft_changed.app_error", + nil, "", http.StatusConflict).Wrap(storeErr) + + // Someone else edited the page since the baseline was captured. The draft's baseline is + // write-once (see store.UpsertDraft), so the client cannot re-baseline the existing draft: + // it recovers by publishing with force, or by discarding the draft and reopening the edit + // session against the current page. Return the current server page alongside the conflict so + // the client can diff and choose in one round-trip rather than a follow-up GET. + // The pre-lock page snapshot is stale by definition here, so re-read the live page. + case store.ConflictReason(storeErr) == store.ReasonConcurrentEdit: + editConflictErr := mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.edit_conflict.app_error", + nil, "", http.StatusConflict).Wrap(storeErr) + current, getErr := s.GetPageInSpace("PublishPageDraft", pageID, spaceID, false) + if getErr != nil { + // A concurrent delete or cross-space move can make the page unreadable here; fall + // back to a bare conflict and let the client GET the page itself. + s.log.Warn("failed to re-read page for edit-conflict body", + "page_id", pageID, "user_id", userID, "err", getErr) + return nil, false, editConflictErr + } + return current, false, editConflictErr + + case store.IsErrConflict(storeErr): + // PK collision on the new-page path: a concurrent publish won this page id. Adopt the + // winner and return 200 without broadcasting; a winner that is not this caller's to read + // falls through to a plain conflict (see adoptPublishRaceWinner). + if isNewPage { + if raced, adopted := s.adoptPublishRaceWinner(userID, pageID, spaceID, draftUpdateAt); adopted { + return raced, false, nil + } + } + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.conflict.app_error", + nil, "", http.StatusConflict).Wrap(storeErr) + case store.IsErrNotFound(storeErr): + // A concurrent delete removed the page or its parent between the pre-checks and the lock. + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.page_deleted.app_error", + nil, "", http.StatusConflict).Wrap(storeErr) + default: + return nil, false, storeAppError("PublishPageDraft", storeErr) + } +} + +// derivePublishTarget classifies a publish target from the GetPageWithDeleted read: no page means +// a new-page publish, a live page in the caller's space means an edit-publish. A page in another +// space reports 404 rather than confirming the id exists elsewhere. A deleted page reports 409: +// the draft outlived its page and cannot be published. +func derivePublishTarget(existing *model.Page, existingErr *mmmodel.AppError, spaceID string) (isNewPage bool, appErr *mmmodel.AppError) { + // The cross-space and deleted cases below are reachable only when the page moved or was deleted + // between the draft read and this classification — the draft read's liveness filter excludes + // drafts in either steady state. + switch { + case existingErr != nil && existingErr.StatusCode == http.StatusNotFound: + return true, nil + case existingErr != nil: + return false, existingErr + case existing == nil: + // GetPageWithDeleted never returns (nil, nil) today; classify it like not-found rather + // than dereferencing nil, matching the guard adoptPublishRaceWinner applies to the same read. + return true, nil + case existing.SpaceId != spaceID: + // GetPageWithDeleted is not space-scoped and the caller is only authorized for spaceID, so + // collapse to the same 404 the space-scoped reads return. + return false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.page_not_found.app_error", + nil, "", http.StatusNotFound) + case existing.DeleteAt != 0: + return false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.page_deleted.app_error", + nil, "", http.StatusConflict) + default: // live page in this space → edit path + return false, nil + } +} + +// buildNewPageForPublish normalises the draft body and constructs the *model.Page inserted by +// store.PublishNewPageDraft. The page adopts the draft's title, body, and props outright. +func (s *Service) buildNewPageForPublish(pageID, spaceID, userID string, draft *model.Draft) (*model.Page, *mmmodel.AppError) { + body, searchText, contentErr := normalizePageContent("PublishPageDraft", draft.Body) + if contentErr != nil { + return nil, contentErr + } + title, titleErr := validateTitle("PublishPageDraft", draft.Title, model.PageTitleMaxRunes) + if titleErr != nil { + return nil, titleErr + } + // ChannelId is derived by the store from the space, matching CreatePage, so it is + // intentionally left unset here. + return &model.Page{ + Id: pageID, + SpaceId: spaceID, + ParentId: draft.ParentId, + Title: title, + Body: body, + SearchText: searchText, + Props: maps.Clone(draft.Props), + UserId: userID, + LastModifiedBy: userID, + }, nil +} + +// buildEditPatchForPublish normalises the draft body and translates the draft into the +// *model.PagePatch applied by store.PublishPageEditDraft, carrying only the fields this draft +// changed. A Draft has no per-field presence markers, so empty is its unset marker: an omitted +// field stays nil in the patch and keeps the live page's current value, which means a partial +// draft never wipes an untouched field and a force-publish cannot revert a concurrent edit to a +// field this draft did not change. Body's "" reading is safe because a document the user cleared +// is stored as EmptyTipTapJSON, never "". Props follow the same rule: a bare map cannot express +// "clear to empty" distinctly from "unset", so an empty draft map means preserve. +// The caller detects a baseline-only draft (every patch field nil) after the build. +func (s *Service) buildEditPatchForPublish(draft *model.Draft, force bool) (*model.PagePatch, *mmmodel.AppError) { + body, searchText, contentErr := normalizePageContent("PublishPageDraft", draft.Body) + if contentErr != nil { + return nil, contentErr + } + // Require an optimistic-lock baseline unless force, so a client that never captured the + // page's EditAt cannot silently overwrite a concurrent edit. + if !force && draft.BaseEditAt == 0 { + return nil, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.baseline_required.app_error", + nil, "", http.StatusBadRequest) + } + patch := &model.PagePatch{} + if draft.Title != "" { + title, titleErr := validateTitle("PublishPageDraft", draft.Title, model.PageTitleMaxRunes) + if titleErr != nil { + return nil, titleErr + } + patch.Title = &title + } + if draft.Body != "" { + patch.Body = &body + patch.SearchText = &searchText + } + if len(draft.Props) > 0 { + // No clone here: the store merges via model.Page.Patch, which clones Props itself. + patch.Props = &draft.Props + } + return patch, nil +} + +// discardBaselineOnlyDraft handles an edit-path publish whose draft carries an optimistic-lock +// baseline but no content in any field — Title, Body, and Props were never populated (all empty). +// This is NOT a user who cleared the document: a cleared doc is EmptyTipTapJSON, a non-empty Body +// that publishes normally; empty here means "never sent". With every field empty there is no page +// change to write. Publishing would still bump EditAt and emit page_updated with no actual change, +// invalidating other editors' baselines for nothing. So delete the draft and return the page from a +// fresh read — not the caller's pre-lock snapshot, which a concurrent edit may have outdated. +func (s *Service) discardBaselineOnlyDraft(userID, pageID, spaceID string, draftUpdateAt int64) (*model.Page, bool, *mmmodel.AppError) { + deleted, delErr := s.store.DeleteDraftVersion(userID, pageID, draftUpdateAt) + if delErr != nil { + return nil, false, storeAppError("PublishPageDraft", delErr) + } + if !deleted { + // A concurrent autosave advanced the draft version between the read and the delete. + // The newer draft may have real content — the client must re-read and re-publish. + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.draft_changed.app_error", + nil, "", http.StatusConflict) + } + current, getErr := s.GetPageInSpace("PublishPageDraft", pageID, spaceID, false) + if getErr != nil { + // The draft is already discarded, so this publish's mutation succeeded; only the re-read + // failed. Mirror the edit-conflict branch: log and translate rather than forwarding the + // re-read error as a publish failure. Clear the rate-limit entry so a later session starts + // unthrottled — with no readable page there is no channel to broadcast the session end to. + s.log.Warn("failed to re-read page after discarding baseline-only draft", + "page_id", pageID, "user_id", userID, "err", getErr) + s.clearPresenceThrottle(pageID, userID) + if getErr.StatusCode == http.StatusNotFound { + // A concurrent delete or cross-space move made the page unreadable: the draft outlived + // its page, which is the defined page_deleted conflict. + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.page_deleted.app_error", + nil, "", http.StatusConflict).Wrap(getErr) + } + return nil, false, getErr + } + s.endDraftPresenceSession(true, pageID, userID, spaceID, current.ChannelId) + return current, false, nil +} + +// adoptPublishRaceWinner handles the PK-collision case on the new-page publish path: a concurrent +// publish already created this page id. It returns the winner and true when this caller should adopt +// it (return 200 without broadcasting — the winner already broadcast wsEventPageCreated). The winner +// must be a live page in this space; a different-space or already-deleted winner is not this caller's +// to read, so it returns (nil, false) and the caller surfaces a plain conflict. +func (s *Service) adoptPublishRaceWinner(userID, pageID, spaceID string, draftUpdateAt int64) (*model.Page, bool) { + raced, rErr := s.GetPageWithDeleted(pageID) + if rErr == nil && raced != nil && raced.SpaceId == spaceID && raced.DeleteAt == 0 { + // Discard this caller's now-orphaned draft so it does not linger pointing at a + // published page — but only if it still holds the version this publish read. A fresh + // autosave after the race winner committed bumps UpdateAt, so the CAS matches no row + // and that newer draft is left intact rather than dropped. + // + // Best-effort: the page is already published by the winner, so a failure here is + // logged (a stray draft the user can discard), never surfaced as a publish failure. + if _, delErr := s.store.DeleteDraftVersion(userID, pageID, draftUpdateAt); delErr != nil { + s.log.Warn("failed to delete orphaned draft after adopting race winner", + "page_id", pageID, "user_id", userID, "err", delErr) + } + // The draft is consumed; end the presence session so the active-editors indicator drops + // this user, matching the non-conflict path. The adopted winner is live by definition. + s.endDraftPresenceSession(true, pageID, userID, spaceID, raced.ChannelId) + return raced, true + } + if rErr != nil { + // Log this: a real store failure here would otherwise look identical to losing the race. + s.log.Warn("failed to read the page that won the publish race", + "page_id", pageID, "user_id", userID, "err", rErr) + } + return nil, false +} diff --git a/server/app/page_draft_internal_test.go b/server/app/page_draft_internal_test.go new file mode 100644 index 0000000..64acf27 --- /dev/null +++ b/server/app/page_draft_internal_test.go @@ -0,0 +1,126 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "net/http" + "testing" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-plugin-docs/server/internal/testutil" + "github.com/mattermost/mattermost-plugin-docs/server/model" + "github.com/mattermost/mattermost-plugin-docs/server/store" +) + +// TestAdoptPublishRaceWinner exercises the PK-collision adoption path directly: PublishPageDraft +// reaches it only when a concurrent publish inserts the page between the target classification and +// the store insert, a window that cannot be held open through the public service surface. +func TestAdoptPublishRaceWinner(t *testing.T) { + s, _ := testutil.OpenTestStore(t) + svc := New(s, nil, nil) + space := testutil.MustCreateSpace(t, s, mmmodel.NewId(), mmmodel.NewId()) + userID := mmmodel.NewId() + + // newDraftAt reserves pageID as the user's new-page draft, mirroring CreateSpaceDraft. + newDraftAt := func(t *testing.T, pageID string) *model.Draft { + t.Helper() + d, _, err := s.UpsertDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Doc"}, nil, nil, nil) + require.NoError(t, err) + return d + } + + // winnerAt commits a live page at pageID, simulating the concurrent publish that won the id. + winnerAt := func(t *testing.T, pageID string) *model.Page { + t.Helper() + winner := testutil.NewPage(space.Id, space.ChannelId, userID, "") + winner.Id = pageID + created, err := s.CreatePage(winner, testutil.UncappedMaxDepth) + require.NoError(t, err) + return created + } + + t.Run("live winner in the same space is adopted and the orphaned draft is consumed", func(t *testing.T) { + pageID := mmmodel.NewId() + draft := newDraftAt(t, pageID) + created := winnerAt(t, pageID) + + raced, adopted := svc.adoptPublishRaceWinner(userID, pageID, space.Id, draft.UpdateAt) + require.True(t, adopted) + require.Equal(t, created.Id, raced.Id) + + _, err := s.GetDraft(userID, pageID) + require.True(t, store.IsErrNotFound(err), "the orphaned draft must be deleted, got %v", err) + }) + + t.Run("deleted winner is not adopted", func(t *testing.T) { + pageID := mmmodel.NewId() + draft := newDraftAt(t, pageID) + winnerAt(t, pageID) + _, err := s.DeletePage(pageID, space.Id, userID) + require.NoError(t, err) + + raced, adopted := svc.adoptPublishRaceWinner(userID, pageID, space.Id, draft.UpdateAt) + require.False(t, adopted, "a deleted page is not a publishable winner") + require.Nil(t, raced) + }) + + t.Run("winner in another space is not adopted", func(t *testing.T) { + pageID := mmmodel.NewId() + draft := newDraftAt(t, pageID) + winnerAt(t, pageID) + + raced, adopted := svc.adoptPublishRaceWinner(userID, pageID, mmmodel.NewId(), draft.UpdateAt) + require.False(t, adopted, "a winner outside the caller's space is not adoptable") + require.Nil(t, raced) + + _, err := s.GetDraft(userID, pageID) + require.NoError(t, err, "a non-adopted draft must be left in place") + }) +} + +// TestDerivePublishTarget covers the publish-target classification directly: the cross-space and +// deleted branches guard races (the page moved or was deleted between the draft read and the +// classification) that cannot be constructed through the public service surface — the draft +// read's liveness filter excludes both steady states. +func TestDerivePublishTarget(t *testing.T) { + spaceID := mmmodel.NewId() + + t.Run("missing page is a new-page publish", func(t *testing.T) { + notFound := mmmodel.NewAppError("GetPageWithDeleted", "app.page.get.not_found.app_error", nil, "", http.StatusNotFound) + isNewPage, appErr := derivePublishTarget(nil, notFound, spaceID) + require.Nil(t, appErr) + require.True(t, isNewPage) + }) + + t.Run("read failure is passed through", func(t *testing.T) { + readErr := mmmodel.NewAppError("GetPageWithDeleted", "app.store.not_found.app_error", nil, "", http.StatusInternalServerError) + isNewPage, appErr := derivePublishTarget(nil, readErr, spaceID) + require.False(t, isNewPage) + require.Equal(t, readErr, appErr) + }) + + t.Run("page in another space reports not-found", func(t *testing.T) { + isNewPage, appErr := derivePublishTarget(&model.Page{SpaceId: mmmodel.NewId()}, nil, spaceID) + require.False(t, isNewPage) + require.NotNil(t, appErr) + require.Equal(t, http.StatusNotFound, appErr.StatusCode) + require.Equal(t, "app.page_draft.publish.page_not_found.app_error", appErr.Id) + }) + + t.Run("deleted page conflicts", func(t *testing.T) { + isNewPage, appErr := derivePublishTarget(&model.Page{SpaceId: spaceID, DeleteAt: 1}, nil, spaceID) + require.False(t, isNewPage) + require.NotNil(t, appErr) + require.Equal(t, http.StatusConflict, appErr.StatusCode) + require.Equal(t, "app.page_draft.publish.page_deleted.app_error", appErr.Id) + }) + + t.Run("live page in the caller's space is an edit publish", func(t *testing.T) { + isNewPage, appErr := derivePublishTarget(&model.Page{SpaceId: spaceID}, nil, spaceID) + require.Nil(t, appErr) + require.False(t, isNewPage) + }) +} diff --git a/server/app/page_draft_test.go b/server/app/page_draft_test.go new file mode 100644 index 0000000..d8db064 --- /dev/null +++ b/server/app/page_draft_test.go @@ -0,0 +1,1095 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app_test + +import ( + "fmt" + "net/http" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin/plugintest" + + "github.com/mattermost/mattermost-plugin-docs/server/app" + "github.com/mattermost/mattermost-plugin-docs/server/model" +) + +// docWith returns a minimal TipTap document whose paragraph contains text. +func docWith(text string) string { + return fmt.Sprintf(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":%q}]}]}`, text) +} + +// publishNewPage creates a new-page draft, autosaves the given body, and publishes it, returning +// the live page. It asserts the reserved draft id is preserved through publish. +func publishNewPage(t *testing.T, h *testHarness, spaceID, userID, title, bodyText string) *model.Page { + t.Helper() + draft, appErr := h.svc.CreateSpaceDraft(userID, spaceID, title, "") + require.Nil(t, appErr) + reservedID := draft.PageId + + _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: spaceID, PageId: reservedID, Title: title, Body: docWith(bodyText)}, nil, nil, nil, "") + require.Nil(t, appErr) + + page, wasCreated, appErr := h.svc.PublishPageDraft(userID, spaceID, reservedID, false) + require.Nil(t, appErr) + require.True(t, wasCreated, "publishing a brand-new page must report wasCreated=true") + require.Equal(t, reservedID, page.Id, "publish must preserve the reserved draft id") + require.Contains(t, page.Body, bodyText) + return page +} + +func TestUpdatePageDraftPreservesBodyOnTitleOnlyAutosave(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Doc", "") + require.Nil(t, appErr) + pageID := draft.PageId + + // Autosave real content. + _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Doc", Body: docWith("keep me")}, nil, nil, nil, "") + require.Nil(t, appErr) + + // A heartbeat that sends only the title (empty body) must not wipe the stored draft body. + saved, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Doc"}, nil, nil, nil, "") + require.Nil(t, appErr) + require.Contains(t, saved.Body, "keep me", "a title-only autosave must not clear the draft body") +} + +func TestPublishEmptyDraftBodyDoesNotWipePage(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + page := publishNewPage(t, h, space.Id, userID, "Important", "ORIGINAL") + + // Start an edit session whose first (and only) autosave carries the title but no body, exactly + // the heartbeat case that previously wiped the page on publish. + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Important", + BaseEditAt: page.EditAt, + }, nil, nil, nil, "") + require.Nil(t, appErr) + + republished, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + require.Nil(t, appErr) + require.Contains(t, republished.Body, "ORIGINAL", "publishing a title-only edit must preserve the page body") +} + +// TestPublishNoOpDraftDiscardsAndReturnsExistingPage verifies that publishing a draft that carries +// no content change — only the optimistic-lock baseline, no Title, no Body — is treated as a +// discard rather than a no-op page write: the draft is deleted, the existing page comes back +// unchanged with its EditAt intact, no page_updated event fires, and wasCreated is false. +func TestPublishNoOpDraftDiscardsAndReturnsExistingPage(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + page := publishNewPage(t, h, space.Id, userID, "Doc", "original") + + // Start an edit session whose only autosave carries the baseline — no Title, no Body. + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, + BaseEditAt: page.EditAt, + }, nil, nil, nil, "") + require.Nil(t, appErr) + + result, wasCreated, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + require.Nil(t, appErr) + require.False(t, wasCreated, "a no-content publish must not report a creation") + require.Equal(t, page.Title, result.Title, "the page's title must be unchanged") + require.Contains(t, result.Body, "original", "the page's body must be unchanged") + require.Equal(t, page.EditAt, result.EditAt, "a no-content publish must not bump EditAt") + mockAPI.AssertNotCalled(t, "PublishWebSocketEvent", "page_updated", mock.Anything, mock.Anything) + + // The draft was consumed: it was converted into a discard rather than left in place. + _, appErr = h.svc.GetPageDraft(userID, space.Id, page.Id) + require.NotNil(t, appErr) + require.Equal(t, http.StatusNotFound, appErr.StatusCode) +} + +func TestPublishRejectsMissingBaselineOnEdit(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + + // UpsertDraft guards the first edit-draft save: attempting to create a draft for an existing + // page without a base_edit_at baseline is rejected at the store layer, so the client must send + // the baseline on the very first autosave (the response tells it to reload and set it). + _, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", Body: docWith("v2")}, nil, nil, nil, "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusConflict, appErr.StatusCode) + + // With a proper baseline the draft is created; force=true publishes regardless of baseline. + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", Body: docWith("v2"), + BaseEditAt: page.EditAt, + }, nil, nil, nil, "") + require.Nil(t, appErr) + + forced, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, true) + require.Nil(t, appErr) + require.Contains(t, forced.Body, "v2") +} + +// TestPublishRejectsNoBaselineOnExistingPageEdit covers the app-level baseline_required guard in +// PublishPageDraft: a stored edit-draft with BaseEditAt still 0 (no baseline was ever captured) must +// be rejected with 400 unless force=true. The store's UpsertDraft guard normally blocks a +// baseline-less draft from ever being created against a live page (see +// TestPublishRejectsMissingBaselineOnEdit), so this simulates the race where the draft's first +// autosave lands as a new-page draft and the underlying page becomes live afterward without the +// draft ever acquiring a baseline — reached here by inserting the draft row directly. +func TestPublishRejectsNoBaselineOnExistingPageEdit(t *testing.T) { + h := openTestService(t) + channelID := mmmodel.NewId() + space := mustCreateSpace(t, h.store, channelID) + userID := mmmodel.NewId() + + page := mustCreatePage(t, h.store, space.Id, channelID, userID, "") + + now := mmmodel.GetMillis() + _, err := h.db.Exec( + `INSERT INTO docs_draft (userid,spaceid,pageid,parentid,title,body,fileids,props,createat,updateat,lastactiveat,baseeditat) + VALUES ($1,$2,$3,'',$4,$5,'[]','{}', $6,$6,$6,0)`, + userID, space.Id, page.Id, "Edited", docWith("v2"), now, + ) + require.NoError(t, err) + + // Without a baseline, a non-force publish of an edit must be rejected. + _, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) + require.Equal(t, "app.page_draft.publish.baseline_required.app_error", appErr.Id) + + // force=true bypasses the missing-baseline guard and publishes the edit. + forced, wasCreated, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, true) + require.Nil(t, appErr) + require.False(t, wasCreated) + require.Equal(t, "Edited", forced.Title) + require.Contains(t, forced.Body, "v2") +} + +func TestPublishStaleBaselineConflicts(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + staleEditAt := page.EditAt + + // Start an edit session with a draft baselined at the current EditAt. The draft persists (it is + // not consumed by any publish), so the stale-baseline conflict surfaces at publish time. + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", Body: docWith("v3"), + BaseEditAt: staleEditAt, + }, nil, nil, nil, "") + require.Nil(t, appErr) + + // A concurrent direct edit advances the page's EditAt out from under that baseline, without + // touching the draft. + concurrent := docWith("concurrent") + _, appErr = h.svc.UpdatePage(page.Id, space.Id, &model.PagePatch{Body: &concurrent}, new(staleEditAt), false, userID) + require.Nil(t, appErr) + + // Publishing the draft against the now-stale baseline must 409, and return the current server + // page (the concurrent edit's content + advanced baseline) so the caller can diff without a + // follow-up read. + current, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + require.NotNil(t, appErr) + require.Equal(t, http.StatusConflict, appErr.StatusCode) + require.NotNil(t, current, "edit conflict must return the current server page") + require.Contains(t, current.Body, "concurrent", "current page must reflect the concurrent edit") + require.Greater(t, current.EditAt, staleEditAt, "current page must carry the advanced baseline") +} + +// TestPublishAfterPageDeleteReturns404 verifies that deleting a page cascade-deletes its drafts, +// so a later publish finds no draft (404) rather than writing to a tombstone. The page-deleted 409 +// path in PublishPageDraft guards only the concurrent-delete race, which this flow cannot produce. +func TestPublishAfterPageDeleteReturns404(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + page := publishNewPage(t, h, space.Id, userID, "Doomed", "x") + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doomed", Body: docWith("y"), + BaseEditAt: page.EditAt, + }, nil, nil, nil, "") + require.Nil(t, appErr) + requireStoreDeletePage(t, h.store, page.Id, space.Id, userID) + + _, _, appErr = h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + require.NotNil(t, appErr) + require.Equal(t, http.StatusNotFound, appErr.StatusCode) +} + +func TestDeletePageDraftRejectsWrongSpace(t *testing.T) { + h := openTestService(t) + spaceA := mustCreateSpace(t, h.store, mmmodel.NewId()) + spaceB := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + draft, appErr := h.svc.CreateSpaceDraft(userID, spaceA.Id, "In A", "") + require.Nil(t, appErr) + + // Deleting the draft while naming the wrong space must 404, and must not delete it. + delErr := h.svc.DeletePageDraft(userID, spaceB.Id, draft.PageId, "") + require.NotNil(t, delErr) + require.Equal(t, http.StatusNotFound, delErr.StatusCode) + + got, appErr := h.svc.GetPageDraft(userID, spaceA.Id, draft.PageId) + require.Nil(t, appErr, "draft must survive a delete attempt through the wrong space") + require.Equal(t, draft.PageId, got.PageId) +} + +// TestPublishPageDraftConcurrentPublishesConverge races two publishes of the same new-page draft +// (a double-clicked publish). The winner is not fixed, so the assertions are outcome invariants: +// exactly one publish creates the page; the other either adopts the winner (success without +// wasCreated — see adoptPublishRaceWinner) or reports a benign already-published outcome (404 +// draft gone / 409 conflict). Either way the page ends up live and the draft consumed. +func TestPublishPageDraftConcurrentPublishesConverge(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Doc", "") + require.Nil(t, appErr) + _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: draft.PageId, Title: "Doc", Body: docWith("race")}, nil, nil, nil, "") + require.Nil(t, appErr) + + type result struct { + page *model.Page + created bool + appErr *mmmodel.AppError + } + results := make([]result, 2) + start := make(chan struct{}) + var wg sync.WaitGroup + for i := range results { + wg.Go(func() { + <-start + p, created, pubErr := h.svc.PublishPageDraft(userID, space.Id, draft.PageId, false) + results[i] = result{page: p, created: created, appErr: pubErr} + }) + } + close(start) + wg.Wait() + + var created, benign int + for _, r := range results { + switch { + case r.appErr == nil && r.created: + created++ + require.Equal(t, draft.PageId, r.page.Id) + case r.appErr == nil: + // Adopted the winner: same live page, without wasCreated. + require.Equal(t, draft.PageId, r.page.Id) + default: + require.Contains(t, []int{http.StatusNotFound, http.StatusConflict}, r.appErr.StatusCode, + "the losing publish may only fail benignly, got %v", r.appErr) + benign++ + } + } + require.Equal(t, 1, created, "exactly one concurrent publish must create the page, got %+v", results) + require.LessOrEqual(t, benign, 1) + + // Converged state: the draft is consumed and the page is live in the space. + _, appErr = h.svc.GetPageDraft(userID, space.Id, draft.PageId) + require.NotNil(t, appErr) + require.Equal(t, http.StatusNotFound, appErr.StatusCode, "the draft must be consumed by the publish") + snapshot, appErr := h.svc.GetPageActiveEditors(draft.PageId, space.Id) + require.Nil(t, appErr, "the published page must resolve in its space") + require.Empty(t, snapshot.ActiveEditors) +} + +// TestGetPageActiveEditorsUnknownPageReturns404 pins the plain not-found case: a page id with no +// row at all (as opposed to a page living in another space, covered below). +func TestGetPageActiveEditorsUnknownPageReturns404(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + + _, appErr := h.svc.GetPageActiveEditors(mmmodel.NewId(), space.Id) + require.NotNil(t, appErr) + require.Equal(t, http.StatusNotFound, appErr.StatusCode) +} + +func TestGetPageActiveEditorsRejectsWrongSpace(t *testing.T) { + h := openTestService(t) + spaceA := mustCreateSpace(t, h.store, mmmodel.NewId()) + spaceB := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + page := publishNewPage(t, h, spaceA.Id, userID, "Doc", "x") + + // The page lives in space A; querying its editors through space B must not resolve it. + _, appErr := h.svc.GetPageActiveEditors(page.Id, spaceB.Id) + require.NotNil(t, appErr) + require.Equal(t, http.StatusNotFound, appErr.StatusCode) + + // Through its own space it resolves (empty set — no active drafts). + snapshot, appErr := h.svc.GetPageActiveEditors(page.Id, spaceA.Id) + require.Nil(t, appErr) + require.Empty(t, snapshot.ActiveEditors) +} + +func TestActiveEditorsSurfacesHeartbeat(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + page := publishNewPage(t, h, space.Id, userID, "Doc", "x") + + // An autosave is the heartbeat; the editor must then appear as active. + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", Body: docWith("editing"), + BaseEditAt: page.EditAt, + }, nil, nil, nil, "") + require.Nil(t, appErr) + + snapshot, appErr := h.svc.GetPageActiveEditors(page.Id, space.Id) + require.Nil(t, appErr) + require.Contains(t, snapshot.ActiveEditors, userID) + require.Positive(t, snapshot.SnapshotAt) + require.Equal(t, app.ActiveEditorTimeoutMs, snapshot.ActiveTimeoutMs) +} + +func TestPublishSetsLastModifiedBy(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + page := publishNewPage(t, h, space.Id, userID, "Doc", "x") + require.Equal(t, userID, page.LastModifiedBy, "a page published via draft must record its author as last modifier") +} + +func TestPublishForceOverridesStaleBaseline(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + staleEditAt := page.EditAt + + // Start an edit session with a draft baselined at the current EditAt; the draft persists. + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", Body: docWith("v3"), + BaseEditAt: staleEditAt, + }, nil, nil, nil, "") + require.Nil(t, appErr) + + // A concurrent direct edit advances the page's EditAt, making the draft's baseline stale. + concurrent := docWith("concurrent") + concurrentPage, appErr := h.svc.UpdatePage(page.Id, space.Id, &model.PagePatch{Body: &concurrent}, new(staleEditAt), false, userID) + require.Nil(t, appErr) + + // force=true must override the stale-baseline CAS and win with the draft's content. + forced, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, true) + require.Nil(t, appErr) + require.Contains(t, forced.Body, "v3", "force must override a stale baseline") + require.Greater(t, forced.EditAt, concurrentPage.EditAt, "a force-publish must still advance the page's EditAt") +} + +func TestPublishForceDoesNotRevertUntouchedField(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + page := publishNewPage(t, h, space.Id, userID, "Original title", "original body") + baseEditAt := page.EditAt + + // A title-only edit: the draft carries a new title but no body, baselined at the current EditAt. + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "New title", + BaseEditAt: baseEditAt, + }, nil, nil, nil, "") + require.Nil(t, appErr) + + // A concurrent edit changes the BODY — a field the draft never touched — and advances EditAt. + concurrent := docWith("concurrent body") + _, appErr = h.svc.UpdatePage(page.Id, space.Id, &model.PagePatch{Body: &concurrent}, new(baseEditAt), false, userID) + require.Nil(t, appErr) + + // Force-publish the title-only draft: force overrides the stale baseline, but only the title may + // be applied. The concurrent body edit must survive rather than be reverted to the pre-lock value. + forced, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, true) + require.Nil(t, appErr) + require.Equal(t, "New title", forced.Title, "the draft's title must be applied") + require.Contains(t, forced.Body, "concurrent body", "a field the draft never set must not be reverted") +} + +func TestUpdatePageDraftRejectsStaleBaselineAfterPublish(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + baseEditAt := page.EditAt + + // Edit and publish: the draft is consumed and the page's EditAt advances. + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", Body: docWith("v2"), + BaseEditAt: baseEditAt, + }, nil, nil, nil, "") + require.Nil(t, appErr) + _, _, appErr = h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + require.Nil(t, appErr) + + // A late autosave carrying the pre-publish baseline must be rejected, not resurrect a phantom + // draft on the now-published page. + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", Body: docWith("late"), + BaseEditAt: baseEditAt, + }, nil, nil, nil, "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusConflict, appErr.StatusCode) + + // No phantom draft was created. + _, appErr = h.svc.GetPageDraft(userID, space.Id, page.Id) + require.NotNil(t, appErr) + require.Equal(t, http.StatusNotFound, appErr.StatusCode) +} + +func TestUpdatePageDraftPreservesPropsOnOmit(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Doc", "") + require.Nil(t, appErr) + pageID := draft.PageId + + // First autosave writes a props map (non-nil pointer → whole-value replace). + saved, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Doc", Body: docWith("v1"), + }, nil, nil, &mmmodel.StringInterface{"custom": "v"}, "") + require.Nil(t, appErr) + require.Equal(t, "v", saved.Props["custom"]) + + // A later autosave that omits props (nil pointer) must preserve the stored map. + saved, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Doc", Body: docWith("v2"), + }, nil, nil, nil, "") + require.Nil(t, appErr) + require.Equal(t, "v", saved.Props["custom"], "omitted props must preserve the stored map") + + // An explicit empty map (non-nil pointer) must clear the stored props. + saved, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Doc", Body: docWith("v3"), + }, nil, nil, &mmmodel.StringInterface{}, "") + require.Nil(t, appErr) + require.Empty(t, saved.Props, "an explicit empty props map must clear the stored props") +} + +// A draft autosave whose (valid TipTap) body exceeds PageBodyMaxBytes must be rejected with 400 — +// the body is well-formed and small in node count, so it clears content normalization and is caught +// by Draft.IsValid's size guard in the store, surfaced through the app layer. +func TestUpdatePageDraftRejectsOversizedBody(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Doc", "") + require.Nil(t, appErr) + + // One paragraph, one text node whose text alone exceeds PageBodyMaxBytes — clears the node/ + // paragraph limits but overflows the serialized-body size cap. + oversized := docWith(strings.Repeat("x", model.PageBodyMaxBytes)) + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: draft.PageId, Title: "Doc", Body: oversized, + }, nil, nil, nil, "") + require.NotNil(t, appErr, "an oversized draft body must be rejected") + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +// Props follow PagePatch.Props write intent on publish: a new page adopts the draft's props, an +// edit whose draft carries a non-empty props map replaces the live page's props, and an edit that +// carries no props preserves them. +func TestPublishCarriesDraftProps(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + // New-page publish adopts the draft's props. + draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Doc", "") + require.Nil(t, appErr) + pageID := draft.PageId + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Doc", Body: docWith("v1"), + }, nil, nil, &mmmodel.StringInterface{"color": "blue"}, "") + require.Nil(t, appErr) + + page, wasCreated, appErr := h.svc.PublishPageDraft(userID, space.Id, pageID, false) + require.Nil(t, appErr) + require.True(t, wasCreated) + require.Equal(t, "blue", page.Props["color"], "a new page must adopt the draft's props on publish") + + // An edit whose draft carries a non-empty props map replaces the page's props. + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Doc", BaseEditAt: page.EditAt, + }, nil, nil, &mmmodel.StringInterface{"color": "red"}, "") + require.Nil(t, appErr) + edited, _, appErr := h.svc.PublishPageDraft(userID, space.Id, pageID, false) + require.Nil(t, appErr) + require.Equal(t, "red", edited.Props["color"], "an edit with non-empty draft props must replace the page's props") + + // An edit that carries content but no props preserves the live page's props. + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: pageID, Body: docWith("v2"), BaseEditAt: edited.EditAt, + }, nil, nil, nil, "") + require.Nil(t, appErr) + preserved, _, appErr := h.svc.PublishPageDraft(userID, space.Id, pageID, false) + require.Nil(t, appErr) + require.Equal(t, "red", preserved.Props["color"], "an edit carrying no props must preserve the page's props") + require.Contains(t, preserved.Body, "v2") +} + +func TestPublishRejectsForeignSpacePage(t *testing.T) { + h := openTestService(t) + spaceA := mustCreateSpace(t, h.store, mmmodel.NewId()) + spaceB := mustCreateSpace(t, h.store, mmmodel.NewId()) + userA, userB := mmmodel.NewId(), mmmodel.NewId() + + // userA reserves a page id in space A. + draftA, appErr := h.svc.CreateSpaceDraft(userA, spaceA.Id, "A doc", "") + require.Nil(t, appErr) + pageID := draftA.PageId + + // userB cannot reserve a draft in space B against userA's not-yet-live page id — + // the cross-space reservation is now correctly rejected at the app layer. + _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userB, SpaceId: spaceB.Id, PageId: pageID, Title: "B doc", Body: docWith("b content")}, nil, nil, nil, "") + require.NotNil(t, appErr, "cross-space draft reservation must be rejected") + require.Equal(t, http.StatusNotFound, appErr.StatusCode, "cross-space draft reservation returns 404") + + // userA autosaves content and publishes, so the page becomes live in space A. + _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userA, SpaceId: spaceA.Id, PageId: pageID, Title: "A doc", Body: docWith("a content")}, nil, nil, nil, "") + require.Nil(t, appErr) + pageA, wasCreated, appErr := h.svc.PublishPageDraft(userA, spaceA.Id, pageID, false) + require.Nil(t, appErr) + require.True(t, wasCreated) + + // userB has no draft for pageID (the cross-space reservation was rejected above), so publishing + // from space B must fail with 404 (draft not found). force cannot bypass this. + for _, force := range []bool{false, true} { + _, _, appErr = h.svc.PublishPageDraft(userB, spaceB.Id, pageID, force) + require.NotNil(t, appErr, "cross-space publish (force=%v) must fail", force) + require.Equal(t, http.StatusNotFound, appErr.StatusCode, + "cross-space publish (force=%v) must be rejected", force) + } + + // Space A's page is unchanged. + stillA, appErr := h.svc.GetPageInSpace("test", pageID, spaceA.Id, false) + require.Nil(t, appErr) + require.Contains(t, stillA.Body, "a content") + require.Equal(t, pageA.EditAt, stillA.EditAt, "cross-space publish must not have modified space A's page") +} + +func TestUpdatePageDraftPreservesTitleOnBodyOnlyAutosave(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Keep Title", "") + require.Nil(t, appErr) + pageID := draft.PageId + + _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Keep Title", Body: docWith("v1")}, nil, nil, nil, "") + require.Nil(t, appErr) + + // A heartbeat that sends only the body (empty title) must not wipe the stored title. + saved, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: pageID, Body: docWith("v2")}, nil, nil, nil, "") + require.Nil(t, appErr) + require.Equal(t, "Keep Title", saved.Title, "a body-only autosave must not clear the draft title") +} + +func TestUpdatePageDraftSanitizesBody(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Doc", "") + require.Nil(t, appErr) + + // The autosave path must sanitize the body on the same content path as publish. + saved, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: draft.PageId, Title: "Doc", + Body: `{"type":"doc","content":[{"type":"image","attrs":{"src":"x","onerror":"alert(document.cookie)"}}]}`, + }, nil, nil, nil, "") + require.Nil(t, appErr) + require.NotContains(t, saved.Body, "onerror", "autosave must sanitize the draft body") +} + +// TestPublishEditIgnoresStaleParentGuard reproduces the edit-path parent-guard bug: a live child +// page whose in-progress draft still carries a parent id that has since become non-live must still +// publish a content-only edit, because the edit path never reparents. +func TestPublishEditIgnoresStaleParentGuard(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + parent := publishNewPage(t, h, space.Id, userID, "Parent", "p") + + // Publish a child under the (live) parent. + childDraft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Child", parent.Id) + require.Nil(t, appErr) + childID := childDraft.PageId + _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: childID, Title: "Child", Body: docWith("c1")}, nil, nil, nil, "") + require.Nil(t, appErr) + child, _, appErr := h.svc.PublishPageDraft(userID, space.Id, childID, false) + require.Nil(t, appErr) + + // Start an edit session whose draft still carries the (currently live) parent id. + parentID := parent.Id + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: childID, Title: "Child", Body: docWith("c2"), + BaseEditAt: child.EditAt, + }, &parentID, nil, nil, "") + require.Nil(t, appErr) + + // The parent is deleted mid-edit (its children are promoted), leaving the draft's parent id stale. + requireStoreDeletePage(t, h.store, parent.Id, space.Id, userID) + + // A content-only edit-publish must still succeed: the edit path does not reparent. + republished, wasCreated, appErr := h.svc.PublishPageDraft(userID, space.Id, childID, false) + require.Nil(t, appErr, "edit-publish must not be blocked by a stale parent id") + require.False(t, wasCreated) + require.Contains(t, republished.Body, "c2") +} + +func TestUpdatePageDraftRejectsInvalidPageID(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + _, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: "not-a-valid-id", Title: "x"}, nil, nil, nil, "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +func TestCreateSpaceDraftRejectsForeignDraftParent(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userA, userB := mmmodel.NewId(), mmmodel.NewId() + + parent, appErr := h.svc.CreateSpaceDraft(userA, space.Id, "Parent", "") + require.Nil(t, appErr) + + // userB cannot parent a draft under userA's draft. + _, appErr = h.svc.CreateSpaceDraft(userB, space.Id, "Child", parent.PageId) + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) + + // userA can, and the child then publishes only after the parent does. + child, appErr := h.svc.CreateSpaceDraft(userA, space.Id, "Child", parent.PageId) + require.Nil(t, appErr) + _, _, appErr = h.svc.PublishPageDraft(userA, space.Id, child.PageId, false) + require.NotNil(t, appErr) + require.Equal(t, http.StatusConflict, appErr.StatusCode, "child cannot publish under an unpublished parent") +} + +func TestUpdatePageDraftRejectsInvalidUserID(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + + _, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: "bad-id", SpaceId: space.Id, PageId: mmmodel.NewId()}, nil, nil, nil, "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +func TestUpdatePageDraftRejectsInvalidSpaceID(t *testing.T) { + h := openTestService(t) + + _, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: mmmodel.NewId(), SpaceId: "bad-id", PageId: mmmodel.NewId()}, nil, nil, nil, "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +func TestCreateSpaceDraftRejectsEmptyTitle(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + + _, appErr := h.svc.CreateSpaceDraft(mmmodel.NewId(), space.Id, "", "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +func TestCreateSpaceDraftRejectsTitleOverCap(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + longTitle := strings.Repeat("x", model.PageTitleMaxRunes+1) + + _, appErr := h.svc.CreateSpaceDraft(mmmodel.NewId(), space.Id, longTitle, "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +func TestCreateSpaceDraftRejectsMalformedParentID(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + + _, appErr := h.svc.CreateSpaceDraft(mmmodel.NewId(), space.Id, "Doc", "not-a-valid-id") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +// TestUpdatePageDraftRejectsResurrectionAfterNewPagePublish covers the else-if-!ok branch of the +// resurrection guard: a late autosave with no edit baseline (new-page path) must be rejected after +// the draft has already been consumed by publish, rather than recreating a phantom draft. +func TestUpdatePageDraftRejectsResurrectionAfterNewPagePublish(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Doc", "") + require.Nil(t, appErr) + pageID := draft.PageId + + _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: pageID, Body: docWith("v1")}, nil, nil, nil, "") + require.Nil(t, appErr) + _, _, appErr = h.svc.PublishPageDraft(userID, space.Id, pageID, false) + require.Nil(t, appErr) + + // Late autosave with no baseline: the draft is gone, so this must be rejected. + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: pageID, Body: docWith("late"), + }, nil, nil, nil, "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusConflict, appErr.StatusCode) + + // No phantom draft must have been created. + _, appErr = h.svc.GetPageDraft(userID, space.Id, pageID) + require.NotNil(t, appErr) + require.Equal(t, http.StatusNotFound, appErr.StatusCode) +} + +// TestUpdatePageDraftRejectsDraftParentCycle covers the cycle-detection guard: setting a draft's +// parent to a page that already (transitively) points back to the draft must be rejected. +func TestUpdatePageDraftRejectsDraftParentCycle(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + draftA, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "A", "") + require.Nil(t, appErr) + draftB, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "B", "") + require.Nil(t, appErr) + + // B → A is valid (A has no parent). + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: draftB.PageId, + }, &draftA.PageId, nil, nil, "") + require.Nil(t, appErr) + + // A → B would create A → B → A: a cycle. This must be rejected. + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: draftA.PageId, + }, &draftB.PageId, nil, nil, "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +func TestUpdatePageDraftRejectsDraftHierarchyTooDeep(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + // Build a chain of model.MaxPageDepth+1 drafts. The first draft is the root + // (no parent). Each subsequent draft sets its parent to the previous one. + // A chain of model.MaxPageDepth drafts fills the limit, so adding one more + // child is the first rejection. + const chainLen = model.MaxPageDepth + drafts := make([]*model.Draft, chainLen) + for i := range chainLen { + d, appErr := h.svc.CreateSpaceDraft(userID, space.Id, fmt.Sprintf("D%d", i), "") + require.Nil(t, appErr) + drafts[i] = d + } + for i := 1; i < chainLen; i++ { + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: drafts[i].PageId, + }, &drafts[i-1].PageId, nil, nil, "") + require.Nil(t, appErr, "chaining draft %d under draft %d must succeed", i, i-1) + } + + // Adding one more level (depth 11) must be rejected. + extra, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Extra", "") + require.Nil(t, appErr) + leaf := drafts[chainLen-1].PageId + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: extra.PageId, + }, &leaf, nil, nil, "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +func TestDeletePageDraftReparentsChildren(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + // Create a parent draft P and a child draft C that points to P. + draftP, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Parent", "") + require.Nil(t, appErr) + + draftC, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Child", "") + require.Nil(t, appErr) + + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: draftC.PageId, + }, &draftP.PageId, nil, nil, "") + require.Nil(t, appErr) + + // Discard the parent draft. + appErr = h.svc.DeletePageDraft(userID, space.Id, draftP.PageId, "") + require.Nil(t, appErr) + + // The parent must be gone. + _, appErr = h.svc.GetPageDraft(userID, space.Id, draftP.PageId) + require.NotNil(t, appErr) + require.Equal(t, http.StatusNotFound, appErr.StatusCode) + + // The child must still exist, reparented to the root (ParentId = ""). + got, appErr := h.svc.GetPageDraft(userID, space.Id, draftC.PageId) + require.Nil(t, appErr) + require.Equal(t, "", got.ParentId, "child must be reparented to root after parent draft is discarded") +} + +func TestGetPageDraftCrossSpaceReturns404(t *testing.T) { + h := openTestService(t) + spaceA := mustCreateSpace(t, h.store, mmmodel.NewId()) + spaceB := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + draft, appErr := h.svc.CreateSpaceDraft(userID, spaceA.Id, "Doc", "") + require.Nil(t, appErr) + + // GetPageDraft through spaceB must return 404, not expose the draft. + _, appErr = h.svc.GetPageDraft(userID, spaceB.Id, draft.PageId) + require.NotNil(t, appErr) + require.Equal(t, http.StatusNotFound, appErr.StatusCode) +} + +func TestDeletePageDraftRejectsInvalidUserID(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + + appErr := h.svc.DeletePageDraft("bad-id", space.Id, mmmodel.NewId(), "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +func TestDeletePageDraftRejectsInvalidPageID(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + + appErr := h.svc.DeletePageDraft(mmmodel.NewId(), space.Id, "bad-id", "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +func TestPublishNewPageRejectsCrossSpaceParent(t *testing.T) { + h := openTestService(t) + spaceA := mustCreateSpace(t, h.store, mmmodel.NewId()) + spaceB := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + // Publish a live parent in spaceA. + parentInA := publishNewPage(t, h, spaceA.Id, userID, "Parent", "p") + + // The store rejects a draft whose ParentId points at a page in another space. + // To simulate the race (parent was in the same space when editing started, then moved), + // inject the draft row directly, bypassing the app-layer parent check. + pageID := mmmodel.NewId() + now := mmmodel.GetMillis() + _, err := h.db.Exec( + `INSERT INTO docs_draft (userid,spaceid,pageid,parentid,title,body,fileids,props,createat,updateat,lastactiveat) + VALUES ($1,$2,$3,$4,$5,$6,'[]','{}', $7,$7,$7)`, + userID, spaceB.Id, pageID, parentInA.Id, "Child", docWith("body"), now, + ) + require.NoError(t, err) + + // Publishing must reject: the parent lives in a different space than the draft's space. The guard + // collapses "cross-space" and "not a live page" into a single parent_unpublished 409 so the + // response can't be used to probe page ids in spaces the caller cannot read. + _, _, appErr := h.svc.PublishPageDraft(userID, spaceB.Id, pageID, false) + require.NotNil(t, appErr) + require.Equal(t, http.StatusConflict, appErr.StatusCode, "cross-space parent must be rejected: %v", appErr) + require.Equal(t, "app.page_draft.publish.parent_unpublished.app_error", appErr.Id) +} + +func TestGetPageActiveEditorsRejectsInvalidPageID(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + + _, appErr := h.svc.GetPageActiveEditors("not-valid", space.Id) + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +func TestCreateSpaceDraftEnforcesQuota(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + // Fill the user's quota by inserting rows directly; calling CreateSpaceDraft 100 times + // would be slow and the store enforcement path (inside UpsertDraft's transaction) is what + // we're testing. + now := mmmodel.GetMillis() + for i := range model.MaxDraftsPerUserPerSpace { + pageID := mmmodel.NewId() + title := fmt.Sprintf("draft-%d", i) + _, err := h.db.Exec( + `INSERT INTO docs_draft (userid,spaceid,pageid,parentid,title,body,fileids,props,createat,updateat,lastactiveat) + VALUES ($1,$2,$3,'',$4,'[]','[]','{}', $5,$5,$5)`, + userID, space.Id, pageID, title, now, + ) + require.NoError(t, err) + } + + _, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "One Too Many", "") + require.NotNil(t, appErr) + // 422, not 429: the cap is a standing per-space quota, so retrying cannot clear it. + require.Equal(t, http.StatusUnprocessableEntity, appErr.StatusCode) + require.Equal(t, "app.page_draft.quota_exceeded.app_error", appErr.Id) +} + +// TestGetPageDraftRejectsInvalidIDs exercises GetPageDraft's three input-validation branches at +// the service layer, independent of HTTP routing. +func TestGetPageDraftRejectsInvalidIDs(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID, pageID := mmmodel.NewId(), mmmodel.NewId() + + cases := []struct { + name string + userID, spaceID, pageID string + }{ + {"invalid user id", "not-valid", space.Id, pageID}, + {"invalid space id", userID, "not-valid", pageID}, + {"invalid page id", userID, space.Id, "not-valid"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, appErr := h.svc.GetPageDraft(tc.userID, tc.spaceID, tc.pageID) + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) + }) + } +} + +// TestGetPageDraftsForSpaceRejectsInvalidIDs exercises the list endpoint's service-level input +// validation, which handler tests reach only through valid routes. +func TestGetPageDraftsForSpaceRejectsInvalidIDs(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + + _, _, appErr := h.svc.GetPageDraftsForSpace("not-valid", space.Id, 0, 10) + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) + + _, _, appErr = h.svc.GetPageDraftsForSpace(mmmodel.NewId(), "not-valid", 0, 10) + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +func TestGetPageActiveEditorsRejectsInvalidSpaceID(t *testing.T) { + h := openTestService(t) + + _, appErr := h.svc.GetPageActiveEditors(mmmodel.NewId(), "not-valid") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +// TestPublishForceAppliesDraftFieldOverConcurrentEdit covers the force-publish interaction where +// the concurrent edit touched BOTH a field the draft changed and one it did not: the draft's +// value must win for the field it carries, while the concurrent value survives for the field the +// draft left unset. +func TestPublishForceAppliesDraftFieldOverConcurrentEdit(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + page := publishNewPage(t, h, space.Id, userID, "Original title", "original body") + baseEditAt := page.EditAt + + // A title-only edit draft baselined at the current EditAt. + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Draft title", + BaseEditAt: baseEditAt, + }, nil, nil, nil, "") + require.Nil(t, appErr) + + // A concurrent edit changes the title AND the body, advancing EditAt past the baseline. + concurrentTitle := "Concurrent title" + concurrentBody := docWith("concurrent body") + _, appErr = h.svc.UpdatePage(page.Id, space.Id, + &model.PagePatch{Title: &concurrentTitle, Body: &concurrentBody}, new(baseEditAt), false, userID) + require.Nil(t, appErr) + + forced, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, true) + require.Nil(t, appErr) + require.Equal(t, "Draft title", forced.Title, "the field the draft carries must win under force") + require.Contains(t, forced.Body, "concurrent body", "a field the draft never set must keep the concurrent value") +} + +// TestUpdatePageDraftRejectsOversizedFileIds covers the inline fileIDs size guard: fileIDs travels +// to the store as a separate write-intent pointer, so Draft.IsValid never sees it and this guard +// is the only bound on the write path. +func TestUpdatePageDraftRejectsOversizedFileIds(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + // 12 valid ids serialize to ~349 runes, over the DraftFileIdsMaxRunes=300 cap. + ids := make(mmmodel.StringArray, 0, 12) + for range 12 { + ids = append(ids, mmmodel.NewId()) + } + + _, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: mmmodel.NewId(), Title: "x"}, nil, &ids, nil, "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) + require.Equal(t, "model.draft.is_valid.file_ids.app_error", appErr.Id) +} + +// TestUpdatePageDraftRejectsOversizedProps covers the inline props size guard: props travels to the +// store as a separate write-intent pointer, so Draft.IsValid checks the (empty) draft.Props field +// and this guard is the only bound on the written value. +func TestUpdatePageDraftRejectsOversizedProps(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + props := mmmodel.StringInterface{"k": strings.Repeat("x", model.PagePropsMaxBytes)} + _, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: mmmodel.NewId(), Title: "x"}, nil, nil, &props, "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) + require.Equal(t, "model.shared.props_too_large.app_error", appErr.Id) +} + +// TestUpdatePageDraftRejectsInvalidFileId covers the per-entry id check on the fileIDs write +// intent, including the empty entry an empty-slice clear must not admit. +func TestUpdatePageDraftRejectsInvalidFileId(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + for name, entry := range map[string]string{"malformed id": "not-a-valid-id", "empty entry": ""} { + t.Run(name, func(t *testing.T) { + ids := mmmodel.StringArray{entry} + _, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: mmmodel.NewId(), Title: "x"}, nil, &ids, nil, "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) + require.Equal(t, "model.draft.is_valid.file_id.app_error", appErr.Id) + }) + } +} diff --git a/server/app/page_duplicate_test.go b/server/app/page_duplicate_test.go index c7a8f5f..ffc81f6 100644 --- a/server/app/page_duplicate_test.go +++ b/server/app/page_duplicate_test.go @@ -12,7 +12,6 @@ import ( mmmodel "github.com/mattermost/mattermost/server/public/model" - "github.com/mattermost/mattermost-plugin-docs/server/app" "github.com/mattermost/mattermost-plugin-docs/server/model" ) @@ -50,16 +49,17 @@ func TestServiceDuplicatePage(t *testing.T) { } // TestServiceDuplicatePage_CopiesSearchText verifies the duplicate carries the source's SearchText -// verbatim (alongside the body) so it is immediately searchable like its source. +// (alongside the body) so it is immediately searchable like its source. SearchText is derived +// server-side from the body, so a caller-supplied value is ignored. func TestServiceDuplicatePage_CopiesSearchText(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() space := mustCreateSpace(t, h.store, channelID) userID := mmmodel.NewId() - source, appErr := h.svc.CreatePage(space.Id, "", "Searchable", "body text", "search text", userID) + source, appErr := h.svc.CreatePage(space.Id, "", "Searchable", "body text", userID) require.Nil(t, appErr) - require.Equal(t, "search text", source.SearchText) + require.Equal(t, "body text", source.SearchText, "SearchText is derived from the body, not the caller-supplied value") dup, appErr := h.svc.DuplicatePage(source.Id, space, userID, false, nil, nil) require.Nil(t, appErr) @@ -76,7 +76,7 @@ func TestServiceDuplicatePage_CopiesProps(t *testing.T) { space := mustCreateSpace(t, h.store, channelID) userID := mmmodel.NewId() - source, appErr := h.svc.CreatePage(space.Id, "", "Has Props", "body text", "", userID) + source, appErr := h.svc.CreatePage(space.Id, "", "Has Props", "body text", userID) require.Nil(t, appErr) // "nested" round-trips through the store as a plain map[string]any (JSON decoding never @@ -108,7 +108,7 @@ func TestServiceDuplicatePage_TruncatesLongTitle(t *testing.T) { userID := mmmodel.NewId() longTitle := strings.Repeat("x", model.PageTitleMaxRunes) - source, appErr := h.svc.CreatePage(space.Id, "", longTitle, "", "", userID) + source, appErr := h.svc.CreatePage(space.Id, "", longTitle, "", userID) require.Nil(t, appErr) dup, appErr := h.svc.DuplicatePage(source.Id, space, userID, false, nil, nil) @@ -205,7 +205,7 @@ func TestServiceDuplicatePage_MaxDepthExceeded(t *testing.T) { // copied under it would land at (MaxPageDepth-1)+2 = MaxPageDepth+1 > MaxPageDepth. var deepest *model.Page parentID := "" - for range app.MaxPageDepth { + for range model.MaxPageDepth { deepest = mustCreatePage(t, h.store, space.Id, channelID, userID, parentID) parentID = deepest.Id } @@ -343,7 +343,7 @@ func TestServiceDuplicatePage_IncludeChildren_MaxDepthExceeded(t *testing.T) { // destinationDepth = (MaxPageDepth-2)+2 = MaxPageDepth — one level short of the cap. var deepest *model.Page parentID := "" - for range app.MaxPageDepth - 1 { + for range model.MaxPageDepth - 1 { deepest = mustCreatePage(t, h.store, space.Id, channelID, userID, parentID) parentID = deepest.Id } diff --git a/server/app/page_hierarchy.go b/server/app/page_hierarchy.go index 8c938e1..1314c06 100644 --- a/server/app/page_hierarchy.go +++ b/server/app/page_hierarchy.go @@ -140,7 +140,7 @@ func (s *Service) MovePage(pageID, spaceID string, newParentID *string, newIndex // committed in between (surviving here via force) would make the earlier-read parent stale and // point clients at the wrong subtree to invalidate. func (s *Service) reparentWithinSpace(where, pageID, spaceID string, newParentID *string, newIndex *int64, expectedUpdateAt *int64, force bool) (*model.Page, *mmmodel.AppError) { - moved, priorParentID, didMove, storeErr := s.store.MovePage(pageID, spaceID, newParentID, newIndex, mmmodel.SafeDereference(expectedUpdateAt), force, MaxPageDepth) + moved, priorParentID, didMove, storeErr := s.store.MovePage(pageID, spaceID, newParentID, newIndex, mmmodel.SafeDereference(expectedUpdateAt), force, model.MaxPageDepth) if storeErr != nil { return nil, storeAppError(where, storeErr) } @@ -161,9 +161,9 @@ func (s *Service) reparentWithinSpace(where, pageID, spaceID string, newParentID // store.MovePageToSpace and surface through storeAppError's shared message keys. // A nil expectedUpdateAt without force is rejected: the mutation must supply a baseline. // sourceSpace and targetSpace are the caller's already-fetched records (from its membership -// gates), so no re-read happens here. userID is the acting user, recorded in logs only — a -// move does not change the page's LastModifiedBy. Per-page restrictions and redirects are not -// handled yet. +// gates), so no re-read happens here. userID is the acting user and must be a valid ID: the store +// scopes the target-space draft quota to the drafts it owns. It does not change the page's +// LastModifiedBy. Per-page restrictions and redirects are not handled yet. func (s *Service) MovePageToSpace(pageID string, sourceSpace, targetSpace *model.Space, parentPageID *string, expectedUpdateAt *int64, force bool, userID string) (*model.Page, *mmmodel.AppError) { if !mmmodel.IsValidId(pageID) { return nil, mmmodel.NewAppError("MovePageToSpace", "app.page.move_to_space.invalid_id.app_error", nil, "", http.StatusBadRequest) @@ -231,7 +231,7 @@ func (s *Service) MovePageToSpace(pageID string, sourceSpace, targetSpace *model s.log.Debug("Moving page to space", "page_id", pageID, "source_space_id", sourceSpace.Id, "target_space_id", targetSpace.Id, "user_id", userID) - moved, priorParentID, storeErr := s.store.MovePageToSpace(pageID, sourceSpace.Id, targetSpace.Id, parentPageID, mmmodel.SafeDereference(expectedUpdateAt), force, MaxPageDepth) + moved, priorParentID, storeErr := s.store.MovePageToSpace(pageID, sourceSpace.Id, targetSpace.Id, userID, parentPageID, mmmodel.SafeDereference(expectedUpdateAt), force, model.MaxPageDepth) if storeErr != nil { return nil, storeAppError("MovePageToSpace", storeErr) } diff --git a/server/app/page_move_test.go b/server/app/page_move_test.go index 1151402..7cadf96 100644 --- a/server/app/page_move_test.go +++ b/server/app/page_move_test.go @@ -10,7 +10,6 @@ import ( mmmodel "github.com/mattermost/mattermost/server/public/model" - "github.com/mattermost/mattermost-plugin-docs/server/app" "github.com/mattermost/mattermost-plugin-docs/server/model" "github.com/mattermost/mattermost-plugin-docs/server/store" ) @@ -186,7 +185,7 @@ func TestServiceMovePage_ErrorPaths(t *testing.T) { // A chain of MaxPageDepth pages: the deepest has MaxPageDepth-1 ancestors, so a leaf // moved under it would land at depth (MaxPageDepth-1)+2 = MaxPageDepth+1 > MaxPageDepth. - chain := buildPageChain(t, h.store, space.Id, channelID, userID, app.MaxPageDepth) + chain := buildPageChain(t, h.store, space.Id, channelID, userID, model.MaxPageDepth) deepest := chain[len(chain)-1] leaf := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -206,7 +205,7 @@ func TestServiceMovePage_ErrorPaths(t *testing.T) { // Anchor at depth MaxPageDepth-1 (MaxPageDepth-2 ancestors): a moved page lands at // (MaxPageDepth-2)+2 = MaxPageDepth (passes the per-page check), but its one child would // be one deeper, MaxPageDepth+1, tripping the subtree check. - chain := buildPageChain(t, h.store, space.Id, channelID, userID, app.MaxPageDepth-1) + chain := buildPageChain(t, h.store, space.Id, channelID, userID, model.MaxPageDepth-1) anchor := chain[len(chain)-1] subtreeRoot := mustCreatePage(t, h.store, space.Id, channelID, userID, "") diff --git a/server/app/page_move_to_space_test.go b/server/app/page_move_to_space_test.go index c4e3a4a..a58c6cb 100644 --- a/server/app/page_move_to_space_test.go +++ b/server/app/page_move_to_space_test.go @@ -12,7 +12,6 @@ import ( mmmodel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin/plugintest" - "github.com/mattermost/mattermost-plugin-docs/server/app" "github.com/mattermost/mattermost-plugin-docs/server/internal/testutil" "github.com/mattermost/mattermost-plugin-docs/server/model" "github.com/mattermost/mattermost-plugin-docs/server/store" @@ -206,7 +205,7 @@ func TestServiceMovePageToSpace_RejectsDepthExceeded(t *testing.T) { // Build a chain in spaceB down to MaxPageDepth; a child under the deepest node would breach it. parentID := "" - for range app.MaxPageDepth { + for range model.MaxPageDepth { p := mustCreatePage(t, h.store, spaceB.Id, chB, user, parentID) parentID = p.Id } diff --git a/server/app/page_presence.go b/server/app/page_presence.go new file mode 100644 index 0000000..5800e80 --- /dev/null +++ b/server/app/page_presence.go @@ -0,0 +1,207 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "net/http" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + + "github.com/mattermost/mattermost-plugin-docs/server/model" +) + +// ActiveEditorTimeoutMs is the window within which a draft autosave keeps a user counted as an +// active editor. Presence is derived from the shared DOCS_Draft table: the editor's autosave is +// the heartbeat, so an editor with a draft updated inside this window is "active". Because the +// list comes from the master DB, it is correct across an HA cluster. +const ActiveEditorTimeoutMs int64 = 5 * 60 * 1000 + +// presenceBroadcastMinIntervalMs is the minimum time between autosave-triggered presence broadcasts +// for the same page. Delete and publish paths always broadcast regardless of this interval. +const presenceBroadcastMinIntervalMs int64 = 30 * 1000 + +// presenceBroadcastSweepIntervalMs is the minimum time between sweeps of the broadcast rate-limit +// map. Sweeping is opportunistic — it runs on the first autosave after the interval has elapsed — so +// an autosave performs a full scan of the map at most once per interval. +const presenceBroadcastSweepIntervalMs int64 = 5 * 60 * 1000 + +func activeEditorSince() int64 { + return mmmodel.GetMillis() - ActiveEditorTimeoutMs +} + +func presenceBroadcastKey(pageID, userID string) string { + return pageID + ":" + userID +} + +// clearPresenceThrottle removes the (pageID, userID) broadcast rate-limit entry, so the next +// broadcast for that editor is not suppressed and a later session starts unthrottled. Called +// whenever a draft session ends. +func (s *Service) clearPresenceThrottle(pageID, userID string) { + s.presenceBroadcastTimes.Delete(presenceBroadcastKey(pageID, userID)) +} + +// sweepPresenceBroadcastTimes removes stale entries from the broadcast rate-limit map to bound its +// size. An entry is normally removed when its session ends (discard or publish); this sweep is the +// fallback for sessions abandoned without either. +// +// It removes entries older than the active-editor window (ActiveEditorTimeoutMs). Removing such an +// entry cannot change behavior: the map only suppresses a broadcast within +// presenceBroadcastMinIntervalMs of the stored time, and an entry this old is already far past that +// window, so the next autosave broadcasts whether or not the entry is still present. CompareAndDelete +// removes only an entry whose value is unchanged, so one a concurrent autosave just refreshed is +// left in place. +func (s *Service) sweepPresenceBroadcastTimes(now int64) { + last := s.lastPresenceSweepAt.Load() + if now-last < presenceBroadcastSweepIntervalMs { + return + } + if !s.lastPresenceSweepAt.CompareAndSwap(last, now) { + return + } + + s.presenceBroadcastTimes.Range(func(key, value any) bool { + if ts, ok := value.(int64); ok && now-ts >= ActiveEditorTimeoutMs { + s.presenceBroadcastTimes.CompareAndDelete(key, value) + } + return true + }) +} + +// getActiveEditors returns the user IDs currently editing pageID in spaceID — those with a draft +// updated within the active-editor window. The bool is true on success and false on a store +// failure; callers must skip the broadcast on failure to avoid publishing a spurious empty snapshot +// that would wrongly clear a valid presence indicator. Use this only where a best-effort answer is +// acceptable, not to back a REST read that must surface a store failure. +func (s *Service) getActiveEditors(pageID, spaceID string) ([]string, bool) { + editors, err := s.store.GetPageActiveEditors(pageID, spaceID, activeEditorSince()) + if err != nil { + s.log.Warn("failed to query active editors; skipping broadcast", + "page_id", pageID, "err", err) + return nil, false + } + return editors, true +} + +// publishSelfPresence sends a presence snapshot to userID only. Used when the page is not yet +// published (no channel to broadcast to), so only the author's own UI learns of the session: +// editors is the author's own ID while the session is active, and empty when it ends. +func (s *Service) publishSelfPresence(userID, pageID, spaceID string, editors []string) { + s.publishToUser(wsEventPagePresenceUpdated, map[string]any{ + "page_id": pageID, + "space_id": spaceID, + "active_editors": editors, + "snapshot_at": mmmodel.GetMillis(), + "active_timeout_ms": ActiveEditorTimeoutMs, + }, userID) +} + +// broadcastPagePresence fans a page_presence_updated event out to the space audience on channelID +// (the space's backing channel), carrying the current presence snapshot. +// Best-effort: failures are logged, never surfaced. The return value reports whether a snapshot was +// actually published, so a throttling caller can release its claimed slot on failure; a nil client +// (store-only unit tests) is a deliberate no-op, not a failure. +// +// Broadcasts fire only on user actions (autosave, discard, publish), never periodically, so a client +// that receives no newer snapshot cannot distinguish a still-active editor from one whose session +// ended abnormally. active_timeout_ms lets it expire the snapshot's editors on its own once +// snapshot_at + active_timeout_ms has passed. +func (s *Service) broadcastPagePresence(pageID, spaceID, channelID string) bool { + if s.client == nil { + return true + } + // Stamp snapshot_at before the editors query so it marks when the snapshot was taken, not when the + // broadcast finished assembling — clients use it to discard out-of-order snapshots. + snapshotAt := mmmodel.GetMillis() + editors, ok := s.getActiveEditors(pageID, spaceID) + if !ok { + return false + } + s.publishToChannels(wsEventPagePresenceUpdated, map[string]any{ + "page_id": pageID, + "space_id": spaceID, + "active_editors": editors, + "snapshot_at": snapshotAt, + "active_timeout_ms": ActiveEditorTimeoutMs, + }, channelID) + return true +} + +// maybeBroadcastDraftPresence performs the rate-limited presence broadcast that follows a +// successful autosave. The rate-limit bucket is keyed per (page, user) so each editor gets an +// independent limit — one editor's broadcast can't rate-limit another editor on the same page — +// and it covers both audiences below, so autosave cadence cannot flood either. +// +// The claimed slot is released again when the channel broadcast fails (a store error while +// assembling the snapshot), so the next autosave retries immediately instead of waiting out the +// full throttle window on an attempt that published nothing. +func (s *Service) maybeBroadcastDraftPresence(pageWasLive bool, pageID, userID, spaceID, channelID string) { + presenceKey := presenceBroadcastKey(pageID, userID) + now := mmmodel.GetMillis() + s.sweepPresenceBroadcastTimes(now) + existing, loaded := s.presenceBroadcastTimes.LoadOrStore(presenceKey, now) + if loaded { + lastTime, ok := existing.(int64) + if !ok || now-lastTime < presenceBroadcastMinIntervalMs { + return + } + if !s.presenceBroadcastTimes.CompareAndSwap(presenceKey, existing, now) { + return + } + } + // New-page drafts (no published page row yet) must not broadcast presence to the space channel: + // that would expose the reserved page ID and the author's identity to all space members before + // the page exists. Send the event only to the author so their own UI can track the session. + if !pageWasLive { + s.publishSelfPresence(userID, pageID, spaceID, []string{userID}) + return + } + if !s.broadcastPagePresence(pageID, spaceID, channelID) { + s.presenceBroadcastTimes.CompareAndDelete(presenceKey, now) + } +} + +// endDraftPresenceSession clears the (pageID, userID) rate-limit entry — so the announcement below +// is never suppressed and a later session starts unthrottled — and announces the end of a draft +// session: channel-wide when the page is live, to the author alone otherwise (an unpublished +// new-page draft's session was never visible to the channel, so it ends the same way it was +// announced, with an empty editor set). +func (s *Service) endDraftPresenceSession(pageWasLive bool, pageID, userID, spaceID, channelID string) { + s.clearPresenceThrottle(pageID, userID) + if !pageWasLive { + s.publishSelfPresence(userID, pageID, spaceID, []string{}) + return + } + s.broadcastPagePresence(pageID, spaceID, channelID) +} + +// GetPageActiveEditors returns the editor-presence snapshot for the given page in the given space, +// after confirming the page exists in that space. Returns 404 if the page is unknown or belongs to +// another space; store failures are propagated (unlike the best-effort getActiveEditors, this backs +// a REST read that must not report "nobody editing" when the query actually failed). +func (s *Service) GetPageActiveEditors(pageID, spaceID string) (*model.PageActiveEditors, *mmmodel.AppError) { + if !mmmodel.IsValidId(pageID) { + return nil, mmmodel.NewAppError("GetPageActiveEditors", "app.page.presence.invalid_page_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(spaceID) { + return nil, mmmodel.NewAppError("GetPageActiveEditors", "app.page.presence.invalid_space_id.app_error", nil, "", http.StatusBadRequest) + } + exists, err := s.store.PageExistsInSpace(pageID, spaceID) + if err != nil { + return nil, storeAppError("GetPageActiveEditors", err) + } + if !exists { + return nil, mmmodel.NewAppError("GetPageActiveEditors", "app.page.not_found.app_error", nil, "", http.StatusNotFound) + } + // Stamp snapshot_at before the query so it marks when the snapshot was taken, matching the WS event. + snapshotAt := mmmodel.GetMillis() + editors, storeErr := s.store.GetPageActiveEditors(pageID, spaceID, snapshotAt-ActiveEditorTimeoutMs) + if storeErr != nil { + return nil, storeAppError("GetPageActiveEditors", storeErr) + } + return &model.PageActiveEditors{ + ActiveEditors: editors, + SnapshotAt: snapshotAt, + ActiveTimeoutMs: ActiveEditorTimeoutMs, + }, nil +} diff --git a/server/app/page_presence_test.go b/server/app/page_presence_test.go new file mode 100644 index 0000000..4426d5b --- /dev/null +++ b/server/app/page_presence_test.go @@ -0,0 +1,49 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestActiveEditorTimeoutMsValue pins the active-editor window to its contract value. The other +// presence tests assert response fields equal this const (verifying wiring), so this is the one +// place that guards the value itself. +func TestActiveEditorTimeoutMsValue(t *testing.T) { + require.Equal(t, int64(5*60*1000), ActiveEditorTimeoutMs) +} + +// TestSweepPresenceBroadcastTimesEvictsStaleEntriesOncePerWindow verifies sweepPresenceBroadcastTimes's +// two behaviors: it evicts entries older than ActiveEditorTimeoutMs while leaving fresh entries in +// place, and it runs at most once per presenceBroadcastSweepIntervalMs — a second call with the same +// `now` must be a no-op even if a new stale entry was added in between. +func TestSweepPresenceBroadcastTimesEvictsStaleEntriesOncePerWindow(t *testing.T) { + svc := &Service{} + + now := int64(1_000_000_000) + staleKey := "stale-page:stale-user" + freshKey := "fresh-page:fresh-user" + svc.presenceBroadcastTimes.Store(staleKey, now-2*ActiveEditorTimeoutMs) + svc.presenceBroadcastTimes.Store(freshKey, now) + + // Open the gate: make the sweep consider itself overdue. + svc.lastPresenceSweepAt.Store(now - presenceBroadcastSweepIntervalMs) + + svc.sweepPresenceBroadcastTimes(now) + + _, staleStillPresent := svc.presenceBroadcastTimes.Load(staleKey) + require.False(t, staleStillPresent, "a stale entry must be evicted by the sweep") + _, freshStillPresent := svc.presenceBroadcastTimes.Load(freshKey) + require.True(t, freshStillPresent, "a fresh entry must survive the sweep") + + // Re-seed a stale entry and call again with the same `now`: the gate must be closed (at most one + // sweep per ActiveEditorTimeoutMs), so this entry must NOT be evicted. + svc.presenceBroadcastTimes.Store(staleKey, now-2*ActiveEditorTimeoutMs) + svc.sweepPresenceBroadcastTimes(now) + + _, stillThere := svc.presenceBroadcastTimes.Load(staleKey) + require.True(t, stillThere, "a second call within the sweep interval must be a no-op") +} diff --git a/server/app/pagination.go b/server/app/pagination.go index afd9cb3..22f8c50 100644 --- a/server/app/pagination.go +++ b/server/app/pagination.go @@ -9,13 +9,7 @@ const PageMaximum = 1 << 20 // ClampPage normalizes a requested page index into [0, PageMaximum]. func ClampPage(page int) int { - if page < 0 { - return 0 - } - if page > PageMaximum { - return PageMaximum - } - return page + return min(max(page, 0), PageMaximum) } // PerPageDefault is the page size used when perPage is not a positive value, matching diff --git a/server/app/service.go b/server/app/service.go index 0ee955c..6091b95 100644 --- a/server/app/service.go +++ b/server/app/service.go @@ -10,6 +10,8 @@ import ( "errors" "net/http" "strings" + "sync" + "sync/atomic" "unicode/utf8" mmmodel "github.com/mattermost/mattermost/server/public/model" @@ -37,6 +39,23 @@ type Service struct { store *store.Store log Logger client *pluginapi.Client + + // presenceBroadcastTimes records the last channel-wide presence broadcast time (ms) per + // (pageID, userID). Autosave cadence is client-driven and unbounded server-side, and every autosave + // on a live page would otherwise fan a presence event out to the whole channel, so this caps those + // broadcasts to at most one per presenceBroadcastMinIntervalMs per (page, user). Delete and publish + // paths bypass this and always broadcast. + // + // The map is per-process, so each node throttles independently: a user whose autosaves are + // spread across nodes can broadcast more often than the interval implies. That is acceptable — + // the payload is queried fresh from the shared DB on every broadcast, so the throttle only + // trades broadcast volume, never correctness. Entries are dropped on discard and publish, and + // swept by age via sweepPresenceBroadcastTimes for sessions abandoned without either. + presenceBroadcastTimes sync.Map + + // lastPresenceSweepAt is the timestamp (ms) of the most recent presenceBroadcastTimes sweep, used + // to rate-limit the sweep itself to once per presenceBroadcastSweepIntervalMs. + lastPresenceSweepAt atomic.Int64 } // New creates a Service wired to the given store, logger, and optional pluginapi client. @@ -129,6 +148,11 @@ func storeAppError(where string, err error) *mmmodel.AppError { return mmmodel.NewAppError(where, "app.page.max_depth_exceeded.app_error", map[string]any{"MaxDepth": limitErr.Limit}, "", http.StatusBadRequest).Wrap(err) case store.ReasonSubtreeMaxDepthExceeded: return mmmodel.NewAppError(where, "app.page.subtree_max_depth_exceeded.app_error", map[string]any{"MaxDepth": limitErr.Limit}, "", http.StatusBadRequest).Wrap(err) + case store.ReasonDraftQuotaExceeded: + // 422, not 429: the caller is over a standing per-space draft cap, not sending too many + // requests. Retrying the same request never succeeds until a draft is discarded, so a + // rate-limit code would send clients into a wait-and-retry loop. + return mmmodel.NewAppError(where, "app.page_draft.quota_exceeded.app_error", nil, "", http.StatusUnprocessableEntity).Wrap(err) } return mmmodel.NewAppError(where, "app.store.too_large.app_error", map[string]any{"Limit": limitErr.Limit}, "", http.StatusUnprocessableEntity).Wrap(err) default: @@ -143,11 +167,16 @@ func storeAppError(where string, err error) *mmmodel.AppError { func invalidInputAppError(where string, err error) *mmmodel.AppError { var invErr *store.ErrInvalidInput if errors.As(err, &invErr) && invErr.Reason != "" { - if invErr.Reason == store.ReasonParentNotLive { + switch invErr.Reason { + case store.ReasonParentNotLive: // Same key as the app-layer parent pre-checks (validateParentExists), so a parent // that disappears between the pre-check and the store's locked check reads // identically to one that never existed — the contract is not race-dependent. return mmmodel.NewAppError(where, "app.page.invalid_parent.app_error", nil, "", http.StatusBadRequest).Wrap(err) + case store.ReasonDraftCycle: + return mmmodel.NewAppError(where, "app.page_draft.update.parent_cycle.app_error", nil, "", http.StatusBadRequest).Wrap(err) + case store.ReasonDraftTooDeep: + return mmmodel.NewAppError(where, "app.page_draft.update.parent_too_deep.app_error", nil, "", http.StatusBadRequest).Wrap(err) } return mmmodel.NewAppError(where, invErr.Reason, nil, "", http.StatusBadRequest).Wrap(err) } diff --git a/server/app/service_test.go b/server/app/service_test.go index d88abab..977ed02 100644 --- a/server/app/service_test.go +++ b/server/app/service_test.go @@ -106,7 +106,7 @@ func TestServiceCreatePageParentDifferentSpace(t *testing.T) { otherSpace := mustCreateSpace(t, h.store, otherChannelID) rogueParent := mustCreatePage(t, h.store, otherSpace.Id, otherChannelID, userID, "") - _, err := h.svc.CreatePage(space.Id, rogueParent.Id, "Child", "", "", userID) + _, err := h.svc.CreatePage(space.Id, rogueParent.Id, "Child", "", userID) require.NotNil(t, err) require.Equal(t, http.StatusBadRequest, err.StatusCode) require.Equal(t, "app.page.invalid_parent.app_error", err.Id) @@ -206,10 +206,10 @@ func TestServiceUpdatePageSearchTextWithoutBody(t *testing.T) { require.Equal(t, "model.page.patch.search_text_body_mismatch.app_error", err.Id) } -// TestServiceUpdatePageBodyWithoutSearchText covers the inverse: a Body change with no -// accompanying SearchText would strand the GIN index on the page's old content, so the -// update-path guard rejects it too (both or neither). -func TestServiceUpdatePageBodyWithoutSearchText(t *testing.T) { +// TestServiceUpdatePageBodyDerivesSearchText verifies that a Body-only patch succeeds and its +// SearchText is derived server-side from the body, keeping the search index in sync without the +// caller having to supply it. +func TestServiceUpdatePageBodyDerivesSearchText(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() @@ -217,18 +217,16 @@ func TestServiceUpdatePageBodyWithoutSearchText(t *testing.T) { userID := mmmodel.NewId() created := mustCreatePage(t, h.store, space.Id, channelID, userID, "") - _, err := h.svc.UpdatePage( + updated, err := h.svc.UpdatePage( created.Id, created.SpaceId, &model.PagePatch{Body: mmmodel.NewPointer("new body")}, new(created.EditAt), false, userID, ) - require.NotNil(t, err) - require.Equal(t, http.StatusBadRequest, err.StatusCode) - require.Equal(t, "model.page.patch.search_text_body_mismatch.app_error", err.Id) + require.Nil(t, err) + require.Equal(t, "new body", updated.SearchText) } -// TestServiceUpdatePageSearchTextWithBodyCleared verifies that clearing Body to "" while -// setting a non-empty SearchText is rejected: SearchText is the body's plain-text projection -// and must not survive an emptied body (mirrors the create-path rule). -func TestServiceUpdatePageSearchTextWithBodyCleared(t *testing.T) { +// TestServiceUpdatePageSearchTextIgnoredOnBodyClear verifies that clearing Body to "" derives an +// empty SearchText regardless of the caller-supplied value — SearchText is the body's projection. +func TestServiceUpdatePageSearchTextIgnoredOnBodyClear(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() @@ -236,14 +234,14 @@ func TestServiceUpdatePageSearchTextWithBodyCleared(t *testing.T) { userID := mmmodel.NewId() created := mustCreatePage(t, h.store, space.Id, channelID, userID, "") - _, err := h.svc.UpdatePage( + updated, err := h.svc.UpdatePage( created.Id, created.SpaceId, - &model.PagePatch{Body: mmmodel.NewPointer(""), SearchText: mmmodel.NewPointer("some text")}, + &model.PagePatch{Body: mmmodel.NewPointer(""), SearchText: mmmodel.NewPointer("ignored")}, new(created.EditAt), false, userID, ) - require.NotNil(t, err) - require.Equal(t, http.StatusBadRequest, err.StatusCode) - require.Equal(t, "model.page.patch.search_text_without_content.app_error", err.Id) + require.Nil(t, err) + require.Equal(t, "", updated.SearchText) + require.Equal(t, "", updated.Body) } // TestServiceUpdatePageClearSearchTextAlone verifies the coupling rule: clearing SearchText @@ -414,7 +412,7 @@ func TestServiceCreatePageDerivesChannelFromSpace(t *testing.T) { space := mustCreateSpace(t, h.store, channelID) userID := mmmodel.NewId() - created, err := h.svc.CreatePage(space.Id, "", "My Page", "", "", userID) + created, err := h.svc.CreatePage(space.Id, "", "My Page", "", userID) require.Nil(t, err) require.Equal(t, "My Page", created.Title) require.Equal(t, space.Id, created.SpaceId) @@ -428,34 +426,34 @@ func TestServiceCreatePage(t *testing.T) { userID := mmmodel.NewId() t.Run("rejects invalid space id", func(t *testing.T) { - _, err := h.svc.CreatePage("not-a-valid-id", "", "Title", "", "", userID) + _, err := h.svc.CreatePage("not-a-valid-id", "", "Title", "", userID) require.NotNil(t, err) require.Equal(t, http.StatusBadRequest, err.StatusCode) require.Equal(t, "app.page.create.invalid_space_id.app_error", err.Id) }) t.Run("rejects invalid user id", func(t *testing.T) { - _, err := h.svc.CreatePage(space.Id, "", "Title", "", "", "not-a-valid-id") + _, err := h.svc.CreatePage(space.Id, "", "Title", "", "not-a-valid-id") require.NotNil(t, err) require.Equal(t, http.StatusBadRequest, err.StatusCode) require.Equal(t, "app.page.create.invalid_user_id.app_error", err.Id) }) t.Run("rejects empty title", func(t *testing.T) { - _, err := h.svc.CreatePage(space.Id, "", " ", "", "", userID) + _, err := h.svc.CreatePage(space.Id, "", " ", "", userID) require.NotNil(t, err) require.Equal(t, 400, err.StatusCode) }) t.Run("rejects title too long", func(t *testing.T) { long := strings.Repeat("x", model.PageTitleMaxRunes+1) - _, err := h.svc.CreatePage(space.Id, "", long, "", "", userID) + _, err := h.svc.CreatePage(space.Id, "", long, "", userID) require.NotNil(t, err) require.Equal(t, 400, err.StatusCode) }) t.Run("rejects nonexistent parent", func(t *testing.T) { - _, err := h.svc.CreatePage(space.Id, mmmodel.NewId(), "Title", "", "", userID) + _, err := h.svc.CreatePage(space.Id, mmmodel.NewId(), "Title", "", userID) require.NotNil(t, err) require.Equal(t, 400, err.StatusCode) }) @@ -464,7 +462,7 @@ func TestServiceCreatePage(t *testing.T) { otherChannelID := mmmodel.NewId() otherSpace := mustCreateSpace(t, h.store, otherChannelID) parent := mustCreatePage(t, h.store, otherSpace.Id, otherChannelID, userID, "") - _, err := h.svc.CreatePage(space.Id, parent.Id, "Title", "", "", userID) + _, err := h.svc.CreatePage(space.Id, parent.Id, "Title", "", userID) require.NotNil(t, err) require.Equal(t, 400, err.StatusCode) }) @@ -476,18 +474,18 @@ func TestServiceCreatePage(t *testing.T) { // Build a full-depth chain (root at depth 1 up to MaxPageDepth); the next // child would be at depth MaxPageDepth+1 and must be rejected. parentID := "" - for range app.MaxPageDepth { - p, err := h.svc.CreatePage(depthSpace.Id, parentID, "d", "", "", userID) + for range model.MaxPageDepth { + p, err := h.svc.CreatePage(depthSpace.Id, parentID, "d", "", userID) require.Nil(t, err) parentID = p.Id } - _, err := h.svc.CreatePage(depthSpace.Id, parentID, "too deep", "", "", userID) + _, err := h.svc.CreatePage(depthSpace.Id, parentID, "too deep", "", userID) require.NotNil(t, err) require.Equal(t, 400, err.StatusCode) }) t.Run("creates a valid page", func(t *testing.T) { - created, err := h.svc.CreatePage(space.Id, "", "My Page", "", "", userID) + created, err := h.svc.CreatePage(space.Id, "", "My Page", "", userID) require.Nil(t, err) require.Equal(t, "My Page", created.Title) }) @@ -542,12 +540,12 @@ func TestServiceUpdatePageOversizedBody(t *testing.T) { _, err := h.svc.UpdatePage(created.Id, created.SpaceId, &model.PagePatch{Body: mmmodel.NewPointer(oversized), SearchText: mmmodel.NewPointer("")}, new(created.EditAt), false, mmmodel.NewId()) require.NotNil(t, err) require.Equal(t, http.StatusBadRequest, err.StatusCode) - require.Equal(t, "model.page.is_valid.body.app_error", err.Id) + require.Equal(t, "app.page.invalid_content.app_error", err.Id) } -// TestServiceUpdatePageOversizedSearchText verifies that the update path rejects oversized -// searchText with 400. -func TestServiceUpdatePageOversizedSearchText(t *testing.T) { +// TestServiceUpdatePageOversizedSearchTextIgnored verifies a caller-supplied oversized SearchText +// is harmless: it is ignored and SearchText is derived from the (small) body instead. +func TestServiceUpdatePageOversizedSearchTextIgnored(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() @@ -555,10 +553,9 @@ func TestServiceUpdatePageOversizedSearchText(t *testing.T) { created := mustCreatePage(t, h.store, space.Id, channelID, mmmodel.NewId(), "") oversized := strings.Repeat("x", model.PageSearchTextMaxBytes+1) - _, err := h.svc.UpdatePage(created.Id, created.SpaceId, &model.PagePatch{Title: mmmodel.NewPointer("Title"), Body: mmmodel.NewPointer("body"), SearchText: mmmodel.NewPointer(oversized)}, new(created.EditAt), false, mmmodel.NewId()) - require.NotNil(t, err) - require.Equal(t, http.StatusBadRequest, err.StatusCode) - require.Equal(t, "model.page.is_valid.search_text.app_error", err.Id) + updated, err := h.svc.UpdatePage(created.Id, created.SpaceId, &model.PagePatch{Title: mmmodel.NewPointer("Title"), Body: mmmodel.NewPointer("body"), SearchText: mmmodel.NewPointer(oversized)}, new(created.EditAt), false, mmmodel.NewId()) + require.Nil(t, err) + require.Equal(t, "body", updated.SearchText) } // TestServiceCreatePageOversizedBody verifies that CreatePage rejects a body that exceeds @@ -570,10 +567,10 @@ func TestServiceCreatePageOversizedBody(t *testing.T) { space := mustCreateSpace(t, h.store, channelID) oversized := strings.Repeat("x", model.PageBodyMaxBytes+1) - _, err := h.svc.CreatePage(space.Id, "", "Title", oversized, "", mmmodel.NewId()) + _, err := h.svc.CreatePage(space.Id, "", "Title", oversized, mmmodel.NewId()) require.NotNil(t, err) require.Equal(t, http.StatusBadRequest, err.StatusCode) - require.Equal(t, "model.page.is_valid.body.app_error", err.Id) + require.Equal(t, "app.page.invalid_content.app_error", err.Id) } // TestServiceUpdatePageCanSetEmptyBody verifies the patch contract: a non-nil empty Body @@ -599,18 +596,17 @@ func TestServiceUpdatePageCanSetEmptyBody(t *testing.T) { require.Equal(t, "", emptied.Body, "an explicit empty Body must clear the stored body") } -// TestServiceCreatePageSearchTextWithoutBody verifies CreatePage rejects searchText -// supplied without a body, matching the update path's rule. -func TestServiceCreatePageSearchTextWithoutBody(t *testing.T) { +// TestServiceCreatePageSearchTextDerivedFromBody verifies CreatePage ignores a caller-supplied +// SearchText and derives it from the body — so an empty body yields an empty SearchText. +func TestServiceCreatePageSearchTextDerivedFromBody(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() space := mustCreateSpace(t, h.store, channelID) - _, err := h.svc.CreatePage(space.Id, "", "Title", "", "searchtext", mmmodel.NewId()) - require.NotNil(t, err) - require.Equal(t, http.StatusBadRequest, err.StatusCode) - require.Equal(t, "app.page.create.search_text_without_content.app_error", err.Id) + created, err := h.svc.CreatePage(space.Id, "", "Title", "", mmmodel.NewId()) + require.Nil(t, err) + require.Equal(t, "", created.SearchText) } // TestServiceGetPageWithDeleted verifies the with-deleted reader returns a soft-deleted diff --git a/server/app/ws_events.go b/server/app/ws_events.go index 3053b08..62b0f8a 100644 --- a/server/app/ws_events.go +++ b/server/app/ws_events.go @@ -34,6 +34,11 @@ const ( wsEventPageMoved = "page_moved" wsEventPageDuplicated = "page_duplicated" wsEventPageMovedToSpace = "page_moved_to_space" + // Unlike the page_* events above — refetch signals carrying only the ids the client needs to + // refetch — wsEventPagePresenceUpdated carries the full presence snapshot inline + // ({page_id, space_id, active_editors, snapshot_at, active_timeout_ms}), so clients need no + // follow-up fetch. + wsEventPagePresenceUpdated = "page_presence_updated" wsEventSpaceCreated = "space_created" wsEventSpaceUpdated = "space_updated" @@ -50,12 +55,12 @@ func (s *Service) publishToChannels(event string, payload map[string]any, channe if s.client == nil { return } - seen := make(map[string]bool, len(channelIDs)) + seen := make(map[string]struct{}, len(channelIDs)) for _, chID := range channelIDs { - if chID == "" || seen[chID] { + if _, ok := seen[chID]; chID == "" || ok { continue } - seen[chID] = true + seen[chID] = struct{}{} s.client.Frontend.PublishWebSocketEvent(event, payload, &mmmodel.WebsocketBroadcast{ChannelId: chID}) } } diff --git a/server/app/ws_events_test.go b/server/app/ws_events_test.go index afd7b33..262a4b9 100644 --- a/server/app/ws_events_test.go +++ b/server/app/ws_events_test.go @@ -8,6 +8,7 @@ package app_test import ( + "slices" "testing" "github.com/stretchr/testify/mock" @@ -173,6 +174,300 @@ func TestServiceMovePageToSpace_NoOpPublishesNothing(t *testing.T) { mockAPI.AssertNotCalled(t, "PublishWebSocketEvent", "page_moved_to_space", mock.Anything, mock.Anything) } +// TestServiceUpdatePageDraft_PublishesPresenceEvent pins page_presence_updated: the presence-snapshot +// payload ({page_id, space_id, active_editors, snapshot_at}) — unlike the other page_* events, which carry +// only {page_id, space_id} as a change signal — broadcast to the space's backing channel. An autosave +// is the heartbeat, so the saving user appears in active_editors. +func TestServiceUpdatePageDraft_PublishesPresenceEvent(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space := mustCreateSpace(t, h.store, channelID) + + // Use a published page so UpdatePageDraft takes the channel-broadcast path. + page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", + BaseEditAt: page.EditAt, + }, nil, nil, nil, channelID) + require.Nil(t, appErr) + + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "page_presence_updated", + mock.MatchedBy(func(payload map[string]any) bool { + editors, ok := payload["active_editors"].([]string) + return ok && + payload["page_id"] == page.Id && + payload["space_id"] == space.Id && + payload["snapshot_at"] != nil && + payload["active_timeout_ms"] == app.ActiveEditorTimeoutMs && + slices.Contains(editors, userID) + }), + &mmmodel.WebsocketBroadcast{ChannelId: channelID}) +} + +// TestServiceUpdatePageDraft_PresenceBroadcastThrottled pins the autosave presence rate limit +// end-to-end: repeated autosaves for the same (page, user) inside presenceBroadcastMinIntervalMs +// broadcast page_presence_updated exactly once — the first autosave claims the throttle slot and +// the rest are suppressed. (Discard and publish bypass the throttle; they are pinned elsewhere.) +func TestServiceUpdatePageDraft_PresenceBroadcastThrottled(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space := mustCreateSpace(t, h.store, channelID) + page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + + presenceBroadcasts := func() int { + n := 0 + for _, c := range mockAPI.Calls { + if c.Method == "PublishWebSocketEvent" && len(c.Arguments) > 0 && c.Arguments[0] == "page_presence_updated" { + n++ + } + } + return n + } + + // publishNewPage ends the draft session (broadcasting presence and clearing the throttle), so + // count deltas from here rather than absolute totals. + before := presenceBroadcasts() + for range 3 { + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", + BaseEditAt: page.EditAt, + }, nil, nil, nil, channelID) + require.Nil(t, appErr) + } + require.Equal(t, before+1, presenceBroadcasts(), + "autosaves within the throttle window must broadcast presence exactly once") +} + +// TestServicePublishPageDraft_PublishesCreatedEvent pins that publishing a brand-new page's draft +// reuses page_created (not a draft-specific event): {page_id, space_id, parent_id} payload, +// broadcast to the new page's backing channel. Also pins the accompanying presence-clear broadcast. +func TestServicePublishPageDraft_PublishesCreatedEvent(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space := mustCreateSpace(t, h.store, channelID) + + page := publishNewPage(t, h, space.Id, userID, "Doc", "hello") + + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "page_created", + map[string]any{"page_id": page.Id, "space_id": space.Id, "parent_id": page.ParentId}, + &mmmodel.WebsocketBroadcast{ChannelId: channelID}) + + // The store publish transaction deletes the draft itself, bypassing DeletePageDraft, so PublishPageDraft broadcasts presence directly. + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "page_presence_updated", + mock.MatchedBy(func(payload map[string]any) bool { + editors, ok := payload["active_editors"].([]string) + return ok && + payload["page_id"] == page.Id && + payload["space_id"] == space.Id && + payload["snapshot_at"] != nil && + len(editors) == 0 + }), + &mmmodel.WebsocketBroadcast{ChannelId: channelID}) +} + +// TestServicePublishPageDraft_PublishesUpdatedEvent pins that publishing an edit to an existing page +// reuses page_updated: {page_id, space_id} payload, broadcast to the page's backing channel. +// Also pins the accompanying presence-clear broadcast. +func TestServicePublishPageDraft_PublishesUpdatedEvent(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space := mustCreateSpace(t, h.store, channelID) + page := publishNewPage(t, h, space.Id, userID, "Doc", "original") + + // Start an edit session against the live page's baseline, then publish it. + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", Body: docWith("edited"), + BaseEditAt: page.EditAt, + }, nil, nil, nil, "") + require.Nil(t, appErr) + + republished, wasCreated, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + require.Nil(t, appErr) + require.False(t, wasCreated, "publishing an edit to an existing page must report wasCreated=false") + + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "page_updated", + map[string]any{"page_id": republished.Id, "space_id": space.Id}, + &mmmodel.WebsocketBroadcast{ChannelId: channelID}) + + // The store publish transaction deletes the draft itself, bypassing DeletePageDraft, so PublishPageDraft broadcasts presence directly. + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "page_presence_updated", + mock.MatchedBy(func(payload map[string]any) bool { + editors, ok := payload["active_editors"].([]string) + return ok && + payload["page_id"] == republished.Id && + payload["space_id"] == space.Id && + payload["snapshot_at"] != nil && + payload["active_timeout_ms"] == app.ActiveEditorTimeoutMs && + len(editors) == 0 + }), + &mmmodel.WebsocketBroadcast{ChannelId: channelID}) +} + +// TestServiceDeletePageDraft_PublishesPresenceEvent pins that discarding a draft broadcasts +// page_presence_updated so the active-editors indicator drops the user; after the discard the +// snapshot is the empty set ([] not null). +func TestServiceDeletePageDraft_PublishesPresenceEvent(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space := mustCreateSpace(t, h.store, channelID) + + // Use a published page so the edit-draft delete takes the channel-broadcast path. + page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", + BaseEditAt: page.EditAt, + }, nil, nil, nil, channelID) + require.Nil(t, appErr) + + require.Nil(t, h.svc.DeletePageDraft(userID, space.Id, page.Id, channelID)) + + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "page_presence_updated", + mock.MatchedBy(func(payload map[string]any) bool { + editors, ok := payload["active_editors"].([]string) + return ok && + payload["page_id"] == page.Id && + payload["space_id"] == space.Id && + payload["snapshot_at"] != nil && + payload["active_timeout_ms"] == app.ActiveEditorTimeoutMs && + len(editors) == 0 + }), + &mmmodel.WebsocketBroadcast{ChannelId: channelID}) +} + +// TestServiceUpdatePageDraft_NewPageDraftPublishesToUserOnly pins that a draft for a not-yet-published +// page broadcasts presence only to the author — never to the space channel, which would leak the +// reserved page ID and author identity before the page exists. +func TestServiceUpdatePageDraft_NewPageDraftPublishesToUserOnly(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space := mustCreateSpace(t, h.store, channelID) + + // Create a new-page draft — no published page row exists yet. + draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Unpublished", "") + require.Nil(t, appErr) + + // Reset call log so only the following UpdatePageDraft broadcast is observed. + mockAPI.Calls = nil + + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: draft.PageId, Title: "Unpublished", + }, nil, nil, nil, channelID) + require.Nil(t, appErr) + + // The broadcast must be user-scoped: only the author learns about their own unreleased page. + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "page_presence_updated", + mock.MatchedBy(func(payload map[string]any) bool { + return payload["page_id"] == draft.PageId && payload["space_id"] == space.Id && + payload["active_timeout_ms"] == app.ActiveEditorTimeoutMs + }), + &mmmodel.WebsocketBroadcast{UserId: userID}) + + // Must not broadcast to the channel, which would expose the reserved page ID. + mockAPI.AssertNotCalled(t, "PublishWebSocketEvent", "page_presence_updated", + mock.Anything, + &mmmodel.WebsocketBroadcast{ChannelId: channelID}) +} + +// TestServiceDeletePageDraft_NewPageDraftClearsSelfPresenceOnly pins that discarding a draft for a +// not-yet-published page clears presence the same way the session was announced: an empty snapshot +// sent to the author only, never to the space channel (the session was never visible there). +func TestServiceDeletePageDraft_NewPageDraftClearsSelfPresenceOnly(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space := mustCreateSpace(t, h.store, channelID) + + // Create a new-page draft — no published page row exists yet. + draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Unpublished", "") + require.Nil(t, appErr) + + // Reset call log so only the discard's broadcast is observed. + mockAPI.Calls = nil + + require.Nil(t, h.svc.DeletePageDraft(userID, space.Id, draft.PageId, channelID)) + + // The clear must be user-scoped and carry the empty editor set ([] not null). + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "page_presence_updated", + mock.MatchedBy(func(payload map[string]any) bool { + editors, ok := payload["active_editors"].([]string) + return ok && + payload["page_id"] == draft.PageId && + payload["space_id"] == space.Id && + payload["snapshot_at"] != nil && + payload["active_timeout_ms"] == app.ActiveEditorTimeoutMs && + len(editors) == 0 + }), + &mmmodel.WebsocketBroadcast{UserId: userID}) + + // Must not broadcast to the channel: the draft was never visible there. + mockAPI.AssertNotCalled(t, "PublishWebSocketEvent", "page_presence_updated", + mock.Anything, + &mmmodel.WebsocketBroadcast{ChannelId: channelID}) +} + +// TestServiceUpdatePageDraft_PresenceRateLimitSuppressesSecondBroadcast verifies that a second +// autosave within presenceBroadcastMinIntervalMs does not trigger a second channel broadcast. +// The rate-limit prevents flooding the channel on every keystroke. +func TestServiceUpdatePageDraft_PresenceRateLimitSuppressesSecondBroadcast(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space := mustCreateSpace(t, h.store, channelID) + page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + + // Reset call log so only the two autosaves below are counted. + mockAPI.Calls = nil + + autosave := func() { + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", + BaseEditAt: page.EditAt, + }, nil, nil, nil, channelID) + require.Nil(t, appErr) + } + autosave() // first: should broadcast + autosave() // second: rate-limited, must not broadcast again + + // Count channel-scoped page_presence_updated calls. + n := 0 + for _, call := range mockAPI.Calls { + if call.Method != "PublishWebSocketEvent" || len(call.Arguments) < 3 { + continue + } + if call.Arguments[0] != "page_presence_updated" { + continue + } + if bc, ok := call.Arguments[2].(*mmmodel.WebsocketBroadcast); ok && bc.ChannelId != "" { + n++ + } + } + require.Equal(t, 1, n, "second autosave within rate-limit window must not broadcast presence to channel again") +} + // TestServiceCreateSpace_PublishesCreatedEvent pins space_created: space-id payload, broadcast // scoped to the backing channel (the visibility boundary the REST API enforces; the team space // list is filtered to the caller's channel memberships). diff --git a/server/model/draft.go b/server/model/draft.go index 592de7d..9ad4400 100644 --- a/server/model/draft.go +++ b/server/model/draft.go @@ -14,6 +14,10 @@ import ( // DraftFileIdsMaxRunes is the maximum rune length of the serialized FileIds JSON array. const DraftFileIdsMaxRunes = 300 +// MaxDraftsPerUserPerSpace is the maximum number of draft rows a single user may hold in one +// space. +const MaxDraftsPerUserPerSpace = 100 + // Draft is a per-user autosave draft for a space page, stored in DOCS_Draft. // // A draft is keyed by (UserId, PageId): PageId is the page id reserved when the @@ -22,17 +26,46 @@ const DraftFileIdsMaxRunes = 300 // same key. An orphan draft — one whose PageId has no matching DOCS_Page row — is legal, // since the page has not been published yet. ParentId carries the pending hierarchy // parent for a new page; Body holds the raw (opaque) editor content. +// +// LastActiveAt is distinct from UpdateAt: it moves only when the user themselves saves the draft, +// whereas UpdateAt also moves when a bulk maintenance write touches the row. Editor presence is +// derived from LastActiveAt, so only real authoring activity counts as editing. type Draft struct { - UserId string `json:"user_id"` - SpaceId string `json:"space_id"` - PageId string `json:"page_id"` - ParentId string `json:"parent_id"` - Title string `json:"title"` - Body string `json:"body"` - FileIds mmmodel.StringArray `json:"file_ids"` - Props mmmodel.StringInterface `json:"props"` - CreateAt int64 `json:"create_at"` - UpdateAt int64 `json:"update_at"` + UserId string `json:"user_id"` + SpaceId string `json:"space_id"` + PageId string `json:"page_id"` + ParentId string `json:"parent_id"` + Title string `json:"title"` + Body string `json:"body"` + FileIds mmmodel.StringArray `json:"file_ids"` + Props mmmodel.StringInterface `json:"props"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + LastActiveAt int64 `json:"last_active_at"` + // BaseEditAt is the optimistic-lock (CAS) baseline: the page EditAt the client saw when it opened + // this page for editing, compared against the page's current EditAt on publish to reject a + // concurrent-edit conflict. Write-once — frozen at the SQL layer on conflict (see + // Store.UpsertDraft), so unlike the mutable Page.EditAt it never changes once established. 0 means + // no baseline: either a new-page draft or an existing-page edit whose baseline was never captured + // (fails closed to a forced publish). "New page" is decided by page existence, never by + // BaseEditAt == 0. + BaseEditAt int64 `json:"base_edit_at"` +} + +// DraftSummary is the metadata projection returned by draft collection endpoints. It deliberately +// omits Body and Props: Body can be up to PageBodyMaxBytes per draft, and Props is opaque and may +// be up to PagePropsMaxBytes — matching the fields PageSummary omits for the same reason. Fetch a +// Draft by page id when the content is required. +type DraftSummary struct { + UserId string `json:"user_id"` + SpaceId string `json:"space_id"` + PageId string `json:"page_id"` + ParentId string `json:"parent_id"` + Title string `json:"title"` + FileIds mmmodel.StringArray `json:"file_ids"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + LastActiveAt int64 `json:"last_active_at"` } // PreSave sanitizes Draft and defaults its Id-independent fields before insert. @@ -53,20 +86,23 @@ func (d *Draft) PreSave() { d.CreateAt = now } d.UpdateAt = now + d.LastActiveAt = now } // Auditable returns Draft's fields safe to include in an audit log, excluding Body. func (d *Draft) Auditable() map[string]any { return map[string]any{ - "user_id": d.UserId, - "space_id": d.SpaceId, - "page_id": d.PageId, - "parent_id": d.ParentId, - "title": d.Title, - "file_ids": d.FileIds, - "props": d.GetProps(), - "create_at": d.CreateAt, - "update_at": d.UpdateAt, + "user_id": d.UserId, + "space_id": d.SpaceId, + "page_id": d.PageId, + "parent_id": d.ParentId, + "title": d.Title, + "file_ids": d.FileIds, + "props": d.GetProps(), + "create_at": d.CreateAt, + "update_at": d.UpdateAt, + "last_active_at": d.LastActiveAt, + "base_edit_at": d.BaseEditAt, } } @@ -75,11 +111,9 @@ func (d *Draft) IsValid() *mmmodel.AppError { if !mmmodel.IsValidId(d.UserId) { return mmmodel.NewAppError("Draft.IsValid", "model.draft.is_valid.user_id.app_error", nil, "user_id="+d.UserId, http.StatusBadRequest) } - if !mmmodel.IsValidId(d.SpaceId) { return mmmodel.NewAppError("Draft.IsValid", "model.draft.is_valid.space_id.app_error", nil, "space_id="+d.SpaceId, http.StatusBadRequest) } - if !mmmodel.IsValidId(d.PageId) { return mmmodel.NewAppError("Draft.IsValid", "model.draft.is_valid.page_id.app_error", nil, "page_id="+d.PageId, http.StatusBadRequest) } @@ -101,6 +135,20 @@ func (d *Draft) IsValid() *mmmodel.AppError { return mmmodel.NewAppError("Draft.IsValid", "model.draft.is_valid.update_at.app_error", nil, "page_id="+d.PageId, http.StatusBadRequest) } + // Write-path contract only: PreSave always stamps LastActiveAt, so a zero here means the + // caller skipped PreSave. Stored rows can still legitimately hold 0 — bulk maintenance writes + // (cross-space move) reset LastActiveAt in SQL to drop the owner from presence — so a read + // path must not treat a stored 0 as invalid. + if d.LastActiveAt == 0 { + return mmmodel.NewAppError("Draft.IsValid", "model.draft.is_valid.last_active_at.app_error", nil, "page_id="+d.PageId, http.StatusBadRequest) + } + + // BaseEditAt is an optimistic-lock baseline (a page EditAt) or 0 for "no baseline"; a negative + // value is never legitimate. 0 is legal and must not be rejected. + if d.BaseEditAt < 0 { + return mmmodel.NewAppError("Draft.IsValid", "model.draft.is_valid.base_edit_at.app_error", nil, "page_id="+d.PageId, http.StatusBadRequest) + } + // A draft publishes into a page, so it is bound by the page content limits. if utf8.RuneCountInString(d.Title) > PageTitleMaxRunes { return mmmodel.NewAppError("Draft.IsValid", "model.draft.is_valid.title_length.app_error", nil, "page_id="+d.PageId, http.StatusBadRequest) @@ -110,17 +158,51 @@ func (d *Draft) IsValid() *mmmodel.AppError { return mmmodel.NewAppError("Draft.IsValid", "model.draft.is_valid.body_size.app_error", nil, "page_id="+d.PageId, http.StatusBadRequest) } - if utf8.RuneCountInString(mmmodel.ArrayToJSON(d.FileIds)) > DraftFileIdsMaxRunes { - return mmmodel.NewAppError("Draft.IsValid", "model.draft.is_valid.file_ids.app_error", nil, "page_id="+d.PageId, http.StatusBadRequest) + if err := ValidateDraftFileIds("Draft.IsValid", "page_id="+d.PageId, d.FileIds); err != nil { + return err } - if err := validatePropsSize("Draft.IsValid", "page_id="+d.PageId, d.Props, PagePropsMaxBytes); err != nil { + if err := ValidatePropsSize("Draft.IsValid", "page_id="+d.PageId, d.Props, PagePropsMaxBytes); err != nil { return err } return nil } +// ValidateDraftFileIds checks that the serialized file-id list stays within DraftFileIdsMaxRunes and +// that every entry is a well-formed ID. An empty list is valid; an empty entry is not, since a blank +// ID names no file and would be stored verbatim. +func ValidateDraftFileIds(where, detail string, fileIDs mmmodel.StringArray) *mmmodel.AppError { + if utf8.RuneCountInString(mmmodel.ArrayToJSON(fileIDs)) > DraftFileIdsMaxRunes { + return mmmodel.NewAppError(where, "model.draft.is_valid.file_ids.app_error", nil, detail, http.StatusBadRequest) + } + for _, fileID := range fileIDs { + if !mmmodel.IsValidId(fileID) { + return mmmodel.NewAppError(where, "model.draft.is_valid.file_id.app_error", nil, detail, http.StatusBadRequest) + } + } + return nil +} + +// ValidateDraftWriteIntent applies the FileIds and Props bounds to the pointer-carried write-intent +// values a draft upsert takes alongside the Draft struct. Those values never reach the struct's own +// fields — the upsert merges them in SQL — so IsValid cannot see them, and without this the bounds +// would hold only for callers that remembered to check them separately. A nil pointer means the +// field was omitted and the stored value is preserved, so there is nothing to bound. +func ValidateDraftWriteIntent(where, detail string, fileIDs *mmmodel.StringArray, props *mmmodel.StringInterface) *mmmodel.AppError { + if fileIDs != nil { + if err := ValidateDraftFileIds(where, detail, *fileIDs); err != nil { + return err + } + } + if props != nil { + if err := ValidatePropsSize(where, detail, *props, PagePropsMaxBytes); err != nil { + return err + } + } + return nil +} + // GetProps returns Props, or an empty map if Props is nil. func (d *Draft) GetProps() mmmodel.StringInterface { return ensureProps(d.Props) diff --git a/server/model/draft_test.go b/server/model/draft_test.go index 58ae14a..bc0aff8 100644 --- a/server/model/draft_test.go +++ b/server/model/draft_test.go @@ -127,6 +127,28 @@ func TestDraftIsValid(t *testing.T) { require.NotNil(t, aerr) require.Equal(t, "model.draft.is_valid.update_at.app_error", aerr.Id) }) + + t.Run("negative BaseEditAt rejected", func(t *testing.T) { + d := validDraft() + d.BaseEditAt = -1 + aerr := d.IsValid() + require.NotNil(t, aerr) + require.Equal(t, "model.draft.is_valid.base_edit_at.app_error", aerr.Id) + }) + + t.Run("zero BaseEditAt allowed", func(t *testing.T) { + d := validDraft() + d.BaseEditAt = 0 + require.Nil(t, d.IsValid()) + }) +} + +func TestDraftIsValidLastActiveAtZeroRejected(t *testing.T) { + d := validDraft() + d.LastActiveAt = 0 + aerr := d.IsValid() + require.NotNil(t, aerr) + require.Equal(t, "model.draft.is_valid.last_active_at.app_error", aerr.Id) } func TestDraftPreSaveDefaults(t *testing.T) { @@ -134,6 +156,32 @@ func TestDraftPreSaveDefaults(t *testing.T) { d.PreSave() require.NotZero(t, d.CreateAt) require.NotZero(t, d.UpdateAt) + require.NotZero(t, d.LastActiveAt) require.NotNil(t, d.FileIds) require.NotNil(t, d.Props) } + +func TestDraftPreSavePreservesExistingCreateAt(t *testing.T) { + d := &model.Draft{UserId: mmmodel.NewId(), SpaceId: mmmodel.NewId(), PageId: mmmodel.NewId(), CreateAt: 12345} + d.PreSave() + require.Equal(t, int64(12345), d.CreateAt, "PreSave must not overwrite an existing CreateAt") +} + +func TestDraftPreSaveTrimsTitleWhitespace(t *testing.T) { + d := &model.Draft{UserId: mmmodel.NewId(), SpaceId: mmmodel.NewId(), PageId: mmmodel.NewId(), Title: " hello "} + d.PreSave() + require.Equal(t, "hello", d.Title) +} + +func TestDraftGetPropsNilReturnsEmpty(t *testing.T) { + d := &model.Draft{Props: nil} + require.NotNil(t, d.GetProps(), "GetProps must return an empty map, not nil") + require.Empty(t, d.GetProps()) +} + +func TestDraftAuditable(t *testing.T) { + d := validDraft() + d.BaseEditAt = 42 + auditable := d.Auditable() + require.Equal(t, int64(42), auditable["base_edit_at"]) +} diff --git a/server/model/page.go b/server/model/page.go index ab840ef..827d4fd 100644 --- a/server/model/page.go +++ b/server/model/page.go @@ -22,6 +22,11 @@ const ( // so this is a storage safety bound against unbounded writes. PageBodyMaxBytes = 2 * 1024 * 1024 + // MaxPageDepth is the page hierarchy depth limit (root is depth 1). It also bounds draft + // nesting: a draft chain publishes into a page chain of the same depth, so the store's + // draft-cycle walk is capped at the same value. + MaxPageDepth = 10 + // PagePropsMaxBytes caps the serialized size of the opaque Props map. PagePropsMaxBytes = 64 * 1024 @@ -108,9 +113,7 @@ func MaxDepthOfPages(pages []*Page, rootID string) int { } cur = parent } - if depth > maxDepth { - maxDepth = depth - } + maxDepth = max(maxDepth, depth) } return maxDepth } @@ -252,7 +255,7 @@ func (p *Page) IsValid() *mmmodel.AppError { return mmmodel.NewAppError("Page.IsValid", "model.page.is_valid.search_text_without_body.app_error", nil, "id="+p.Id, http.StatusBadRequest) } - if err := validatePropsSize("Page.IsValid", "id="+p.Id, p.Props, PagePropsMaxBytes); err != nil { + if err := ValidatePropsSize("Page.IsValid", "id="+p.Id, p.Props, PagePropsMaxBytes); err != nil { return err } diff --git a/server/model/page_content.go b/server/model/page_content.go new file mode 100644 index 0000000..e4f45a9 --- /dev/null +++ b/server/model/page_content.go @@ -0,0 +1,608 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "encoding/base64" + "encoding/json" + "html" + "net/http" + "slices" + "strings" + + "github.com/pkg/errors" +) + +const ( + TipTapDocType = "doc" + EmptyTipTapJSON = `{"type":"doc","content":[]}` +) + +// TipTapDocument is the parsed form of a TipTap editor document. Content is left as an untyped node +// tree because node attributes and nesting vary per editor extension; the permitted node and mark +// type names are constrained by allowedNodeTypes/allowedMarkTypes during sanitization. Client-supplied +// content must be produced by ParseTipTapDocument for the sanitization invariant to hold — a value +// decoded from client JSON any other way has not passed sanitizeTipTapDocument and must not be +// stored or rendered. The one exception is a document assembled internally from trusted parts (e.g. +// convertPlainTextToTipTap, which emits only paragraph and text nodes with no attrs or marks): it is +// safe by construction and needs no sanitization pass. +type TipTapDocument struct { + Type string `json:"type"` + Content []map[string]any `json:"content"` +} + +// BuildSearchText extracts searchable plain text from a TipTap document. +func BuildSearchText(doc TipTapDocument) string { + var b strings.Builder + for _, node := range doc.Content { + appendNodeText(&b, node, 0) + } + return b.String() +} + +// ParseTipTapDocument parses and sanitizes a TipTap JSON string into a TipTapDocument. Unlike the +// model's field validators, it returns a plain error (not an *mmmodel.AppError): its failures are +// parse-level, not per-field, so there is no single i18n key to attach per reason. +func ParseTipTapDocument(contentJSON string) (TipTapDocument, error) { + if contentJSON == "" { + return TipTapDocument{ + Type: TipTapDocType, + Content: []map[string]any{}, + }, nil + } + + // Reject over-limit content before parsing: json.Unmarshal materializes every node as a + // map[string]any at a large multiple of its encoded size, and the per-node budget inside the + // sanitize walk only applies after that allocation. + if len(contentJSON) > PageBodyMaxBytes { + return TipTapDocument{}, errors.New("content exceeds the maximum body size") + } + + var doc TipTapDocument + if err := json.Unmarshal([]byte(contentJSON), &doc); err != nil { + return TipTapDocument{}, err + } + + if doc.Type != TipTapDocType { + return TipTapDocument{}, errors.New("content must be valid TipTap JSON with type: doc") + } + + // A document with no "content" key (or an explicit null) decodes to a nil slice, which would + // re-marshal to "content":null. Empty content is [] everywhere else, and a client walking the + // array would fault on null, so normalize to the one empty representation. + if doc.Content == nil { + doc.Content = []map[string]any{} + } + + if err := sanitizeTipTapDocument(&doc); err != nil { + return TipTapDocument{}, err + } + return doc, nil +} + +// appendNodeText walks a node subtree once, appending each text leaf and mention label to b. A +// single shared builder keeps extraction O(total text) instead of re-joining every subtree at each +// ancestor, which would copy a leaf once per level of nesting. +func appendNodeText(b *strings.Builder, node map[string]any, depth int) { + if depth > maxTipTapDepth { + return + } + + if textVal, ok := node["text"]; ok { + if text, ok := textVal.(string); ok && text != "" { + if normalized := strings.Join(strings.Fields(text), " "); normalized != "" { + writeSearchTextPart(b, normalized) + } + } + } + + if nodeType, ok := node["type"].(string); ok && (nodeType == "mention" || nodeType == "channelMention") { + if attrs, ok := node["attrs"].(map[string]any); ok { + if label, ok := attrs["label"].(string); ok && label != "" { + writeSearchTextPart(b, "@"+label) + } else if id, ok := attrs["id"].(string); ok && id != "" { + writeSearchTextPart(b, "@"+id) + } + } + } + + if contentVal, ok := node["content"]; ok { + if children, ok := contentVal.([]any); ok { + for _, child := range children { + if childNode, ok := child.(map[string]any); ok { + appendNodeText(b, childNode, depth+1) + } + } + } + } +} + +// writeSearchTextPart appends part to b, separated from any prior content by a single space. +func writeSearchTextPart(b *strings.Builder, part string) { + if b.Len() > 0 { + b.WriteByte(' ') + } + b.WriteString(part) +} + +// maxTipTapDepth bounds recursion over client-supplied content. encoding/json already caps nesting, +// but this rejects a pathologically deep document before the recursive walk and keeps stored content +// within a sane depth. +const maxTipTapDepth = 100 + +// maxTipTapNodes caps the total number of content nodes in a TipTap document, bounding the +// sanitize walk and the stored node count. It applies after json.Unmarshal has materialized the +// document, so it does not bound the parse itself — the pre-parse body-size check at the top of +// ParseTipTapDocument does that. The plain-text path is capped at maxPlainTextParagraphs +// (10 000 paragraphs → ~20 000 nodes, since each non-empty line is a paragraph plus a text child); +// rich documents with ~5 inline nodes per paragraph stay well under 50 000 for any sane document. +const maxTipTapNodes = 50_000 + +var errAttrDepthExceeded = errors.New("content attribute nesting exceeds the maximum depth") + +func sanitizeTipTapDocument(doc *TipTapDocument) error { + // count is a running node budget shared across the walk: sanitizeTipTapNode increments it per + // node and bails once it crosses maxTipTapNodes, so a crafted payload is rejected before the + // walk allocates without bound — without a separate counting pass over the whole tree. + count := 0 + for i := range doc.Content { + if doc.Content[i] == nil { + return errors.New("content document nodes must be objects") + } + if err := sanitizeTipTapNode(doc.Content[i], 0, &count); err != nil { + return err + } + } + return nil +} + +// urlAttrKeys are the attribute keys (matched case-insensitively) whose values are URLs and must +// pass through sanitizeURL. +var urlAttrKeys = map[string]struct{}{ + "href": {}, + "src": {}, + "poster": {}, + "xlink:href": {}, + "xlinkhref": {}, +} + +// allowedNodeTypes is the allowlist of TipTap node type values permitted in stored content, keyed to +// the core WysiwygEditor schema plus the extensions the page editor augments it with. A node type not +// listed here is rejected outright: this pins the server's accepted node set to the client's schema so +// the two cannot drift silently, and it fails closed for any script- or embed-bearing type the client +// never emits. Matched case-sensitively, since TipTap schema names are case-sensitive camelCase. +// +// This is a hand-maintained mirror of the editor schema: when the page editor gains a node type — a +// core WysiwygEditor/StarterKit change or a Docs-specific extension — add it here in the same change, +// or the new content is rejected on save. +var allowedNodeTypes = map[string]struct{}{ + // Core WysiwygEditor schema (StarterKit + Link + CodeBlockLowlight + Table). + "doc": {}, + "paragraph": {}, + "text": {}, + "heading": {}, + "hardBreak": {}, + "horizontalRule": {}, + "blockquote": {}, + "codeBlock": {}, + "bulletList": {}, + "orderedList": {}, + "listItem": {}, + "table": {}, + "tableRow": {}, + "tableCell": {}, + "tableHeader": {}, + // Page editor extensions layered on the core schema. + "taskList": {}, + "taskItem": {}, + "mention": {}, + "channelMention": {}, + "callout": {}, + "image": {}, + "imageResize": {}, + "imagePlaceholder": {}, + "video": {}, + "fileAttachment": {}, +} + +// allowedMarkTypes is the allowlist of TipTap mark type values, keyed to the core WysiwygEditor +// schema plus the page editor's extensions. Like allowedNodeTypes, a mark type not listed here is +// rejected rather than passed through. "link" is a mark (TipTap's inline hyperlink); its href is +// sanitized by sanitizeURL. +var allowedMarkTypes = map[string]struct{}{ + // Core WysiwygEditor marks. + "bold": {}, + "italic": {}, + "strike": {}, + "code": {}, + "underline": {}, + "link": {}, + // Page editor extensions. + "textStyle": {}, + "commentAnchor": {}, +} + +// dangerousAttrKeys are attribute keys (matched case-insensitively) stripped outright regardless of +// value: they can execute script or embed foreign markup, and no supported node needs them. This +// denylist is layered on top of the URL-scheme allowlist. +var dangerousAttrKeys = map[string]struct{}{ + "style": {}, + "formaction": {}, + "action": {}, + "srcdoc": {}, + "srcset": {}, + "background": {}, + "dynsrc": {}, + "lowsrc": {}, + "ping": {}, + "data": {}, +} + +// trimBrowserIgnoredChars trims leading/trailing ASCII space and control characters (r <= ' ') — +// the characters an HTML tokenizer ignores around attribute names and a browser strips from the +// ends of a URL — so a sanitizer match cannot be defeated by padding with them. +func trimBrowserIgnoredChars(s string) string { + return strings.TrimFunc(s, func(r rune) bool { return r <= ' ' }) +} + +// stripDangerousKeys strips script-bearing keys (event handlers plus the dangerousAttrKeys set) and +// neutralizes dangerous URL schemes on any URL-valued key, at the top level of m only. Key names are +// matched case-insensitively, since HTML attribute names are case-insensitive. +// +// It is applied to attribute maps and also to the node and mark objects themselves, because a +// lenient client renderer may read a dangerous or URL-valued key placed directly on the object +// rather than nested under its "attrs". +func stripDangerousKeys(m map[string]any) { + for key, val := range m { + // Trim leading/trailing whitespace and control characters before matching: an HTML + // tokenizer treats "\tonclick" as the onclick attribute, so a key that fails to match + // here because of such a prefix would carry its payload through every check below. + trimmed := trimBrowserIgnoredChars(key) + lower := strings.ToLower(trimmed) + if _, dangerous := dangerousAttrKeys[lower]; strings.HasPrefix(lower, "on") || dangerous { + delete(m, key) + continue + } + // A padded key is not a name any editor schema defines — the padding exists only to slip + // past a matcher that does not trim. Drop it outright instead of sanitizing its value in + // place, so a renderer that resolves the trimmed name cannot find both a padded and a + // canonical entry for the same attribute. + if trimmed != key { + delete(m, key) + continue + } + // A data-* attribute may carry a URL a lenient client renderer treats as a navigation + // target, but it is not necessarily one: an editor extension may store a ratio ("16:9"), a + // timestamp ("12:30"), or any other colon-bearing value under a data-* key. The strict URL + // allowlist would read the leading token as a scheme and blank every such value, so + // neutralize only the unambiguously dangerous schemes here (javascript:/vbscript:/non-image + // data:), matching the bare-array-string path. A non-string value (number, nested map/array) + // carries no scheme to strip and is left for the recursive attr walk to handle. + if strings.HasPrefix(lower, "data-") { + if v, ok := val.(string); ok { + m[key] = neutralizeAmbiguousURLScheme(v) + } + continue + } + // A designated URL key (href, src, ...) genuinely holds a URL, so apply the strict scheme + // allowlist. A non-string value (e.g. a JSON array) can be coerced back into a dangerous + // string by a client renderer, so drop it rather than leave it untouched. + if _, isURL := urlAttrKeys[lower]; isURL { + v, ok := val.(string) + if !ok { + delete(m, key) + continue + } + m[key] = sanitizeURL(v) + } + } +} + +// sanitizeAttrs strips dangerous keys from an attribute map and descends into any nested maps and +// arrays it holds, so a URL or handler buried under a sub-object (e.g. attrs.config.href, a shape +// an extension's schema may define) is neutralized rather than passed through untouched. It fails +// closed: attribute nesting past maxTipTapDepth returns an error (rejecting the whole document) +// rather than silently leaving the over-deep subtree unsanitized. +func sanitizeAttrs(attrs map[string]any, depth int) error { + if depth > maxTipTapDepth { + return errAttrDepthExceeded + } + stripDangerousKeys(attrs) + for _, val := range attrs { + if err := sanitizeAttrValue(val, depth); err != nil { + return err + } + } + return nil +} + +// sanitizeAttrValue recurses into the containers an attribute value may hold, neutralizing dangerous +// URL schemes on any bare string reached inside an array. A scalar string held directly under a map +// key is left to stripDangerousKeys, which sanitizes it only under a URL-designated key; but a string +// inside an array has no key to mark it, so a dangerous scheme there — e.g. ["javascript:alert(1)"] +// under a non-URL-designated key an editor extension may define — would otherwise pass through +// untouched. Like sanitizeAttrs, it fails closed past maxTipTapDepth. +func sanitizeAttrValue(val any, depth int) error { + if depth > maxTipTapDepth { + return errAttrDepthExceeded + } + switch v := val.(type) { + case map[string]any: + return sanitizeAttrs(v, depth+1) + case []any: + for i, item := range v { + if s, ok := item.(string); ok { + v[i] = neutralizeAmbiguousURLScheme(s) + continue + } + if err := sanitizeAttrValue(item, depth+1); err != nil { + return err + } + } + } + return nil +} + +// nodeSkipKeys are the top-level keys excluded from the flat-key sanitization pass in +// sanitizeTipTapNode: "attrs" is handled separately, and "content"/"marks" get their own recursion. +var nodeSkipKeys = map[string]struct{}{"attrs": {}, "content": {}, "marks": {}} + +// markSkipKeys are the top-level keys excluded from the flat-key sanitization pass for mark objects. +var markSkipKeys = map[string]struct{}{"attrs": {}} + +// sanitizeObjAttrsAndFlatKeys sanitizes the "attrs" sub-object of a node or mark, and any flat keys +// not in skipKeys. stripDangerousKeys must be called on obj before this. +func sanitizeObjAttrsAndFlatKeys(obj map[string]any, attrsErrMsg string, skipKeys map[string]struct{}, depth int) error { + if attrsVal, ok := obj["attrs"]; ok && attrsVal != nil { + attrs, ok := attrsVal.(map[string]any) + if !ok { + return errors.New(attrsErrMsg) + } + // The attrs subtree restarts the depth budget at 0, while the flat-key values below inherit + // the node's depth and so get what remains of it. Both fail closed at maxTipTapDepth, and + // attrs nesting is a property of the attribute value rather than of the document tree, so + // charging it against the node's remaining depth would bound it by where the node happens + // to sit. The asymmetry is deliberate. + if err := sanitizeAttrs(attrs, 0); err != nil { + return err + } + } + for key, val := range obj { + if _, skip := skipKeys[key]; skip { + continue + } + if err := sanitizeAttrValue(val, depth); err != nil { + return err + } + } + return nil +} + +func sanitizeTipTapNode(node map[string]any, depth int, count *int) error { + if node == nil { + return errors.New("content node must not be null") + } + if depth > maxTipTapDepth { + return errors.New("content nesting exceeds the maximum depth") + } + *count++ + if *count > maxTipTapNodes { + return errors.Errorf("content exceeds the maximum of %d nodes", maxTipTapNodes) + } + + // Strip dangerous/URL keys placed directly on the node object, then sanitize its attrs. The + // node's own keys need the same treatment as a mark's: "content" and "marks" are walked below, + // and no supported node key collides with the handler/URL sets. + stripDangerousKeys(node) + + nodeType, ok := node["type"].(string) + if !ok || nodeType == "" { + return errors.New("content node must have a non-empty type field") + } + if _, allowed := allowedNodeTypes[nodeType]; !allowed { + return errors.Errorf("content node type %q is not allowed", nodeType) + } + + if err := sanitizeObjAttrsAndFlatKeys(node, "content node attrs must be an object", nodeSkipKeys, depth); err != nil { + return err + } + + if marksVal, ok := node["marks"]; ok && marksVal != nil { + marksArray, ok := marksVal.([]any) + if !ok { + return errors.New("content node marks must be an array") + } + for _, mark := range marksArray { + markNode, ok := mark.(map[string]any) + if !ok { + return errors.New("content mark must be an object") + } + // Sanitize both the mark's nested attrs and any dangerous/URL keys placed directly on + // the mark object (a non-standard shape a lenient renderer may read). + stripDangerousKeys(markNode) + markType, ok := markNode["type"].(string) + if !ok || markType == "" { + return errors.New("content mark must have a non-empty type field") + } + if _, allowed := allowedMarkTypes[markType]; !allowed { + return errors.Errorf("content mark type %q is not allowed", markType) + } + if err := sanitizeObjAttrsAndFlatKeys(markNode, "content mark attrs must be an object", markSkipKeys, depth); err != nil { + return err + } + } + } + + if contentVal, ok := node["content"]; ok && contentVal != nil { + contentArray, ok := contentVal.([]any) + if !ok { + return errors.New("content node content must be an array") + } + for _, child := range contentArray { + childNode, ok := child.(map[string]any) + if !ok { + return errors.New("content child must be an object") + } + if err := sanitizeTipTapNode(childNode, depth+1, count); err != nil { + return err + } + } + } + return nil +} + +// safeImageMIMETypes are the only image MIME types allowed through data: URIs. SVG is excluded +// because it can carry script. safeImageDataPrefixes is derived from this slice so adding a new +// type here keeps both in sync automatically. +var safeImageMIMETypes = []string{ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/bmp", +} + +var safeImageDataPrefixes = func() []string { + out := make([]string, len(safeImageMIMETypes)) + for i, mt := range safeImageMIMETypes { + out[i] = "data:" + mt + } + return out +}() + +// sniffBase64Chars is how many leading base64 characters are decoded for content sniffing. 24 chars +// decode to 18 bytes, covering the longest signature http.DetectContentType matches on for the types +// above (WebP, whose RIFF container marker runs to byte 14). 24 is a multiple of 4, so a truncated +// (unpadded) prefix stays valid StdEncoding; a short payload that already carries "=" padding within +// those 24 chars decodes too (unlike RawStdEncoding, which rejects padding). +const sniffBase64Chars = 24 + +// isBase64ImagePayload confirms a data:image/* URI actually carries base64-encoded bytes that sniff +// as one of the allowed raster images, so a script-bearing payload cannot ride in under an allowed +// MIME label (e.g. an SVG declared as image/png). +func isBase64ImagePayload(url string) bool { + meta, payload, ok := strings.Cut(url, ",") + if !ok { + return false + } + if !strings.Contains(strings.ToLower(meta), ";base64") { + return false + } + trimmed := strings.TrimSpace(payload) + if len(trimmed) > sniffBase64Chars { + trimmed = trimmed[:sniffBase64Chars] + } + data, err := base64.StdEncoding.DecodeString(trimmed) + if err != nil { + return false + } + contentType, _, _ := strings.Cut(http.DetectContentType(data), ";") + return slices.Contains(safeImageMIMETypes, contentType) +} + +// urlScheme returns the scheme of a URL and whether one is present. lower must already be +// lowercased (decodeURLScheme passes its lowered form): the character check below accepts only a-z, +// so an uppercase scheme would be reported as absent. A scheme must sit at the very start and +// precede any '/', '?', or '#', matching how browsers parse schemes; a relative reference (no +// scheme) returns ("", false). +func urlScheme(lower string) (string, bool) { + for i, r := range lower { + if r == ':' { + if i == 0 { + return "", false + } + return lower[:i], true + } + if r == '/' || r == '?' || r == '#' { + return "", false + } + valid := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '+' || r == '-' || r == '.' + if !valid { + return "", false + } + } + return "", false +} + +// urlStripChars removes the ASCII tab/newline/CR that browsers strip from a URL before resolving +// its scheme, so an obfuscated "java\tscript:" cannot slip past the scheme check. +var urlStripChars = strings.NewReplacer("\t", "", "\n", "", "\r", "", "\x00", "") + +// decodeURLScheme extracts the scheme a browser would resolve from url, defeating the obfuscation a +// dangerous scheme can hide behind. It decodes HTML entities and strips the tab/newline/CR/null +// browsers ignore, so an entity-encoded ":" or an embedded control char is detected. Two strip +// passes: the first removes literal control chars, the second removes any html.UnescapeString +// re-introduces (e.g. " " → "\t"). Percent-encoded characters (%09, %0A, etc.) are intentionally +// NOT stripped: browsers do not strip them from scheme names, so "java%09script:" never parses as +// "javascript:". Returns the lowercased scheme, the lowercased cleaned string (for data: prefix +// matching), and whether a scheme is present (false for a relative reference). The decode is used +// only for detection; callers return the original string when they allow it. +func decodeURLScheme(url string) (scheme, lower string, hasScheme bool) { + cleaned := urlStripChars.Replace(url) + cleaned = html.UnescapeString(cleaned) + cleaned = urlStripChars.Replace(cleaned) + cleaned = trimBrowserIgnoredChars(cleaned) + lower = strings.ToLower(cleaned) + scheme, hasScheme = urlScheme(lower) + return scheme, lower, hasScheme +} + +// isSafeImageDataURL reports whether a data: URL carries a base64 payload sniffing as an allowed +// raster image. lower is the lowercased, obfuscation-decoded form of url from decodeURLScheme. +func isSafeImageDataURL(url, lower string) bool { + for _, prefix := range safeImageDataPrefixes { + if strings.HasPrefix(lower, prefix) && isBase64ImagePayload(url) { + return true + } + } + return false +} + +// sanitizeURL returns the URL unchanged if its scheme is on the allowlist (or it is a relative +// reference), and "" otherwise. It defends against control-character, leading-whitespace, and +// HTML-entity obfuscation of dangerous schemes (e.g. "java script:alert(1)"). Applied to a +// value under a URL-designated attribute key (href, src, poster, xlink:href). data-* keys are not +// necessarily URLs and take the lenient neutralizeAmbiguousURLScheme path instead. +func sanitizeURL(url string) string { + scheme, lower, hasScheme := decodeURLScheme(url) + if !hasScheme { + return url + } + switch scheme { + case "http", "https", "mailto", "tel": + return url + case "data": + if isSafeImageDataURL(url, lower) { + return url + } + return "" + default: + return "" + } +} + +// neutralizeAmbiguousURLScheme blanks a string that carries a script-executing or foreign-content +// URL scheme, for values that are not unambiguously URLs. It is reached two ways: a bare element of +// an array attribute value (no key marks it a URL), and a data-* attribute value (whose key may mark +// a URL but need not — an extension may store a "12:30" timestamp or "16:9" ratio there). Unlike +// sanitizeURL (a strict allowlist applied where an attribute key designates the value a URL), only +// the unambiguously dangerous schemes are neutralized: a plain colon-bearing string is preserved, +// while a javascript:/vbscript:/non-image data: payload is dropped. +func neutralizeAmbiguousURLScheme(s string) string { + scheme, lower, hasScheme := decodeURLScheme(s) + if !hasScheme { + return s + } + switch scheme { + case "javascript", "vbscript": + return "" + case "data": + if isSafeImageDataURL(s, lower) { + return s + } + return "" + default: + return s + } +} diff --git a/server/model/page_content_test.go b/server/model/page_content_test.go new file mode 100644 index 0000000..edc00c0 --- /dev/null +++ b/server/model/page_content_test.go @@ -0,0 +1,753 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model_test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-plugin-docs/server/model" +) + +// marshal round-trips a parsed document to JSON so tests can assert on the sanitized output. +func marshal(t *testing.T, doc model.TipTapDocument) string { + t.Helper() + b, err := json.Marshal(doc) + require.NoError(t, err) + return string(b) +} + +func TestParseTipTapDocument(t *testing.T) { + t.Run("empty string yields an empty doc", func(t *testing.T) { + doc, err := model.ParseTipTapDocument("") + require.NoError(t, err) + require.Equal(t, model.TipTapDocType, doc.Type) + require.Empty(t, doc.Content) + }) + + t.Run("valid doc round-trips", func(t *testing.T) { + doc, err := model.ParseTipTapDocument(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}`) + require.NoError(t, err) + require.Equal(t, model.TipTapDocType, doc.Type) + require.Len(t, doc.Content, 1) + }) + + t.Run("non-doc top-level type is rejected", func(t *testing.T) { + _, err := model.ParseTipTapDocument(`{"type":"bogus","content":[]}`) + require.Error(t, err) + }) + + t.Run("malformed JSON is rejected", func(t *testing.T) { + _, err := model.ParseTipTapDocument(`{not json`) + require.Error(t, err) + }) + + t.Run("minimal doc without content does not panic", func(t *testing.T) { + doc, err := model.ParseTipTapDocument(`{"type":"doc"}`) + require.NoError(t, err) + require.Empty(t, doc.Content) + }) +} + +func TestParseTipTapDocumentSanitizesURLs(t *testing.T) { + // Each case is a link mark href; want=="" means the scheme must be stripped. + cases := []struct { + name string + href string + want string + }{ + {"plain javascript", "javascript:alert(1)", ""}, + {"tab-obfuscated javascript", "java\tscript:alert(1)", ""}, + {"newline-obfuscated javascript", "java\nscript:alert(1)", ""}, + {"leading-control javascript", "\x01javascript:alert(1)", ""}, + {"uppercase javascript", "JAVASCRIPT:alert(1)", ""}, + {"leading-space javascript", " javascript:alert(1)", ""}, + {"entity-colon javascript", "javascript:alert(1)", ""}, + {"entity-tab javascript", "java script:alert(1)", ""}, + {"vbscript", "vbscript:msgbox(1)", ""}, + {"data html", "data:text/html,", ""}, + {"data svg", "data:image/svg+xml,", ""}, + {"http allowed", "http://example.com/a", "http://example.com/a"}, + {"https allowed", "https://example.com/a", "https://example.com/a"}, + {"mailto allowed", "mailto:a@example.com", "mailto:a@example.com"}, + {"relative allowed", "/pages/abc", "/pages/abc"}, + {"anchor allowed", "#section", "#section"}, + {"tel allowed", "tel:+15551234567", "tel:+15551234567"}, + {"data png allowed", "data:image/png;base64,iVBORw0KGgo=", "data:image/png;base64,iVBORw0KGgo="}, + {"data png non-image payload rejected", "data:image/png;base64,AAAA", ""}, + {"data png mislabeled non-base64 rejected", "data:image/png,", ""}, + // JPEG: magic bytes FF D8 FF → base64 "/9j/" + {"data jpeg allowed", "data:image/jpeg;base64,/9j/AAAA", "data:image/jpeg;base64,/9j/AAAA"}, + // GIF89a: magic bytes 47 49 46 38 39 61 → base64 "R0lGODlh" + {"data gif allowed", "data:image/gif;base64,R0lGODlh", "data:image/gif;base64,R0lGODlh"}, + // BMP: magic bytes 42 4D 00 00 00 00 00 00 00 (9 bytes, no padding) → base64 "Qk0AAAAAAAAA" + {"data bmp allowed", "data:image/bmp;base64,Qk0AAAAAAAAA", "data:image/bmp;base64,Qk0AAAAAAAAA"}, + // SVG relabeled as JPEG must be rejected (it sniffs as text, not an image). + {"data jpeg mislabeled svg rejected", "data:image/jpeg;base64,PHN2Zy8+", ""}, + // WebP: "RIFF" + 4 size bytes + "WEBPVP8 " → base64 "UklGRgAAAABXRUJQVlA4IA==" + {"data webp allowed", "data:image/webp;base64,UklGRgAAAABXRUJQVlA4IA==", "data:image/webp;base64,UklGRgAAAABXRUJQVlA4IA=="}, + // A WAV shares WebP's leading "RIFF" container marker and differs only at byte 8, so the + // payload must be sniffed past that marker rather than matched on its first four bytes. + {"data webp mislabeled wav rejected", "data:image/webp;base64,UklGRgAAAABXQVZFZm10IA==", ""}, + // An ICO is a raster image but is not one of the allowed types, so it cannot ride in under + // an image/png label. + {"data png mislabeled ico rejected", "data:image/png;base64,AAABAAEA", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + raw := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "text", + "text": "link", + "marks": []any{ + map[string]any{"type": "link", "attrs": map[string]any{"href": tc.href}}, + }, + }, + }, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + doc, err := model.ParseTipTapDocument(string(b)) + require.NoError(t, err) + + out := marshal(t, doc) + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &parsed)) + content := parsed["content"].([]any) + node := content[0].(map[string]any) + mark := node["marks"].([]any)[0].(map[string]any) + gotHref := mark["attrs"].(map[string]any)["href"] + require.Equal(t, tc.want, gotHref, "href sanitization mismatch for %q", tc.href) + }) + } +} + +// TestParseTipTapDocumentRejectsForbiddenTypes pins the node/mark allowlist — the strongest +// defense in the sanitizer — by asserting that a document carrying an unlisted type is rejected +// outright rather than stripped and accepted. +func TestParseTipTapDocumentRejectsForbiddenTypes(t *testing.T) { + // Types a lenient renderer could execute, plus a couple of allowed ones as a control. + nodeCases := []struct { + nodeType string + rejected bool + }{ + {"script", true}, + {"iframe", true}, + {"embed", true}, + {"object", true}, + {"noscript", true}, + {"template", true}, + {"style", true}, + {"link", true}, + {"svg", true}, + {"math", true}, + {"animate", true}, + {"animatetransform", true}, + {"foreignobject", true}, + {"maction", true}, + {"SCRIPT", true}, // the allowlist is case-sensitive, so a cased variant is not listed + {"IFrame", true}, + {"paragraph", false}, // control: allowed + } + for _, tc := range nodeCases { + t.Run("node type "+tc.nodeType, func(t *testing.T) { + raw := map[string]any{ + "type": "doc", + "content": []any{map[string]any{"type": tc.nodeType}}, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + _, err = model.ParseTipTapDocument(string(b)) + if tc.rejected { + require.Error(t, err, "forbidden node type %q must be rejected", tc.nodeType) + } else { + require.NoError(t, err, "allowed node type %q must parse", tc.nodeType) + } + }) + } + + // Unlisted mark types on an otherwise-valid text node. "link" is intentionally absent — it is a + // valid mark whose href is sanitized rather than blocked (see TestParseTipTapDocumentSanitizesURLs). + markCases := []struct { + markType string + rejected bool + }{ + {"script", true}, + {"iframe", true}, + {"style", true}, + {"svg", true}, + {"foreignobject", true}, + {"MAction", true}, // cased variant of an unlisted type is still unlisted + {"link", false}, // control: allowed as a mark + {"bold", false}, // control: ordinary formatting mark + {"", true}, // a mark with an empty type is rejected, matching node-type strictness + } + for _, tc := range markCases { + t.Run("mark type "+tc.markType, func(t *testing.T) { + raw := map[string]any{ + "type": "doc", + "content": []any{map[string]any{ + "type": "text", + "text": "x", + "marks": []any{map[string]any{"type": tc.markType}}, + }}, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + _, err = model.ParseTipTapDocument(string(b)) + if tc.rejected { + require.Error(t, err, "forbidden mark type %q must be rejected", tc.markType) + } else { + require.NoError(t, err, "allowed mark type %q must parse", tc.markType) + } + }) + } +} + +func TestParseTipTapDocumentRejectsNullNodes(t *testing.T) { + t.Run("null top-level content node", func(t *testing.T) { + _, err := model.ParseTipTapDocument(`{"type":"doc","content":[null]}`) + require.Error(t, err) + }) + + t.Run("null mark in node", func(t *testing.T) { + _, err := model.ParseTipTapDocument(`{"type":"doc","content":[{"type":"text","text":"hello","marks":[null]}]}`) + require.Error(t, err) + }) + + t.Run("null child in content", func(t *testing.T) { + _, err := model.ParseTipTapDocument(`{"type":"doc","content":[{"type":"paragraph","content":[null]}]}`) + require.Error(t, err) + }) + + t.Run("node with empty type field", func(t *testing.T) { + _, err := model.ParseTipTapDocument(`{"type":"doc","content":[{"type":""}]}`) + require.Error(t, err) + }) + + t.Run("node with no type field", func(t *testing.T) { + _, err := model.ParseTipTapDocument(`{"type":"doc","content":[{"text":"orphan"}]}`) + require.Error(t, err) + }) +} + +func TestParseTipTapDocumentDropsScriptAttributes(t *testing.T) { + // An image node carrying event-handler and style attributes must have them stripped while a + // safe src survives. + raw := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "image", + "attrs": map[string]any{ + "src": "https://example.com/cat.png", + "onerror": "alert(document.cookie)", + "onload": "steal()", + "style": "background:url(javascript:alert(1))", + "alt": "a cat", + }, + }, + }, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + doc, err := model.ParseTipTapDocument(string(b)) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(marshal(t, doc)), &parsed)) + attrs := parsed["content"].([]any)[0].(map[string]any)["attrs"].(map[string]any) + + require.Equal(t, "https://example.com/cat.png", attrs["src"], "safe src should survive") + require.Equal(t, "a cat", attrs["alt"], "non-script attr should survive") + require.NotContains(t, attrs, "onerror") + require.NotContains(t, attrs, "onload") + require.NotContains(t, attrs, "style") +} + +func TestParseTipTapDocumentSanitizesCaseInsensitiveAttrs(t *testing.T) { + // HTML attribute names are case-insensitive, so a mixed-case event handler or URL attribute + // must be sanitized the same as its lowercase form. + raw := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "text", + "text": "link", + "marks": []any{ + map[string]any{"type": "link", "attrs": map[string]any{ + "HREF": "javascript:alert(1)", + "OnClick": "steal()", + }}, + }, + }, + }, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + doc, err := model.ParseTipTapDocument(string(b)) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(marshal(t, doc)), &parsed)) + attrs := parsed["content"].([]any)[0].(map[string]any)["marks"].([]any)[0].(map[string]any)["attrs"].(map[string]any) + + require.Equal(t, "", attrs["HREF"], "uppercase HREF with a dangerous scheme must be neutralized") + require.NotContains(t, attrs, "OnClick", "mixed-case event handler must be stripped") +} + +func TestParseTipTapDocumentStripsDangerousAttrs(t *testing.T) { + // Attributes that can execute script or embed foreign markup must be dropped regardless of + // value, even on a node type the sanitizer does not otherwise recognize. + raw := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "blockquote", + "attrs": map[string]any{ + "srcdoc": "", + "background": "javascript:alert(1)", + "data": "javascript:alert(1)", + "action": "https://evil.example", + "ping": "https://evil.example", + "srcset": "javascript:alert(1)", + "title": "safe", + }, + }, + }, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + doc, err := model.ParseTipTapDocument(string(b)) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(marshal(t, doc)), &parsed)) + attrs := parsed["content"].([]any)[0].(map[string]any)["attrs"].(map[string]any) + + for _, k := range []string{"srcdoc", "background", "data", "action", "ping", "srcset"} { + require.NotContains(t, attrs, k, "%s must be stripped", k) + } + require.Equal(t, "safe", attrs["title"], "non-dangerous attr should survive") +} + +func TestParseTipTapDocumentSanitizesFlatMarkHref(t *testing.T) { + // A mark that carries href directly on the mark object (not under attrs) must still be neutralized. + raw := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "text", + "text": "link", + "marks": []any{map[string]any{"type": "link", "href": "javascript:alert(1)"}}, + }, + }, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + doc, err := model.ParseTipTapDocument(string(b)) + require.NoError(t, err) + require.NotContains(t, marshal(t, doc), "javascript:alert") +} + +func TestParseTipTapDocumentSanitizesFlatNodeKeys(t *testing.T) { + // A node that carries a handler or href directly on the node object (not under attrs) must be + // neutralized, exactly as the same shape is on a mark. + raw := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "paragraph", + "onclick": "alert(document.cookie)", + "href": "javascript:alert(1)", + "content": []any{map[string]any{"type": "text", "text": "hi"}}, + }, + }, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + doc, err := model.ParseTipTapDocument(string(b)) + require.NoError(t, err) + out := marshal(t, doc) + require.NotContains(t, out, "onclick") + require.NotContains(t, out, "javascript:alert") + require.Contains(t, out, "hi", "the node's legitimate content must survive") +} + +func TestParseTipTapDocumentSanitizesNestedAttrs(t *testing.T) { + // A URL nested inside a sub-object of attrs must be neutralized, not passed through because the + // sanitizer only looked at the top level of the attrs map. + raw := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "image", + "attrs": map[string]any{ + "config": map[string]any{"href": "javascript:alert(1)"}, + "list": []any{map[string]any{"src": "javascript:alert(2)"}}, + }, + }, + }, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + doc, err := model.ParseTipTapDocument(string(b)) + require.NoError(t, err) + require.NotContains(t, marshal(t, doc), "javascript:alert") +} + +func TestParseTipTapDocumentRejectsDeeplyNestedAttrs(t *testing.T) { + // A dangerous URL buried under attrs nested past the depth cap must NOT slip through + // unsanitized: the attrs walk fails closed (rejects the whole document) rather than silently + // stopping and leaving the over-deep subtree untouched. Guards against a fail-open sanitizer + // bypass where a single shallow node hides a deep attrs subtree. + depth := 150 + deep := `{"type":"doc","content":[{"type":"image","attrs":` + + strings.Repeat(`{"a":`, depth) + + `{"href":"javascript:alert(1)"}` + + strings.Repeat(`}`, depth) + + `}]}` + doc, err := model.ParseTipTapDocument(deep) + require.Error(t, err, "attrs nested beyond the limit must be rejected, not silently passed through") + require.NotContains(t, marshal(t, doc), "javascript:alert", "no unsanitized payload may survive") +} + +func TestParseTipTapDocumentDropsNonStringURLAttr(t *testing.T) { + // A URL attribute whose value is not a string (e.g. a JSON array) can be coerced back into a + // dangerous string by a client renderer, so it must be dropped rather than left untouched. + raw := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "text", + "text": "link", + "marks": []any{ + map[string]any{"type": "link", "attrs": map[string]any{"href": []any{"javascript:alert(1)"}}}, + }, + }, + }, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + doc, err := model.ParseTipTapDocument(string(b)) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(marshal(t, doc)), &parsed)) + attrs := parsed["content"].([]any)[0].(map[string]any)["marks"].([]any)[0].(map[string]any)["attrs"].(map[string]any) + require.NotContains(t, attrs, "href", "non-string URL attr must be dropped") +} + +func TestParseTipTapDocumentNeutralizesBareArrayURLStrings(t *testing.T) { + // A dangerous scheme carried as a bare string inside an array attribute value — under a key an + // editor extension may define that is not one of the designated URL keys — has no attribute key + // to mark it a URL, so stripDangerousKeys does not reach it. The array walk must still neutralize + // the executable scheme while leaving legitimate colon-bearing strings (a timestamp, a ratio) and + // safe URLs untouched. + raw := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "fileAttachment", + "attrs": map[string]any{ + "sources": []any{"javascript:alert(1)", "vbscript:msgbox(1)", "http://ok.example", "12:30", "16:9"}, + "nested": []any{[]any{"javascript:alert(2)"}}, + }, + }, + }, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + doc, err := model.ParseTipTapDocument(string(b)) + require.NoError(t, err) + + out := marshal(t, doc) + require.NotContains(t, out, "javascript:alert", "bare javascript: array element must be neutralized") + require.NotContains(t, out, "vbscript:", "bare vbscript: array element must be neutralized") + require.Contains(t, out, "http://ok.example", "a safe URL in the array must be preserved") + require.Contains(t, out, "12:30", "a non-URL colon-bearing string must be preserved") + require.Contains(t, out, "16:9", "a non-URL ratio string must be preserved") + + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &parsed)) + attrs := parsed["content"].([]any)[0].(map[string]any)["attrs"].(map[string]any) + sources := attrs["sources"].([]any) + require.Equal(t, "", sources[0], "javascript: element blanked in place, preserving array length") + require.Equal(t, "", sources[1], "vbscript: element blanked in place") + require.Equal(t, "http://ok.example", sources[2]) +} + +func TestParseTipTapDocumentSanitizesDataAttrs(t *testing.T) { + // A data-* attribute is not necessarily a URL: an extension may store a ratio or timestamp + // under a data-* key. Such colon-bearing values must be preserved (the strict URL allowlist + // would parse the leading token as a scheme and blank them), while a dangerous scheme carried + // under a data-* key is still neutralized, and a non-string data-* value is left untouched. + raw := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "fileAttachment", + "attrs": map[string]any{ + "data-ratio": "16:9", + "data-time": "12:30", + "data-src": "https://ok.example/img.png", + "data-onnav": "javascript:alert(1)", + "data-vb": "vbscript:msgbox(1)", + "data-index": float64(5), + "data-payload": "data:text/html,", + // Mixed-case key: the data- prefix is matched case-insensitively (the key is + // lowercased before the prefix test), so a dangerous scheme here is still blanked + // and a colon-bearing non-URL value is still preserved. + "DATA-Nav": "javascript:alert(2)", + "Data-Ok": "21:9", + // Non-string container under a data-* key: stripDangerousKeys skips the non-string + // value, and the recursive attr walk sanitizes the nested map's designated URL key. + "data-config": map[string]any{"href": "javascript:alert(3)", "keep": "plain"}, + }, + }, + }, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + doc, err := model.ParseTipTapDocument(string(b)) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(marshal(t, doc)), &parsed)) + attrs := parsed["content"].([]any)[0].(map[string]any)["attrs"].(map[string]any) + + require.Equal(t, "16:9", attrs["data-ratio"], "a non-URL ratio under a data-* key must be preserved") + require.Equal(t, "12:30", attrs["data-time"], "a non-URL timestamp under a data-* key must be preserved") + require.Equal(t, "https://ok.example/img.png", attrs["data-src"], "a safe URL under a data-* key must be preserved") + require.Equal(t, "", attrs["data-onnav"], "a javascript: scheme under a data-* key must be neutralized") + require.Equal(t, "", attrs["data-vb"], "a vbscript: scheme under a data-* key must be neutralized") + require.Equal(t, float64(5), attrs["data-index"], "a non-string data-* value must be left untouched") + require.Equal(t, "", attrs["data-payload"], "a non-image data: URI under a data-* key must be neutralized") + require.Equal(t, "", attrs["DATA-Nav"], "a dangerous scheme under a mixed-case data-* key must be neutralized") + require.Equal(t, "21:9", attrs["Data-Ok"], "a non-URL value under a mixed-case data-* key must be preserved") + + config, ok := attrs["data-config"].(map[string]any) + require.True(t, ok, "a nested map under a data-* key must be preserved as a map") + require.Equal(t, "", config["href"], "a dangerous URL nested under a data-* map must be sanitized recursively") + require.Equal(t, "plain", config["keep"], "a non-URL sibling in the nested map must be preserved") +} + +func TestParseTipTapDocumentRejectsTooDeep(t *testing.T) { + // A pathologically deep document is rejected rather than walked. + depth := 200 + deep := `{"type":"doc","content":` + strings.Repeat(`[{"type":"blockquote","content":`, depth) + `[]` + strings.Repeat(`}]`, depth) + `}` + _, err := model.ParseTipTapDocument(deep) + require.Error(t, err, "content nested beyond the limit must be rejected") +} + +func TestParseTipTapDocumentNodeDepthBoundary(t *testing.T) { + // Pins the exact node-nesting cutoff, mirroring maxTipTapDepth in the sanitize walk + // (server/model/page_content.go); a drifted or off-by-one cap fails these loudly, unlike the + // far-past-the-limit rejection test above. + const depthLimit = 100 + + // nestedTo builds a document whose deepest node sits at the given depth (the top-level + // content node is depth 0). + nestedTo := func(depth int) string { + n := depth + 1 + return `{"type":"doc","content":` + strings.Repeat(`[{"type":"blockquote","content":`, n) + `[]` + strings.Repeat(`}]`, n) + `}` + } + + t.Run("nesting at the limit is accepted", func(t *testing.T) { + _, err := model.ParseTipTapDocument(nestedTo(depthLimit)) + require.NoError(t, err) + }) + + t.Run("nesting one past the limit is rejected", func(t *testing.T) { + _, err := model.ParseTipTapDocument(nestedTo(depthLimit + 1)) + require.Error(t, err) + }) +} + +func TestParseTipTapDocumentAttrsDepthBoundary(t *testing.T) { + // Pins the exact attrs-nesting cutoff: the attrs walk shares maxTipTapDepth with the node walk + // but counts depth independently, so it gets its own boundary pin. + const depthLimit = 100 + + // attrsNestedTo builds a node whose attrs contain a map chain whose deepest map sits at the + // given depth (the attrs object itself is depth 0). + attrsNestedTo := func(depth int) string { + return `{"type":"doc","content":[{"type":"paragraph","attrs":` + + strings.Repeat(`{"a":`, depth) + `{"b":1}` + strings.Repeat(`}`, depth) + `}]}` + } + + t.Run("attrs nesting at the limit is accepted", func(t *testing.T) { + _, err := model.ParseTipTapDocument(attrsNestedTo(depthLimit)) + require.NoError(t, err) + }) + + t.Run("attrs nesting one past the limit is rejected", func(t *testing.T) { + _, err := model.ParseTipTapDocument(attrsNestedTo(depthLimit + 1)) + require.Error(t, err) + }) +} + +func TestParseTipTapDocumentRejectsOffSchemaNodeType(t *testing.T) { + // A node type outside the allowlist is rejected outright, so a client node type the server does + // not know about surfaces as a loud failure rather than passing through unrecognized. + _, err := model.ParseTipTapDocument(`{"type":"doc","content":[{"type":"widget","content":[]}]}`) + require.Error(t, err, "an off-schema node type must be rejected") +} + +func TestParseTipTapDocumentRejectsOffSchemaMarkType(t *testing.T) { + // Mark types are allowlisted the same as node types: a mark outside the schema is rejected. + _, err := model.ParseTipTapDocument(`{"type":"doc","content":[{"type":"text","text":"x","marks":[{"type":"blink"}]}]}`) + require.Error(t, err, "an off-schema mark type must be rejected") +} + +func TestParseTipTapDocumentRejectsAllowlistedTypeInWrongCase(t *testing.T) { + // The allowlist matches case-sensitively (TipTap schema names are camelCase). A case variant of an + // allowed type is rejected — this also guards the denylist->allowlist switch, since the old + // case-insensitive denylist would have lowercased "Paragraph" and let it through. + _, err := model.ParseTipTapDocument(`{"type":"doc","content":[{"type":"Paragraph"}]}`) + require.Error(t, err, "a wrong-case node type must be rejected under the case-sensitive allowlist") +} + +func TestParseTipTapDocumentAllowsSchemaNodeTypes(t *testing.T) { + // Representative core-schema and extension node types must pass sanitization unchanged. + for _, nodeType := range []string{"heading", "table", "callout", "taskList", "taskItem", "channelMention", "video", "fileAttachment", "imagePlaceholder", "imageResize"} { + doc := `{"type":"doc","content":[{"type":"` + nodeType + `","content":[]}]}` + _, err := model.ParseTipTapDocument(doc) + require.NoError(t, err, "schema node type %q must be allowed", nodeType) + } +} + +func TestParseTipTapDocumentStripsAdditionalDangerousAttrs(t *testing.T) { + // formaction, dynsrc, and lowsrc are in the denylist but not covered by the main dangerous-attrs test. + raw := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "image", + "attrs": map[string]any{ + "formaction": "https://evil.example", + "dynsrc": "javascript:alert(1)", + "lowsrc": "javascript:alert(2)", + "title": "safe", + }, + }, + }, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + doc, err := model.ParseTipTapDocument(string(b)) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(marshal(t, doc)), &parsed)) + attrs := parsed["content"].([]any)[0].(map[string]any)["attrs"].(map[string]any) + require.NotContains(t, attrs, "formaction") + require.NotContains(t, attrs, "dynsrc") + require.NotContains(t, attrs, "lowsrc") + require.Equal(t, "safe", attrs["title"]) +} + +// buildTipTapContentJSON returns a TipTap document JSON string whose top-level content array holds +// n flat sibling paragraph nodes, built without any nesting so the node count equals n exactly. +func buildTipTapContentJSON(n int) string { + var b strings.Builder + b.WriteString(`{"type":"doc","content":[`) + for i := range n { + if i > 0 { + b.WriteByte(',') + } + b.WriteString(`{"type":"paragraph"}`) + } + b.WriteString(`]}`) + return b.String() +} + +func TestParseTipTapDocumentRejectsTooManyNodes(t *testing.T) { + // Mirrors the maxTipTapNodes guard in sanitizeTipTapDocument (server/model/page_content.go). + const nodeLimit = 50_000 + + t.Run("node count at the limit is accepted", func(t *testing.T) { + doc, err := model.ParseTipTapDocument(buildTipTapContentJSON(nodeLimit)) + require.NoError(t, err) + require.Len(t, doc.Content, nodeLimit) + }) + + t.Run("node count exceeding the limit is rejected", func(t *testing.T) { + _, err := model.ParseTipTapDocument(buildTipTapContentJSON(nodeLimit + 1)) + require.Error(t, err) + require.Contains(t, err.Error(), "exceeds the maximum of 50000 nodes") + }) +} + +func TestBuildSearchText(t *testing.T) { + t.Run("extracts and joins text", func(t *testing.T) { + doc, err := model.ParseTipTapDocument(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"},{"type":"text","text":"world"}]}]}`) + require.NoError(t, err) + require.Equal(t, "hello world", model.BuildSearchText(doc)) + }) + + t.Run("extracts mention labels", func(t *testing.T) { + doc, err := model.ParseTipTapDocument(`{"type":"doc","content":[{"type":"mention","attrs":{"label":"alice"}}]}`) + require.NoError(t, err) + require.Equal(t, "@alice", model.BuildSearchText(doc)) + }) + + t.Run("extracts channelMention label", func(t *testing.T) { + doc, err := model.ParseTipTapDocument(`{"type":"doc","content":[{"type":"channelMention","attrs":{"label":"general"}}]}`) + require.NoError(t, err) + require.Equal(t, "@general", model.BuildSearchText(doc)) + }) + + t.Run("empty document returns empty string", func(t *testing.T) { + doc, err := model.ParseTipTapDocument(`{"type":"doc","content":[]}`) + require.NoError(t, err) + require.Equal(t, "", model.BuildSearchText(doc)) + }) + + t.Run("collapses whitespace", func(t *testing.T) { + doc, err := model.ParseTipTapDocument(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a b\n\nc"}]}]}`) + require.NoError(t, err) + require.Equal(t, "a b c", model.BuildSearchText(doc)) + }) +} + +func TestParseTipTapDocumentSanitizesWhitespacePrefixedAttrKeys(t *testing.T) { + // An HTML tokenizer treats "\tonclick" as the onclick attribute, so keys carrying leading or + // trailing whitespace/control characters must be recognized the same as their clean forms. No + // editor schema defines a padded name, so every one of them is dropped rather than kept with a + // sanitized value — a renderer resolving the trimmed name must not find two entries for it. + raw := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "image", + "attrs": map[string]any{ + " onclick": "alert(document.cookie)", + "\tonerror": "steal()", + " href": "javascript:alert(1)", + "alt": "a cat", + }, + }, + }, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + doc, err := model.ParseTipTapDocument(string(b)) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(marshal(t, doc)), &parsed)) + attrs := parsed["content"].([]any)[0].(map[string]any)["attrs"].(map[string]any) + + require.NotContains(t, attrs, " onclick", "whitespace-prefixed event handler must be stripped") + require.NotContains(t, attrs, "\tonerror", "control-char-prefixed event handler must be stripped") + require.NotContains(t, attrs, " href", "whitespace-prefixed URL key must be dropped, not kept sanitized") + require.NotContains(t, attrs, "href", "dropping the padded key must not introduce a canonical one") + require.Equal(t, "a cat", attrs["alt"], "clean attr must survive") +} diff --git a/server/model/page_presence.go b/server/model/page_presence.go new file mode 100644 index 0000000..23873a9 --- /dev/null +++ b/server/model/page_presence.go @@ -0,0 +1,17 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +// PageActiveEditors is the editor-presence snapshot for a page: the users counted as editing it, +// when the snapshot was taken, and how long an entry stays current without a further save. A client +// that receives no newer snapshot expires the list itself once ActiveTimeoutMs has elapsed since +// SnapshotAt. +// +// The REST active-editors endpoint and the page_presence_updated WebSocket payload carry these same +// fields, so a client reads one presence contract whether it resyncs or receives a live event. +type PageActiveEditors struct { + ActiveEditors []string `json:"active_editors"` + SnapshotAt int64 `json:"snapshot_at"` + ActiveTimeoutMs int64 `json:"active_timeout_ms"` +} diff --git a/server/model/props.go b/server/model/props.go index 98f0218..9ff8692 100644 --- a/server/model/props.go +++ b/server/model/props.go @@ -20,9 +20,9 @@ func ensureProps(props mmmodel.StringInterface) mmmodel.StringInterface { return maps.Clone(props) } -// validatePropsSize enforces the serialized-size cap on a Props map. +// ValidatePropsSize enforces the serialized-size cap on a Props map. // The where argument identifies the calling operation for logs; the message keys are shared across callers. -func validatePropsSize(where, details string, props mmmodel.StringInterface, maxBytes int) *mmmodel.AppError { +func ValidatePropsSize(where, details string, props mmmodel.StringInterface, maxBytes int) *mmmodel.AppError { if props == nil { return nil } diff --git a/server/model/props_test.go b/server/model/props_test.go index 48d05fb..5bd4e45 100644 --- a/server/model/props_test.go +++ b/server/model/props_test.go @@ -13,7 +13,8 @@ import ( "github.com/mattermost/mattermost-plugin-docs/server/model" ) -// validatePropsSize is unexported; exercise it through Page.IsValid which delegates to it. +// TestValidatePropsSize exercises model.ValidatePropsSize indirectly through Page.IsValid, +// which delegates to it. func TestValidatePropsSize(t *testing.T) { t.Run("nil props passes", func(t *testing.T) { p := validPage() @@ -42,3 +43,21 @@ func TestValidatePropsSize(t *testing.T) { require.Equal(t, "model.shared.props_too_large.app_error", err.Id) }) } + +func TestValidatePropsSizeDirect(t *testing.T) { + t.Run("nil props passes", func(t *testing.T) { + require.Nil(t, model.ValidatePropsSize("where", "details", nil, model.PagePropsMaxBytes)) + }) + + t.Run("props within limit passes", func(t *testing.T) { + props := mmmodel.StringInterface{"key": "value"} + require.Nil(t, model.ValidatePropsSize("where", "details", props, model.PagePropsMaxBytes)) + }) + + t.Run("props over limit fails", func(t *testing.T) { + props := mmmodel.StringInterface{"key": strings.Repeat("x", model.PagePropsMaxBytes+1)} + err := model.ValidatePropsSize("where", "details", props, model.PagePropsMaxBytes) + require.NotNil(t, err) + require.Equal(t, "model.shared.props_too_large.app_error", err.Id) + }) +} diff --git a/server/model/space.go b/server/model/space.go index a5ccb72..ad3029d 100644 --- a/server/model/space.go +++ b/server/model/space.go @@ -183,7 +183,7 @@ func (s *Space) IsValid() *mmmodel.AppError { return mmmodel.NewAppError("Space.IsValid", "model.space.is_valid.icon_length.app_error", nil, "id="+s.Id, http.StatusBadRequest) } - if err := validatePropsSize("Space.IsValid", "id="+s.Id, s.Props, SpacePropsMaxBytes); err != nil { + if err := ValidatePropsSize("Space.IsValid", "id="+s.Id, s.Props, SpacePropsMaxBytes); err != nil { return err } diff --git a/server/store/draft_store.go b/server/store/draft_store.go index b72e650..47fbbdf 100644 --- a/server/store/draft_store.go +++ b/server/store/draft_store.go @@ -5,21 +5,30 @@ package store import ( "database/sql" + "fmt" + "strings" "github.com/jmoiron/sqlx" + mmmodel "github.com/mattermost/mattermost/server/public/model" sq "github.com/mattermost/squirrel" "github.com/pkg/errors" "github.com/mattermost/mattermost-plugin-docs/server/model" ) +// maxActiveEditorsPerPage caps the number of user IDs returned by GetPageActiveEditors. A page +// with more simultaneous editors than this cap is pathological; the cap prevents unbounded result +// sets if LastActiveAt filtering alone is insufficient. +const maxActiveEditorsPerPage = 100 + var draftSelectColumns = []string{ - "UserId", "SpaceId", "PageId", "ParentId", "Title", "Body", "FileIds", "Props", "CreateAt", "UpdateAt", + "UserId", "SpaceId", "PageId", "ParentId", "Title", "Body", "FileIds", "Props", "CreateAt", "UpdateAt", "LastActiveAt", "BaseEditAt", } -// draftMetaColumns is the metadata column set for draft queries — Body omitted because it can be up to PageBodyMaxBytes per draft. +// draftMetaColumns is the metadata column set for draft queries — Body and Props omitted because +// they can be up to PageBodyMaxBytes and PagePropsMaxBytes per draft (see model.DraftSummary). var draftMetaColumns = []string{ - "UserId", "SpaceId", "PageId", "ParentId", "Title", "FileIds", "Props", "CreateAt", "UpdateAt", + "UserId", "SpaceId", "PageId", "ParentId", "Title", "FileIds", "CreateAt", "UpdateAt", "LastActiveAt", } // applyDraftLivenessFilter adds the space-liveness JOIN and page-liveness condition shared by @@ -36,12 +45,14 @@ func applyDraftLivenessFilter(q sq.SelectBuilder) sq.SelectBuilder { }) } -// deleteDraftsForPage hard-deletes every user's draft for pageID. Drafts have no soft-delete -// and must be cleaned up when the page is deleted. Must run inside tx. -func (s *Store) deleteDraftsForPage(tx *sqlx.Tx, pageID string) error { +// deleteDraftsForPage hard-deletes every user's draft for pageID scoped to spaceID. Drafts have +// no soft-delete and must be cleaned up when the page is deleted. The spaceID predicate prevents +// a delete in one space from removing drafts for the same pageID in another space. Must run +// inside tx. +func (s *Store) deleteDraftsForPage(tx *sqlx.Tx, pageID, spaceID string) error { query := s.getQueryBuilder(). Delete("DOCS_Draft"). - Where(sq.Eq{"PageId": pageID}) + Where(sq.Eq{"PageId": pageID, "SpaceId": spaceID}) if _, err := s.execBuilder(tx, query); err != nil { return errors.Wrap(err, "failed to delete page drafts") } @@ -49,95 +60,398 @@ func (s *Store) deleteDraftsForPage(tx *sqlx.Tx, pageID string) error { } // reparentDraftsForPage reparents every new-page draft pointing at pageID to newParentID, -// so drafts don't retain a soft-deleted page as their pending parent. Must run inside tx. -func (s *Store) reparentDraftsForPage(tx *sqlx.Tx, pageID, newParentID string, now int64) error { +// so drafts don't retain a soft-deleted page as their pending parent. The spaceID predicate +// scopes the rewrite the same way deleteDraftsForPage scopes its delete. Must run inside tx. +// +// UpdateAt uses GREATEST(now, UpdateAt+1) for the same reason as UpsertDraft: it must be a +// strictly-monotonic token so the publish CAS-delete (deletePublishedDraftTx) cannot match a row +// that a concurrent autosave already advanced past this reparent. +func (s *Store) reparentDraftsForPage(tx *sqlx.Tx, pageID, newParentID, spaceID string, now int64) error { query := s.getQueryBuilder(). Update("DOCS_Draft"). Set("ParentId", newParentID). - Set("UpdateAt", now). - Where(sq.Eq{"ParentId": pageID}) + Set("UpdateAt", monotonicBump("UpdateAt", now)). + Where(sq.Eq{"ParentId": pageID, "SpaceId": spaceID}) if _, err := s.execBuilder(tx, query); err != nil { return errors.Wrap(err, "failed to reparent page drafts") } return nil } +// draftParentExistsTx reports whether userID has a draft for parentID in spaceID, under the +// same visibility rule as GetDraft, reading within tx so it observes uncommitted state. It locks +// the matched parent-draft row (FOR UPDATE OF d) so a concurrent DeleteDraftReparenting cannot +// remove the parent between this check and the child-draft insert. Only the draft row is locked, since the +// liveness filter LEFT JOINs the nullable page side, which cannot be a FOR UPDATE target. +func (s *Store) draftParentExistsTx(tx *sqlx.Tx, userID, spaceID, parentID string) (bool, error) { + builder := applyDraftLivenessFilter( + s.getQueryBuilder(). + Select("1"). + From("DOCS_Draft d"), + ).Where(sq.Eq{"d.UserId": userID, "d.SpaceId": spaceID, "d.PageId": parentID}). + Suffix("FOR UPDATE OF d") + + var one int + if err := s.getBuilder(tx, &one, builder); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return false, errors.Wrap(err, "failed to check draft parent") + } + return true, nil +} + +// countDraftsForUser returns the number of draft rows the user holds in spaceID, using the given executor. +func (s *Store) countDraftsForUser(e sqlx.ExtContext, userID, spaceID string) (int, error) { + var count int + builder := s.getQueryBuilder(). + Select("COUNT(*)"). + From("DOCS_Draft"). + Where(sq.Eq{"UserId": userID, "SpaceId": spaceID}) + if err := s.getBuilder(e, &count, builder); err != nil { + return 0, errors.Wrap(err, "unable_to_count_drafts_for_user") + } + return count, nil +} + +// draftExistsTx reports whether a draft row keyed by (userID, pageID) currently exists — and if so, +// its stored SpaceId — read within tx so it observes the transaction's own uncommitted writes. +func (s *Store) draftExistsTx(tx *sqlx.Tx, userID, pageID string) (exists bool, spaceID string, err error) { + builder := s.getQueryBuilder(). + Select("SpaceId"). + From("DOCS_Draft"). + Where(sq.Eq{"UserId": userID, "PageId": pageID}) + switch err := s.getBuilder(tx, &spaceID, builder); { + case err == nil: + return true, spaceID, nil + case errors.Is(err, sql.ErrNoRows): + return false, "", nil + default: + return false, "", errors.Wrap(err, "failed to check draft existence") + } +} + +// checkNoDraftCycle walks the parent chain from startParentID through the caller's draft rows and +// returns an error if leafPageID appears anywhere in the chain (cycle) or if the total depth +// (draft chain + live-page ancestor) would exceed model.MaxPageDepth. A live-page ancestor +// terminates the recursion: an edit draft on a live page is version bookkeeping, not a hierarchy +// edge (its ParentId is never applied at publish), so the walk follows draft ParentId links only +// for new-page drafts and switches to the live tree (pageDepth) at the boundary. Squirrel cannot +// express recursive CTEs, so raw SQL is used here. +func (s *Store) checkNoDraftCycle(tx *sqlx.Tx, userID, leafPageID, startParentID string) error { + // The recursive term follows a draft row only when its page is not live — an edit draft on a + // live page must not redirect the walk (its stored ParentId defaults to '' and would silently + // truncate the chain). The live_ancestor subquery finds the deepest node that IS a live page + // (the live-tree boundary). COALESCE converts NULL to '' so the struct scan never fails. + query := fmt.Sprintf(` +WITH RECURSIVE chain(node, depth) AS ( + SELECT $1::varchar(26), 0 + UNION ALL + SELECT d.ParentId, chain.depth + 1 + FROM DOCS_Draft d + JOIN chain ON d.PageId = chain.node + WHERE d.UserId = $2 + AND chain.node <> '' + AND chain.depth < %d + AND NOT EXISTS ( + SELECT 1 FROM DOCS_Page lp + WHERE lp.Id = d.PageId AND lp.DeleteAt = 0 AND lp.OriginalId = '' + ) +) +SELECT + COALESCE(bool_or(node = $3), false) AS is_cycle, + COALESCE(max(depth) >= %d, false) AS too_deep, + COALESCE(max(depth), 0) AS chain_depth, + COALESCE(( + SELECT c.node FROM chain c + WHERE c.node <> '' + AND EXISTS (SELECT 1 FROM DOCS_Page lp WHERE lp.Id = c.node AND lp.DeleteAt = 0 AND lp.OriginalId = '') + ORDER BY c.depth DESC LIMIT 1 + ), '') AS live_ancestor +FROM chain`, model.MaxPageDepth, model.MaxPageDepth) + + var result struct { + IsCycle bool `db:"is_cycle"` + TooDeep bool `db:"too_deep"` + ChainDepth int `db:"chain_depth"` + LiveAncestor string `db:"live_ancestor"` + } + if err := s.get(tx, &result, query, startParentID, userID, leafPageID); err != nil { + return errors.Wrap(err, "cycle check: failed to read ancestor chain") + } + if result.IsCycle { + return &ErrInvalidInput{Entity: "Draft", Field: "ParentId", Value: startParentID, Reason: ReasonDraftCycle} + } + if result.TooDeep { + return &ErrInvalidInput{Entity: "Draft", Field: "ParentId", Value: startParentID, Reason: ReasonDraftTooDeep} + } + // Include the live-page ancestor's depth: a draft chain valid on its own can still exceed + // the publishing limit when the live ancestor is already deeply nested. + if result.LiveAncestor != "" { + liveDepth, err := s.pageDepth(tx, result.LiveAncestor) + if err != nil { + return errors.Wrap(err, "cycle check: failed to read live ancestor depth") + } + // Total depth = liveDepth + ChainDepth + 1: liveDepth is the live ancestor's own absolute + // depth (counts the ancestor itself), ChainDepth is the number of draft hops from that + // ancestor down to the new parent, and +1 places the new leaf one level below its parent. + if liveDepth+result.ChainDepth+1 > model.MaxPageDepth { + return &ErrInvalidInput{Entity: "Draft", Field: "ParentId", Value: startParentID, Reason: ReasonDraftTooDeep} + } + } + return nil +} + // UpsertDraft creates or replaces the draft keyed by (UserId, PageId). It fills in defaults and // rejects an invalid draft itself, so the caller need not prepare or validate it beforehand. -// If a draft already exists for that key every field is overwritten (no field-level merge), -// except CreateAt, which keeps the existing row's original value. // -// The draft's space must be live; the PageId constraint is enforced at the page lock below. -func (s *Store) UpsertDraft(draft *model.Draft) (_ *model.Draft, err error) { +// An autosave may carry only the fields the editor changed, so on the update path an empty Title or +// Body means "not sent", not "cleared", and the stored value is kept (a cleared document is +// EmptyTipTapJSON, not ""). ParentId, FileIds, and Props do not follow this empty-means-not-sent +// rule: they use explicit pointer intent (see the parentID/fileIDs/props paragraphs below), where a +// nil pointer — not an empty value — means "not sent". +// CreateAt keeps the existing row's original value. UpdateAt is bumped strictly monotonically +// (GREATEST(incoming, stored+1)), so it is a collision-free version token: publish CAS-deletes the +// draft on this value, and two saves within the same millisecond can no longer share it. All of this +// happens inside the single upsert statement, so two concurrent autosaves cannot lose a field by +// merging against a stale read. The stored row is returned. +// +// The draft's space must be live, and the PageId must belong to the draft's space. +// +// parentID encodes the write intent for the ParentId column: nil means "omitted — preserve the +// existing stored parent", a pointer to "" means "clear to root", and a pointer to a valid ID +// means "set to that ID". The draft struct's own ParentId field is not used on the write path. +// +// fileIDs encodes the write intent for the FileIds column: nil means "omitted — preserve the +// existing stored value", a pointer to an empty slice means "clear to no attachments", and a +// pointer to a non-empty slice means "replace with these IDs". This mirrors parentID's +// preserve/clear/set semantics. +// +// props encodes the write intent for the Props column: nil means "omitted — preserve the existing +// stored map", and a non-nil pointer replaces the whole map with the pointed-to value (an empty map +// clears all keys). This is a whole-value replace, not a key-wise merge, mirroring parentID/fileIDs. +// +// The pointed-to fileIDs and props values are bounded by model.ValidateDraftWriteIntent here, since +// the struct's own fields — the only ones IsValid checks — are not what gets written. +func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmodel.StringArray, props *mmmodel.StringInterface) (*model.Draft, bool, error) { + return s.upsertDraft(draft, parentID, fileIDs, props, false) +} + +// AutosaveDraft is UpsertDraft with the autosave-path guards enabled: it refuses to establish a new +// draft row when the page id has no live page backing it (only CreateSpaceDraft may reserve a page +// id, so an autosave for an unknown id cannot squat on it), and it rejects an autosave addressed to +// a different space than an existing draft's stored SpaceId (ReasonPageNotLive) — e.g. a client +// still saving under a stale space URL after a cross-space move re-homed the draft — preventing +// cross-space drift. Updating the caller's own existing draft, including a new-page draft whose +// page is not yet published, is unaffected. +func (s *Store) AutosaveDraft(draft *model.Draft, parentID *string, fileIDs *mmmodel.StringArray, props *mmmodel.StringInterface) (*model.Draft, bool, error) { + return s.upsertDraft(draft, parentID, fileIDs, props, true) +} + +func (s *Store) upsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmodel.StringArray, props *mmmodel.StringInterface, autosave bool) (_ *model.Draft, pageWasLive bool, err error) { if draft == nil { - return nil, &ErrInvalidInput{Entity: "Draft", Field: "draft", Value: nil} + return nil, false, &ErrInvalidInput{Entity: "Draft", Field: "draft", Value: nil} + } + + // parentIDParam is the SQL-level parameter: nil → SQL NULL (preserve on conflict), non-nil + // → the explicit value (stored via COALESCE on INSERT, used directly on UPDATE). + var parentIDParam any + if parentID != nil { + parentIDParam = *parentID + } + + // fileIDsParam follows the same nil/non-nil semantics as parentIDParam. + var fileIDsParam any + if fileIDs != nil { + fileIDsParam = mmmodel.ArrayToJSON([]string(*fileIDs)) + } + + // propsParam follows the same nil/non-nil semantics: nil → SQL NULL (preserve on conflict), + // non-nil → the whole map (replace). A non-nil pointer to a nil map is normalized to an empty + // map so it serializes as JSONB '{}' (clear), never JSON null. + var propsParam any + if props != nil { + p := *props + if p == nil { + p = mmmodel.StringInterface{} + } + propsParam = p } draft.PreSave() if validErr := draft.IsValid(); validErr != nil { - return nil, &ErrInvalidInput{Entity: "Draft", Field: "IsValid", Value: validErr.Error(), Reason: validErr.Id} + return nil, false, &ErrInvalidInput{Entity: "Draft", Field: "IsValid", Value: validErr.Error(), Reason: validErr.Id} + } + // The pointer-carried values are merged in SQL below and never populate the struct's own fields, + // so IsValid does not cover them; bound them here, where every caller of the upsert passes. + if validErr := model.ValidateDraftWriteIntent("Draft.IsValid", "page_id="+draft.PageId, fileIDs, props); validErr != nil { + return nil, false, &ErrInvalidInput{Entity: "Draft", Field: "IsValid", Value: validErr.Error(), Reason: validErr.Id} } tx, err := s.db.Beginx() if err != nil { - return nil, errors.Wrap(err, "begin_transaction") + return nil, false, errors.Wrap(err, "begin_transaction") } defer s.finalizeTransaction(tx, &err) if lockErr := s.lockLiveSpace(tx, draft.SpaceId); lockErr != nil { - return nil, lockErr + return nil, false, lockErr + } + + isExisting, existingSpaceID, existErr := s.draftExistsTx(tx, draft.UserId, draft.PageId) + if existErr != nil { + return nil, false, existErr + } + // On the autosave path an existing draft pins the space: the ON CONFLICT clause below never + // changes a stored SpaceId, so an upsert addressed to a different space would silently update + // the row's content under the wrong space URL. Reject it instead. + if autosave && isExisting && existingSpaceID != draft.SpaceId { + return nil, false, &ErrInvalidInput{Entity: "Draft", Field: "SpaceId", Value: draft.SpaceId, Reason: ReasonPageNotLive} + } + // Quota check: enforce MaxDraftsPerUserPerSpace atomically inside the space lock so + // concurrent CreateSpaceDraft calls in the same space cannot both pass a stale pre-check + // and each insert a row that pushes the total past the cap. + // Skip the check on the UPDATE path: updating an existing draft adds no row, so it cannot + // push the total over the cap. + if !isExisting { + count, countErr := s.countDraftsForUser(tx, draft.UserId, draft.SpaceId) + if countErr != nil { + return nil, false, countErr + } + if count >= model.MaxDraftsPerUserPerSpace { + return nil, false, &ErrLimitExceeded{Resource: "Draft", Limit: model.MaxDraftsPerUserPerSpace, Reason: ReasonDraftQuotaExceeded} + } } var page struct { SpaceID string DeleteAt int64 OriginalId string + EditAt int64 } pageLockQuery := s.getQueryBuilder(). - Select("SpaceId", "DeleteAt", "OriginalId"). + Select("SpaceId", "DeleteAt", "OriginalId", "EditAt"). From("DOCS_Page"). Where(sq.Eq{"Id": draft.PageId}). Suffix("FOR UPDATE") switch pErr := s.getBuilder(tx, &page, pageLockQuery); { case pErr == nil: // A page row exists: the draft edits it, so it must be a live page in the - // draft's own space. + // draft's own space. Report that liveness so the caller can decide presence + // scoping. + pageWasLive = true if page.DeleteAt != 0 || page.OriginalId != "" || page.SpaceID != draft.SpaceId { - return nil, &ErrInvalidInput{Entity: "Draft", Field: "PageId", Value: draft.PageId} + return nil, false, &ErrInvalidInput{Entity: "Draft", Field: "PageId", Value: draft.PageId, Reason: ReasonPageNotLive} + } + // Establish-time baseline sanity check: on the establishing INSERT (no draft row yet), a + // baseline ahead of the live page is impossible — the client cannot have seen a version newer + // than the one that exists — so reject it as invalid input. isExisting + // is authoritative here: lockLiveSpace's per-space FOR UPDATE serializes same-space upserts + // before it is read, so a concurrent establish cannot make it stale. The update path needs no + // guard — BaseEditAt is write-once, so the incoming value is ignored on conflict. + if !isExisting && draft.BaseEditAt > page.EditAt { + return nil, false, &ErrInvalidInput{Entity: "Draft", Field: "BaseEditAt", Value: draft.BaseEditAt} + } + // Refuse to resurrect a draft a concurrent publish already consumed. When this autosave's + // edit-session baseline is behind the page's current EditAt, the page advanced under it (a + // publish or another edit). A still-existing draft row may keep saving — the conflict is + // deferred to publish — but if no row exists, this upsert would re-INSERT a phantom draft + // that a just-committed publish deleted, so reject it as a stale edit instead. Holding the + // page row FOR UPDATE serializes this with the publish transaction's draft delete, so the existence + // check is stable within the transaction. + conflictReason := "" + base := draft.BaseEditAt + if base != 0 && page.EditAt > base { + conflictReason = ReasonConcurrentEdit + } else if base == 0 { + // New-page autosave: no optimistic-lock baseline was set. The page row now exists, + // which means a concurrent publish claimed this page id. If the draft no longer + // exists (publish deleted it), reject rather than resurrect it — a re-INSERT here + // would leave stale recoverable content and ghost presence behind. Holding the page + // row FOR UPDATE serializes this check with the publish transaction's draft delete. + conflictReason = ReasonConcurrentAutosave + } + if conflictReason != "" { + // Re-read draft existence now that the page row is locked: a concurrent publish + // may have committed (deleting the draft) between the pre-lock draftExistsTx above + // and this point, making the earlier isExisting result stale. + isExistingNow, _, reErr := s.draftExistsTx(tx, draft.UserId, draft.PageId) + if reErr != nil { + return nil, false, reErr + } + if !isExistingNow { + return nil, false, &ErrConflict{Resource: "Draft page_id=" + draft.PageId, Reason: conflictReason} + } } case errors.Is(pErr, sql.ErrNoRows): - // New-page draft: no page row to lock. + // New-page draft: no page row to lock. On the autosave path, a draft may only be + // established here when the page is live — a page id with neither a page row nor an + // existing draft is unknown, and autosaving it would ghost-draft (or squat on) an + // unreserved id. Reserving a page id is CreateSpaceDraft's job. + if autosave && !isExisting { + return nil, false, &ErrInvalidInput{Entity: "Draft", Field: "PageId", Value: draft.PageId, Reason: ReasonPageNotLive} + } default: - return nil, errors.Wrap(pErr, "failed to lock page for draft upsert") + return nil, false, errors.Wrap(pErr, "failed to lock page for draft upsert") } - // Re-validate the parent is still live under the same transaction (see lockLiveParent). A - // cross-space ParentId, or one whose page row does not exist yet, finds no row and is rejected. - if draft.ParentId != "" { - if parentErr := s.lockLiveParent(tx, draft.ParentId, draft.SpaceId, "Draft"); parentErr != nil { - return nil, parentErr + // Re-validate the parent under the same transaction. A parent is valid when it is a live + // page in the draft's space (see tryLockLiveParent) or a draft of the same user in the same + // space — a child draft may sit under a not-yet-published draft parent, and publish gates + // on the parent being published. Anything else is rejected. + // A nil parentID means "preserve existing"; it skips validation. An explicit "" clears to root + // and also skips validation. Only a non-empty parentID needs the liveness and cycle checks. + if parentID != nil && *parentID != "" { + ok, parentErr := s.tryLockLiveParent(tx, *parentID, draft.SpaceId) + if parentErr != nil { + return nil, false, parentErr + } + if !ok { + ok, parentErr = s.draftParentExistsTx(tx, draft.UserId, draft.SpaceId, *parentID) + if parentErr != nil { + return nil, false, parentErr + } + } + if !ok { + return nil, false, &ErrInvalidInput{Entity: "Draft", Field: "ParentId", Value: *parentID, Reason: ReasonParentNotLive} + } + if cycleErr := s.checkNoDraftCycle(tx, draft.UserId, draft.PageId, *parentID); cycleErr != nil { + return nil, false, cycleErr } } + // The INSERT ... VALUES is squirrel-built; squirrel cannot express ON CONFLICT ... DO UPDATE or + // RETURNING, so those are carried raw in the Suffix below. + // The COALESCE in VALUES ensures NOT NULL is satisfied on INSERT; the CASE in the ON CONFLICT + // clause reads the original bound parameter (not EXCLUDED.ParentId) to distinguish nil + // ("omit, preserve") from "" ("explicit clear to root"). builder := s.getQueryBuilder(). Insert("DOCS_Draft"). Columns(draftSelectColumns...). - Values(draft.UserId, draft.SpaceId, draft.PageId, draft.ParentId, draft.Title, draft.Body, draft.FileIds, draft.GetProps(), draft.CreateAt, draft.UpdateAt). - Suffix("ON CONFLICT (UserId, PageId) DO UPDATE SET SpaceId = EXCLUDED.SpaceId, ParentId = EXCLUDED.ParentId, Title = EXCLUDED.Title, Body = EXCLUDED.Body, FileIds = EXCLUDED.FileIds, Props = EXCLUDED.Props, UpdateAt = EXCLUDED.UpdateAt RETURNING CreateAt") + Values(draft.UserId, draft.SpaceId, draft.PageId, sq.Expr("COALESCE(?::varchar(26), '')", parentIDParam), draft.Title, draft.Body, sq.Expr("COALESCE(?::text, '[]')", fileIDsParam), sq.Expr("COALESCE(?::jsonb, '{}'::jsonb)", propsParam), draft.CreateAt, draft.UpdateAt, draft.LastActiveAt, draft.BaseEditAt). + Suffix(`ON CONFLICT (UserId, PageId) DO UPDATE SET + SpaceId = DOCS_Draft.SpaceId, + ParentId = CASE WHEN ?::varchar(26) IS NULL THEN DOCS_Draft.ParentId ELSE EXCLUDED.ParentId END, + Title = COALESCE(NULLIF(EXCLUDED.Title, ''), DOCS_Draft.Title), + Body = COALESCE(NULLIF(EXCLUDED.Body, ''), DOCS_Draft.Body), + FileIds = CASE WHEN ?::text IS NULL THEN DOCS_Draft.FileIds ELSE EXCLUDED.FileIds END, + Props = CASE WHEN ?::jsonb IS NULL THEN DOCS_Draft.Props ELSE EXCLUDED.Props END, + UpdateAt = GREATEST(EXCLUDED.UpdateAt, DOCS_Draft.UpdateAt + 1), + LastActiveAt = GREATEST(EXCLUDED.LastActiveAt, DOCS_Draft.LastActiveAt) + RETURNING `+strings.Join(draftSelectColumns, ", "), parentIDParam, fileIDsParam, propsParam) - // On the update path CreateAt keeps the existing row's value (it is not in the SET list), - // which can differ from the caller-supplied one, so read the stored value back. - var storedCreateAt int64 - if cErr := s.getBuilder(tx, &storedCreateAt, builder); cErr != nil { - return nil, errors.Wrap(cErr, "unable_to_upsert_draft") + // Read the stored row back: the omitted-field preserve and the props merge happen in the + // statement above, so the returned row — not the caller's struct — is the saved draft. + var stored model.Draft + if cErr := s.getBuilder(tx, &stored, builder); cErr != nil { + return nil, false, errors.Wrap(cErr, "unable_to_upsert_draft") } - draft.CreateAt = storedCreateAt if err = tx.Commit(); err != nil { - return nil, errors.Wrap(err, "commit_transaction") + return nil, false, errors.Wrap(err, "commit_transaction") } - return draft, nil + return &stored, pageWasLive, nil } // GetDraft returns the draft keyed by (userID, pageID), or ErrNotFound. It is gated the same @@ -168,6 +482,34 @@ func (s *Store) GetDraft(userID, pageID string) (*model.Draft, error) { return &draft, nil } +// GetDraftSpaceID returns the SpaceId of the draft keyed by (userID, pageID) without fetching the +// row, gated by the same liveness filter as GetDraft — for callers that need only the space (or +// bare existence) and must not haul the draft body. Returns ErrNotFound when no live draft exists. +func (s *Store) GetDraftSpaceID(userID, pageID string) (string, error) { + if userID == "" { + return "", &ErrInvalidInput{Entity: "Draft", Field: "userId", Value: userID} + } + if pageID == "" { + return "", &ErrInvalidInput{Entity: "Draft", Field: "pageId", Value: pageID} + } + + builder := applyDraftLivenessFilter( + s.getQueryBuilder(). + Select("d.SpaceId"). + From("DOCS_Draft d"), + ).Where(sq.Eq{"d.UserId": userID, "d.PageId": pageID}) + + var spaceID string + if err := s.getBuilder(s.db, &spaceID, builder); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", &ErrNotFound{EntityName: "Draft", ID: pageID} + } + return "", errors.Wrap(err, "unable_to_get_draft_space_id") + } + + return spaceID, nil +} + // DeleteDraft removes the draft keyed by (userID, pageID), or returns ErrNotFound. func (s *Store) DeleteDraft(userID, pageID string) error { if userID == "" { @@ -189,18 +531,132 @@ func (s *Store) DeleteDraft(userID, pageID string) error { return checkRowsAffected(result, "Draft", pageID) } +// DeleteDraftVersion deletes the draft keyed by (userID, pageID) only if its UpdateAt still equals +// expectedUpdateAt. It returns true when a row was deleted, and false — without error — when the +// version no longer matches (a newer autosave exists and must be left intact) or no draft exists. +// Use it to discard a draft the caller believes it has finished with, without clobbering a +// concurrent autosave that landed in the meantime. +func (s *Store) DeleteDraftVersion(userID, pageID string, expectedUpdateAt int64) (bool, error) { + if userID == "" { + return false, &ErrInvalidInput{Entity: "Draft", Field: "userId", Value: userID} + } + if pageID == "" { + return false, &ErrInvalidInput{Entity: "Draft", Field: "pageId", Value: pageID} + } + + builder := s.getQueryBuilder(). + Delete("DOCS_Draft"). + Where(sq.Eq{"UserId": userID, "PageId": pageID, "UpdateAt": expectedUpdateAt}) + + result, err := s.execBuilder(s.db, builder) + if err != nil { + return false, errors.Wrap(err, "unable_to_delete_draft_version") + } + rows, err := result.RowsAffected() + if err != nil { + return false, errors.Wrap(err, "unable_to_read_rows_affected_delete_draft_version") + } + return rows > 0, nil +} + +// DeleteDraftReparenting deletes the draft keyed by (userID, spaceID, pageID). When the discarded +// draft is a new-page draft (no live page yet), its child drafts are first reparented to its own +// parent so they don't dangle; an edit draft's children still point at the live page and are left +// untouched. Returns ErrNotFound when no draft exists for (userID, spaceID, pageID). pageWasLive is +// true when the deleted draft was an edit draft (the page exists as a live page), false for new-page +// drafts; callers use this to decide whether a presence broadcast is needed. +func (s *Store) DeleteDraftReparenting(userID, spaceID, pageID string) (pageWasLive bool, err error) { + if userID == "" { + return false, &ErrInvalidInput{Entity: "Draft", Field: "userId", Value: userID} + } + if spaceID == "" { + return false, &ErrInvalidInput{Entity: "Draft", Field: "spaceId", Value: spaceID} + } + if pageID == "" { + return false, &ErrInvalidInput{Entity: "Draft", Field: "pageId", Value: pageID} + } + + tx, err := s.db.Beginx() + if err != nil { + return false, errors.Wrap(err, "begin_transaction") + } + defer s.finalizeTransaction(tx, &err) + + // Lock the space row before any draft row, matching the space→row lock order of every other + // structural draft mutation (UpsertDraft, deletePublishedDraftTx): this serializes concurrent + // discards in the same chain so two cannot interleave into a dangling parent, and keeps the lock order + // consistent to avoid an AB-BA deadlock against UpsertDraft. + if lockErr := s.lockLiveSpace(tx, spaceID); lockErr != nil { + return false, lockErr + } + + // Lock the draft row and read its parent for reparenting. + var draft struct { + ParentId string + } + lockQ := s.getQueryBuilder(). + Select("ParentId"). + From("DOCS_Draft"). + Where(sq.Eq{"UserId": userID, "SpaceId": spaceID, "PageId": pageID}). + Suffix("FOR UPDATE") + switch lockErr := s.getBuilder(tx, &draft, lockQ); { + case lockErr == nil: + case errors.Is(lockErr, sql.ErrNoRows): + return false, &ErrNotFound{EntityName: "Draft", ID: pageID} + default: + return false, errors.Wrap(lockErr, "failed to lock draft for delete") + } + + // Reparent child drafts only when discarding a new-page draft. For edit drafts (page is live), + // children's ParentId still points at a valid live page and must not be changed. + pageIsLive, liveErr := s.pageExistsInSpace(tx, pageID, spaceID) + if liveErr != nil { + return false, errors.Wrap(liveErr, "failed to check page liveness for reparenting") + } + if !pageIsLive { + now := mmmodel.GetMillis() + reparentQ := s.getQueryBuilder(). + Update("DOCS_Draft"). + Set("ParentId", draft.ParentId). + Set("UpdateAt", monotonicBump("UpdateAt", now)). + Where(sq.Eq{"UserId": userID, "SpaceId": spaceID, "ParentId": pageID}) + if _, rErr := s.execBuilder(tx, reparentQ); rErr != nil { + return false, errors.Wrap(rErr, "failed to reparent child drafts") + } + } + + deleteQ := s.getQueryBuilder(). + Delete("DOCS_Draft"). + Where(sq.Eq{"UserId": userID, "SpaceId": spaceID, "PageId": pageID}) + result, dErr := s.execBuilder(tx, deleteQ) + if dErr != nil { + return false, errors.Wrap(dErr, "unable_to_delete_draft") + } + if err = checkRowsAffected(result, "Draft", pageID); err != nil { + return false, err + } + + if err = tx.Commit(); err != nil { + return false, errors.Wrap(err, "commit_transaction") + } + return pageIsLive, nil +} + // GetDraftsForSpace returns the user's drafts in the given space, most-recently-updated first, -// with Body omitted (metadata only — see draftMetaColumns). Results pass applyDraftLivenessFilter, +// with Body and Props omitted (metadata only — see draftMetaColumns). Results pass applyDraftLivenessFilter, // so a soft-deleted space lists no drafts (they survive the soft-delete and reappear after -// RestoreSpace) and a draft whose page is soft-deleted is excluded from results. The result is -// capped at MaxRowsPerQuery; ErrLimitExceeded is returned rather than truncating when more rows match. -func (s *Store) GetDraftsForSpace(userID, spaceID string) ([]*model.Draft, error) { +// RestoreSpace) and a draft whose page is soft-deleted is excluded from results. Results are +// paginated via offset/limit (see applyLimitOffset); callers must pass a positive limit. +func (s *Store) GetDraftsForSpace(userID, spaceID string, offset, limit int) ([]*model.DraftSummary, error) { if userID == "" { return nil, &ErrInvalidInput{Entity: "Draft", Field: "userId", Value: userID} } if spaceID == "" { return nil, &ErrInvalidInput{Entity: "Draft", Field: "spaceId", Value: spaceID} } + if err := requirePositiveLimit("Draft", limit); err != nil { + return nil, err + } builder := applyDraftLivenessFilter( s.getQueryBuilder(). @@ -208,16 +664,54 @@ func (s *Store) GetDraftsForSpace(userID, spaceID string) ([]*model.Draft, error From("DOCS_Draft d"), ). Where(sq.Eq{"d.UserId": userID, "d.SpaceId": spaceID}). - OrderBy("d.UpdateAt DESC"). - Limit(uint64(MaxRowsPerQuery + 1)) + OrderBy("d.UpdateAt DESC, d.PageId") + builder = applyLimitOffset(builder, offset, limit) - drafts := []*model.Draft{} + drafts := make([]*model.DraftSummary, 0, limit) if err := s.selectBuilder(s.db, &drafts, builder); err != nil { return nil, errors.Wrap(err, "unable_to_get_drafts_for_space") } - if len(drafts) > MaxRowsPerQuery { - return nil, &ErrLimitExceeded{Resource: "Drafts for user_id=" + userID + " space_id=" + spaceID, Limit: MaxRowsPerQuery} - } return drafts, nil } + +// GetPageActiveEditors returns the user IDs who last saved a draft on the page in spaceID at or +// after minActiveAt — users recently editing it. Presence is derived from the shared DOCS_Draft +// table so it is consistent across all cluster nodes. +// +// The result is scoped to spaceID: a draft is keyed by (UserId, PageId) without a space, and an +// unpublished new-page draft has no page row to bound it, so two users in different spaces holding +// a draft at the same reserved page id would otherwise appear in each other's presence set. Scoping +// on the draft's own SpaceId keeps a broadcast to one space from disclosing the other space's editor. +// +// The filter is LastActiveAt, not UpdateAt (see model.Draft): the maintenance writes that bump +// UpdateAt without the user touching the draft — a page delete reparenting its pending child +// drafts, a move-to-space re-homing them — would otherwise report the draft's owner as editing a +// page they never opened. +func (s *Store) GetPageActiveEditors(pageID, spaceID string, minActiveAt int64) ([]string, error) { + if pageID == "" { + return nil, &ErrInvalidInput{Entity: "Draft", Field: "pageId", Value: pageID} + } + if spaceID == "" { + return nil, &ErrInvalidInput{Entity: "Draft", Field: "spaceId", Value: spaceID} + } + + // Initialize non-nil: a page with no active editors is the common case, and the caller marshals + // this straight into the active-editors REST response and the page_presence_updated WS payload, + // which must carry [] rather than null. + userIDs := []string{} + builder := applyDraftLivenessFilter( + s.getQueryBuilder(). + Select("d.UserId"). + From("DOCS_Draft d"), + ).Where(sq.Eq{"d.PageId": pageID, "d.SpaceId": spaceID}). + Where(sq.GtOrEq{"d.LastActiveAt": minActiveAt}). + OrderBy("d.LastActiveAt DESC"). + Limit(maxActiveEditorsPerPage) + + if err := s.selectBuilder(s.db, &userIDs, builder); err != nil { + return nil, errors.Wrap(err, "failed to get active editors for page") + } + + return userIDs, nil +} diff --git a/server/store/draft_store_test.go b/server/store/draft_store_test.go new file mode 100644 index 0000000..b8bad3f --- /dev/null +++ b/server/store/draft_store_test.go @@ -0,0 +1,1345 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package store_test + +import ( + "errors" + "strings" + "sync" + "testing" + + sq "github.com/mattermost/squirrel" + "github.com/stretchr/testify/require" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + + "github.com/mattermost/mattermost-plugin-docs/server/model" + "github.com/mattermost/mattermost-plugin-docs/server/store" +) + +// --- Draft tests --- + +func newDraft(userID, spaceID, pageID, parentID string) *model.Draft { + return &model.Draft{ + UserId: userID, + SpaceId: spaceID, + PageId: pageID, + ParentId: parentID, + Title: "Test Draft", + Body: `{"type":"doc","content":[]}`, + } +} + +func TestDraft(t *testing.T) { + t.Run("upsert then get returns the stored draft", func(t *testing.T) { + s := openTestDB(t) + userID, pageID := mmmodel.NewId(), mmmodel.NewId() + space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, spaceErr) + spaceID := space.Id + + saved, _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil, nil) + require.NoError(t, err) + require.NotZero(t, saved.CreateAt) + + got, err := s.GetDraft(userID, pageID) + require.NoError(t, err) + require.Equal(t, pageID, got.PageId) + require.Equal(t, "Test Draft", got.Title) + require.Equal(t, spaceID, got.SpaceId) + }) + + t.Run("upsert replaces existing row and preserves CreateAt", func(t *testing.T) { + s := openTestDB(t) + userID, pageID := mmmodel.NewId(), mmmodel.NewId() + space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, spaceErr) + spaceID := space.Id + + first, _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil, nil) + require.NoError(t, err) + + second := newDraft(userID, spaceID, pageID, "") + second.CreateAt = first.CreateAt + second.Title = "Updated" + _, _, err = s.UpsertDraft(second, nil, nil, nil) + require.NoError(t, err) + + got, err := s.GetDraft(userID, pageID) + require.NoError(t, err) + require.Equal(t, "Updated", got.Title) + require.Equal(t, first.CreateAt, got.CreateAt, "CreateAt preserved across upsert") + }) + + t.Run("an autosave that omits a field keeps the stored value", func(t *testing.T) { + s := openTestDB(t) + userID, pageID := mmmodel.NewId(), mmmodel.NewId() + space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, spaceErr) + + full := newDraft(userID, space.Id, pageID, "") + full.Title = "Original title" + full.Body = `{"type":"doc","content":[{"type":"paragraph"}]}` + full.BaseEditAt = 1234 + stored, _, err := s.UpsertDraft(full, nil, nil, nil) + require.NoError(t, err) + + // A body-only heartbeat: no title, no props. Neither may be wiped. + bodyOnly := newDraft(userID, space.Id, pageID, "") + bodyOnly.Title = "" + bodyOnly.Body = `{"type":"doc","content":[{"type":"paragraph"},{"type":"paragraph"}]}` + bodyOnly.Props = nil + saved, _, err := s.UpsertDraft(bodyOnly, nil, nil, nil) + require.NoError(t, err) + require.Equal(t, "Original title", saved.Title, "an omitted title must not wipe the stored one") + require.Equal(t, bodyOnly.Body, saved.Body, "the sent body must be written") + require.Equal(t, int64(1234), saved.BaseEditAt, + "an omitted baseline must not drop the stored optimistic-lock baseline") + require.Equal(t, stored.CreateAt, saved.CreateAt, "CreateAt preserved across upsert") + + // A title-only heartbeat: no body. The body just written must survive. + titleOnly := newDraft(userID, space.Id, pageID, "") + titleOnly.Title = "Renamed" + titleOnly.Body = "" + saved, _, err = s.UpsertDraft(titleOnly, nil, nil, nil) + require.NoError(t, err) + require.Equal(t, "Renamed", saved.Title) + require.Equal(t, bodyOnly.Body, saved.Body, "an omitted body must not wipe the stored one") + }) + + t.Run("two users can draft the same page id", func(t *testing.T) { + s := openTestDB(t) + pageID := mmmodel.NewId() + space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, spaceErr) + spaceID := space.Id + userA, userB := mmmodel.NewId(), mmmodel.NewId() + + _, _, err := s.UpsertDraft(newDraft(userA, spaceID, pageID, ""), nil, nil, nil) + require.NoError(t, err) + _, _, err = s.UpsertDraft(newDraft(userB, spaceID, pageID, ""), nil, nil, nil) + require.NoError(t, err) + + gotA, err := s.GetDraft(userA, pageID) + require.NoError(t, err) + require.Equal(t, userA, gotA.UserId) + gotB, err := s.GetDraft(userB, pageID) + require.NoError(t, err) + require.Equal(t, userB, gotB.UserId) + }) + + t.Run("delete makes draft not found", func(t *testing.T) { + s := openTestDB(t) + userID, pageID := mmmodel.NewId(), mmmodel.NewId() + space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, spaceErr) + spaceID := space.Id + + _, _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil, nil) + require.NoError(t, err) + + require.NoError(t, s.DeleteDraft(userID, pageID)) + + _, err = s.GetDraft(userID, pageID) + require.True(t, store.IsErrNotFound(err)) + }) + + t.Run("delete nonexistent draft returns not-found", func(t *testing.T) { + s := openTestDB(t) + err := s.DeleteDraft(mmmodel.NewId(), mmmodel.NewId()) + require.True(t, store.IsErrNotFound(err)) + }) + + t.Run("get nonexistent draft returns not-found", func(t *testing.T) { + s := openTestDB(t) + _, err := s.GetDraft(mmmodel.NewId(), mmmodel.NewId()) + require.True(t, store.IsErrNotFound(err)) + }) + + t.Run("drafts for space lists new-page drafts most-recent-first", func(t *testing.T) { + s := openTestDB(t) + userID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil, nil) + require.NoError(t, err) + second, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil, nil) + require.NoError(t, err) + + drafts, err := s.GetDraftsForSpace(userID, space.Id, 0, testDraftListLimit) + require.NoError(t, err) + require.Len(t, drafts, 2) + require.Equal(t, second.PageId, drafts[0].PageId, "most-recently-updated first") + }) + + t.Run("drafts for soft-deleted space are not listed but survive for restore", func(t *testing.T) { + s := openTestDB(t) + userID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + draft, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil, nil) + require.NoError(t, err) + + require.NoError(t, s.DeleteSpace(space.Id)) + + // While the space is soft-deleted both reads are gated to nothing... + drafts, err := s.GetDraftsForSpace(userID, space.Id, 0, testDraftListLimit) + require.NoError(t, err) + require.Empty(t, drafts, "a soft-deleted space lists no drafts") + + _, err = s.GetDraft(userID, draft.PageId) + require.True(t, store.IsErrNotFound(err), "a soft-deleted space gates GetDraft too") + + // ...but the draft row is kept (not purged), so it reappears once the space is restored. + require.NoError(t, s.RestoreSpace(space.Id)) + drafts, err = s.GetDraftsForSpace(userID, space.Id, 0, testDraftListLimit) + require.NoError(t, err) + require.Len(t, drafts, 1, "restoring the space brings its drafts back") + + kept, err := s.GetDraft(userID, draft.PageId) + require.NoError(t, err, "draft is readable again after restore") + require.Equal(t, draft.PageId, kept.PageId) + }) + + t.Run("drafts for space excludes a draft whose page lives in another space", func(t *testing.T) { + s := openTestDB(t) + userID := mmmodel.NewId() + + spaceA, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + spaceB, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + // A live page in space B. UpsertDraft refuses to attach a space-A draft to it (see the + // write-path test below), so insert the cross-space row directly to exercise the + // read-path guard against a corrupt or legacy row. + pageInB, err := s.CreatePage(newPage(spaceB.Id, spaceB.ChannelId, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + now := mmmodel.GetMillis() + _, rawErr := s.RawExecForTest( + "INSERT INTO DOCS_Draft (UserId, SpaceId, PageId, ParentId, Title, Body, FileIds, Props, CreateAt, UpdateAt) VALUES ($1, $2, $3, '', '', '', '[]', '{}', $4, $4)", + userID, spaceA.Id, pageInB.Id, now) + require.NoError(t, rawErr) + + drafts, err := s.GetDraftsForSpace(userID, spaceA.Id, 0, testDraftListLimit) + require.NoError(t, err) + require.Empty(t, drafts, "a draft whose page belongs to another space must not be listed") + }) + + t.Run("upsert rejects a draft whose page lives in another space", func(t *testing.T) { + s := openTestDB(t) + userID := mmmodel.NewId() + + spaceA, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + spaceB, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + pageInB, err := s.CreatePage(newPage(spaceB.Id, spaceB.ChannelId, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + _, _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, pageInB.Id, ""), nil, nil, nil) + require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a cross-space page, got %v", err) + }) + + t.Run("drafts for space excludes drafts on soft-deleted pages", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + userID := mmmodel.NewId() + + // A draft editing a live page is included. + live, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + dLive := newDraft(userID, space.Id, live.Id, "") + dLive.BaseEditAt = live.EditAt + _, _, err = s.UpsertDraft(dLive, nil, nil, nil) + require.NoError(t, err) + + // A draft whose page is soft-deleted is excluded. + deleted, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + dDeleted := newDraft(userID, space.Id, deleted.Id, "") + dDeleted.BaseEditAt = deleted.EditAt + _, _, err = s.UpsertDraft(dDeleted, nil, nil, nil) + require.NoError(t, err) + require.NoError(t, deletePageErr(s, deleted.Id, deleted.SpaceId, userID)) + + drafts, err := s.GetDraftsForSpace(userID, space.Id, 0, testDraftListLimit) + require.NoError(t, err) + require.Len(t, drafts, 1) + require.Equal(t, live.Id, drafts[0].PageId) + }) + + t.Run("drafts for space excludes drafts on version snapshots", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + userID := mmmodel.NewId() + + // Draft a live page, then turn that page into a version snapshot (OriginalId set, + // soft-deleted) directly. UpsertDraft refuses to attach to a snapshot, so the draft is + // written while the page is still live; the read path must then exclude it: the LEFT + // JOIN matches the snapshot row, OriginalId != '' fails the live-page predicate, and + // p.Id IS NULL is false. + snap, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + dSnap := newDraft(userID, space.Id, snap.Id, "") + dSnap.BaseEditAt = snap.EditAt + _, _, err = s.UpsertDraft(dSnap, nil, nil, nil) + require.NoError(t, err) + _, rawErr := s.ExecBuilderForTest(s.QueryBuilderForTest(). + Update("DOCS_Page"). + Set("OriginalId", mmmodel.NewId()). + Set("DeleteAt", mmmodel.GetMillis()). + Where(sq.Eq{"Id": snap.Id})) + require.NoError(t, rawErr) + + drafts, err := s.GetDraftsForSpace(userID, space.Id, 0, testDraftListLimit) + require.NoError(t, err) + require.Empty(t, drafts, "a draft on a version snapshot must be excluded") + }) + + t.Run("upsert rejects a draft for a soft-deleted page", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + userID := mmmodel.NewId() + + page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + require.NoError(t, deletePageErr(s, page.Id, page.SpaceId, userID)) + + // An autosave landing after the page was deleted must not recreate a draft for it. + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, page.Id, ""), nil, nil, nil) + require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a deleted page, got %v", err) + }) + + t.Run("upsert rejects a draft in a soft-deleted space", func(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + require.NoError(t, s.DeleteSpace(space.Id)) + + _, _, err = s.UpsertDraft(newDraft(mmmodel.NewId(), space.Id, mmmodel.NewId(), ""), nil, nil, nil) + require.True(t, store.IsErrNotFound(err), "expected not-found for a deleted space, got %v", err) + }) + + t.Run("upsert accepts a new-page draft under a live parent", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + userID := mmmodel.NewId() + + parent, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + parentID := parent.Id + saved, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentID), &parentID, nil, nil) + require.NoError(t, err) + require.Equal(t, parent.Id, saved.ParentId) + }) + + t.Run("upsert rejects a draft whose parent does not exist", func(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + missingParentID := mmmodel.NewId() + _, _, err = s.UpsertDraft(newDraft(mmmodel.NewId(), space.Id, mmmodel.NewId(), missingParentID), &missingParentID, nil, nil) + require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a missing parent, got %v", err) + }) + + t.Run("upsert rejects a draft whose parent is soft-deleted", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + userID := mmmodel.NewId() + + parent, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + require.NoError(t, deletePageErr(s, parent.Id, parent.SpaceId, userID)) + + parentID := parent.Id + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentID), &parentID, nil, nil) + require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a deleted parent, got %v", err) + }) + + t.Run("upsert rejects a draft whose parent lives in another space", func(t *testing.T) { + s := openTestDB(t) + userID := mmmodel.NewId() + + spaceA, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + spaceB, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + parentInB, err := s.CreatePage(newPage(spaceB.Id, spaceB.ChannelId, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + parentID := parentInB.Id + _, _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), parentID), &parentID, nil, nil) + require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a cross-space parent, got %v", err) + }) + + t.Run("upsert accepts a parent that is the user's own draft in the same space", func(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userID := mmmodel.NewId() + + parentDraft, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil, nil) + require.NoError(t, err) + + parentPageID := parentDraft.PageId + saved, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentPageID), &parentPageID, nil, nil) + require.NoError(t, err) + require.Equal(t, parentDraft.PageId, saved.ParentId) + }) + + t.Run("upsert rejects a parent that is another user's draft", func(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userA, userB := mmmodel.NewId(), mmmodel.NewId() + + otherDraft, _, err := s.UpsertDraft(newDraft(userA, space.Id, mmmodel.NewId(), ""), nil, nil, nil) + require.NoError(t, err) + + otherPageID := otherDraft.PageId + _, _, err = s.UpsertDraft(newDraft(userB, space.Id, mmmodel.NewId(), otherPageID), &otherPageID, nil, nil) + require.True(t, store.IsErrInvalidInput(err), "expected invalid input for another user's draft parent, got %v", err) + }) + + // TestDraft/"upsert rejects a draft whose parent chain cycles back to itself" exercises + // checkNoDraftCycle's cycle branch: a root new-page draft, a second draft parented under it, + // then re-parenting the root under the second draft closes the loop root -> child -> root. + t.Run("upsert rejects a draft whose parent chain cycles back to itself", func(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userID := mmmodel.NewId() + + rootDraft, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil, nil) + require.NoError(t, err) + + rootPageID := rootDraft.PageId + childDraft, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), rootPageID), &rootPageID, nil, nil) + require.NoError(t, err) + + childPageID := childDraft.PageId + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, rootDraft.PageId, childPageID), &childPageID, nil, nil) + require.Error(t, err) + var inv *store.ErrInvalidInput + require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) + require.Equal(t, store.ReasonDraftCycle, inv.Reason) + }) + + // TestDraft/"upsert rejects a draft whose parent chain exceeds the max depth" exercises + // checkNoDraftCycle's too-deep branch. Each draft added to the chain is itself parent-chain + // validated, so a chain of exactly model.MaxPageDepth new-page drafts is the deepest one + // that can be built without tripping the cap; a further draft parented under the deepest one + // is rejected as too deep. + t.Run("upsert rejects a draft whose parent chain exceeds the max depth", func(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userID := mmmodel.NewId() + + parentID := "" + for range model.MaxPageDepth { + pageID := mmmodel.NewId() + var parentParam *string + if parentID != "" { + p := parentID + parentParam = &p + } + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, pageID, parentID), parentParam, nil, nil) + require.NoError(t, err) + parentID = pageID + } + + deepestParentID := parentID + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), deepestParentID), &deepestParentID, nil, nil) + require.Error(t, err) + var inv *store.ErrInvalidInput + require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) + require.Equal(t, store.ReasonDraftTooDeep, inv.Reason) + }) + + t.Run("drafts for space is scoped to the user", func(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userA, userB := mmmodel.NewId(), mmmodel.NewId() + + _, _, err = s.UpsertDraft(newDraft(userA, space.Id, mmmodel.NewId(), ""), nil, nil, nil) + require.NoError(t, err) + _, _, err = s.UpsertDraft(newDraft(userB, space.Id, mmmodel.NewId(), ""), nil, nil, nil) + require.NoError(t, err) + + drafts, err := s.GetDraftsForSpace(userA, space.Id, 0, testDraftListLimit) + require.NoError(t, err) + require.Len(t, drafts, 1) + require.Equal(t, userA, drafts[0].UserId) + }) + + t.Run("body, file_ids and props round-trip through the database", func(t *testing.T) { + s := openTestDB(t) + userID, pageID := mmmodel.NewId(), mmmodel.NewId() + space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, spaceErr) + spaceID := space.Id + + d := newDraft(userID, spaceID, pageID, "") + d.Body = `{"type":"doc","content":[{"type":"paragraph"}]}` + d.FileIds = mmmodel.StringArray{mmmodel.NewId(), mmmodel.NewId()} + d.Props = mmmodel.StringInterface{"k": float64(1700000000123)} + _, _, err := s.UpsertDraft(d, nil, &d.FileIds, &d.Props) + require.NoError(t, err) + + got, err := s.GetDraft(userID, pageID) + require.NoError(t, err) + require.Equal(t, d.Body, got.Body) + require.Equal(t, d.FileIds, got.FileIds, "StringArray must round-trip through the TEXT column") + require.Equal(t, float64(1700000000123), got.Props["k"], "Props must round-trip through the jsonb column") + }) + + t.Run("empty props default to an empty map on read", func(t *testing.T) { + s := openTestDB(t) + userID, pageID := mmmodel.NewId(), mmmodel.NewId() + space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, spaceErr) + spaceID := space.Id + + _, _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil, nil) + require.NoError(t, err) + + got, err := s.GetDraft(userID, pageID) + require.NoError(t, err) + require.NotNil(t, got.Props) + require.Empty(t, got.Props) + }) + + t.Run("upsert overwrites parent id", func(t *testing.T) { + s := openTestDB(t) + userID, pageID := mmmodel.NewId(), mmmodel.NewId() + space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, spaceErr) + spaceID := space.Id + + // Parents must be live pages in the space (UpsertDraft validates ParentId liveness). + firstPage, err := s.CreatePage(newPage(spaceID, space.ChannelId, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + secondPage, err := s.CreatePage(newPage(spaceID, space.ChannelId, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + firstParent, secondParent := firstPage.Id, secondPage.Id + + _, _, err = s.UpsertDraft(newDraft(userID, spaceID, pageID, firstParent), &firstParent, nil, nil) + require.NoError(t, err) + got, err := s.GetDraft(userID, pageID) + require.NoError(t, err) + require.Equal(t, firstParent, got.ParentId) + + _, _, err = s.UpsertDraft(newDraft(userID, spaceID, pageID, secondParent), &secondParent, nil, nil) + require.NoError(t, err) + got, err = s.GetDraft(userID, pageID) + require.NoError(t, err) + require.Equal(t, secondParent, got.ParentId, "second upsert must overwrite ParentId") + }) + + t.Run("title-only empty body round-trips", func(t *testing.T) { + s := openTestDB(t) + userID, pageID := mmmodel.NewId(), mmmodel.NewId() + space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, spaceErr) + spaceID := space.Id + + d := newDraft(userID, spaceID, pageID, "") + d.Title = "Title Only" + d.Body = "" + _, _, err := s.UpsertDraft(d, nil, nil, nil) + require.NoError(t, err) + + got, err := s.GetDraft(userID, pageID) + require.NoError(t, err) + require.Equal(t, "Title Only", got.Title) + require.Equal(t, "", got.Body, "empty body must round-trip as empty string") + }) + + t.Run("drafts for space excludes other spaces for the same user", func(t *testing.T) { + s := openTestDB(t) + userID := mmmodel.NewId() + spaceA, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + spaceB, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + _, _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), ""), nil, nil, nil) + require.NoError(t, err) + _, _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), ""), nil, nil, nil) + require.NoError(t, err) + _, _, err = s.UpsertDraft(newDraft(userID, spaceB.Id, mmmodel.NewId(), ""), nil, nil, nil) + require.NoError(t, err) + + drafts, err := s.GetDraftsForSpace(userID, spaceA.Id, 0, testDraftListLimit) + require.NoError(t, err) + require.Len(t, drafts, 2) + for _, d := range drafts { + require.Equal(t, spaceA.Id, d.SpaceId) + } + }) + + t.Run("drafts for space returns empty when user has none", func(t *testing.T) { + s := openTestDB(t) + drafts, err := s.GetDraftsForSpace(mmmodel.NewId(), mmmodel.NewId(), 0, testDraftListLimit) + require.NoError(t, err) + require.Empty(t, drafts) + }) + + t.Run("store rejects invalid ids", func(t *testing.T) { + s := openTestDB(t) + valid := mmmodel.NewId() + + // Upsert runs the full model IsValid, so a malformed (non-empty) id is rejected as + // invalid input. + _, _, err := s.UpsertDraft(newDraft("bad", valid, valid, ""), nil, nil, nil) + require.True(t, store.IsErrInvalidInput(err), "upsert with bad user id, got %v", err) + + // Upsert with nil draft must return ErrInvalidInput. + _, _, err = s.UpsertDraft(nil, nil, nil, nil) + require.True(t, store.IsErrInvalidInput(err), "upsert nil draft, got %v", err) + + // Get/Delete guard only against empty ids (matching the page/space store convention); + // a non-empty but unknown id falls through to the query and returns not-found. + _, err = s.GetDraft("", valid) + require.True(t, store.IsErrInvalidInput(err), "get with empty user id, got %v", err) + + _, err = s.GetDraft(valid, "") + require.True(t, store.IsErrInvalidInput(err), "get with empty page id, got %v", err) + + err = s.DeleteDraft("", valid) + require.True(t, store.IsErrInvalidInput(err), "delete with empty user id, got %v", err) + + err = s.DeleteDraft(valid, "") + require.True(t, store.IsErrInvalidInput(err), "delete with empty page id, got %v", err) + }) + + t.Run("GetDraftsForSpace rejects empty userID", func(t *testing.T) { + s := openTestDB(t) + _, err := s.GetDraftsForSpace("", mmmodel.NewId(), 0, testDraftListLimit) + require.True(t, store.IsErrInvalidInput(err), "got %v", err) + }) + + t.Run("GetDraftsForSpace rejects empty spaceID", func(t *testing.T) { + s := openTestDB(t) + _, err := s.GetDraftsForSpace(mmmodel.NewId(), "", 0, testDraftListLimit) + require.True(t, store.IsErrInvalidInput(err), "got %v", err) + }) + + t.Run("GetDraftsForSpace rejects non-positive limit", func(t *testing.T) { + s := openTestDB(t) + _, err := s.GetDraftsForSpace(mmmodel.NewId(), mmmodel.NewId(), 0, 0) + require.True(t, store.IsErrInvalidInput(err), "zero limit must be rejected, got %v", err) + }) +} + +// TestDeletePageReparentsPendingDrafts verifies that deleting a page reparents the new-page +// drafts pending under it to the deleted page's parent — mirroring live-child promotion — so a +// draft never dangles under a soft-deleted parent and stays publishable. +func TestDeletePageReparentsPendingDrafts(t *testing.T) { + s := openTestDB(t) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + + grandparent, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + parent, err := s.CreatePage(newPage(space.Id, channelID, userID, grandparent.Id), testDefaultMaxDepth) + require.NoError(t, err) + + // A new-page draft (its own page not yet created) pending as a child of parent. + newPageID := mmmodel.NewId() + parentID := parent.Id + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, newPageID, parentID), &parentID, nil, nil) + require.NoError(t, err) + + require.NoError(t, deletePageErr(s, parent.Id, parent.SpaceId, userID)) + + // The draft survives and is reparented to the deleted page's parent (the grandparent), + // which the invariant guarantees is live. + got, err := s.GetDraft(userID, newPageID) + require.NoError(t, err, "pending draft must survive its parent's deletion") + require.Equal(t, grandparent.Id, got.ParentId, "draft must be reparented to the deleted page's parent") + + // The reparented draft is publishable: CreatePage with its parent now succeeds. + _, err = s.CreatePage(newPage(space.Id, channelID, userID, got.ParentId), testDefaultMaxDepth) + require.NoError(t, err, "draft's reparented parent must be a valid live parent") +} + +// TestGetActiveEditorsForPage covers the presence window predicate: a draft updated at/after the +// cutoff counts its user as active; one before the cutoff, or on another page, does not. +func TestGetActiveEditorsForPage(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + pageID := mmmodel.NewId() + userID := mmmodel.NewId() + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil, nil) + require.NoError(t, err) + now := mmmodel.GetMillis() + + t.Run("within window includes the editor", func(t *testing.T) { + editors, err := s.GetPageActiveEditors(pageID, space.Id, now-5*60*1000) + require.NoError(t, err) + require.Contains(t, editors, userID) + }) + + t.Run("cutoff after the update excludes the editor", func(t *testing.T) { + editors, err := s.GetPageActiveEditors(pageID, space.Id, now+60*1000) + require.NoError(t, err) + require.NotContains(t, editors, userID) + }) + + t.Run("cutoff exactly at LastActiveAt includes the editor", func(t *testing.T) { + // The window is inclusive (LastActiveAt >= cutoff): a draft updated exactly at the cutoff + // still counts, pinning the >= predicate against an accidental strict >. + d, err := s.GetDraft(userID, pageID) + require.NoError(t, err) + editors, err := s.GetPageActiveEditors(pageID, space.Id, d.LastActiveAt) + require.NoError(t, err) + require.Contains(t, editors, userID) + }) + + t.Run("a different page has no editors", func(t *testing.T) { + editors, err := s.GetPageActiveEditors(mmmodel.NewId(), space.Id, 0) + require.NoError(t, err) + require.Empty(t, editors) + }) + + t.Run("a new-page draft at the same reserved id in another space does not leak", func(t *testing.T) { + otherSpace, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + otherUser := mmmodel.NewId() + // Same (reserved) pageID, different space and user — an unpublished new-page draft. + _, _, err = s.UpsertDraft(newDraft(otherUser, otherSpace.Id, pageID, ""), nil, nil, nil) + require.NoError(t, err) + + editors, err := s.GetPageActiveEditors(pageID, space.Id, mmmodel.GetMillis()-5*60*1000) + require.NoError(t, err) + require.Contains(t, editors, userID) + require.NotContains(t, editors, otherUser, + "presence for a page must not disclose an editor from another space sharing the reserved id") + }) +} + +func TestGetActiveEditorsForPageInputValidation(t *testing.T) { + s := openTestDB(t) + valid := mmmodel.NewId() + + _, err := s.GetPageActiveEditors("", valid, 0) + require.True(t, store.IsErrInvalidInput(err), "empty pageID, got %v", err) + + _, err = s.GetPageActiveEditors(valid, "", 0) + require.True(t, store.IsErrInvalidInput(err), "empty spaceID, got %v", err) +} + +func TestGetActiveEditorsForPageMultipleEditorsOrderedByLastActiveAt(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + pageID := mmmodel.NewId() + userA, userB := mmmodel.NewId(), mmmodel.NewId() + + _, _, err = s.UpsertDraft(newDraft(userA, space.Id, pageID, ""), nil, nil, nil) + require.NoError(t, err) + _, _, err = s.UpsertDraft(newDraft(userB, space.Id, pageID, ""), nil, nil, nil) + require.NoError(t, err) + + // Push userA's LastActiveAt into the past so userB (more recent) should appear first. + past := mmmodel.GetMillis() - 60*1000 + _, err = s.ExecBuilderForTest(s.QueryBuilderForTest(). + Update("DOCS_Draft"). + Set("LastActiveAt", past). + Where(sq.Eq{"UserId": userA, "PageId": pageID})) + require.NoError(t, err) + + editors, err := s.GetPageActiveEditors(pageID, space.Id, 0) + require.NoError(t, err) + require.Len(t, editors, 2) + require.Equal(t, userB, editors[0], "most-recently-active editor must appear first") + require.Equal(t, userA, editors[1]) +} + +// TestGetActiveEditorsForPageIgnoresMaintenanceWrites pins presence to LastActiveAt rather than +// UpdateAt. Deleting a page reparents the drafts pending under it, which stamps their UpdateAt +// without their owner having touched them — that must not report the owner as an active editor. +func TestGetActiveEditorsForPageIgnoresMaintenanceWrites(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + + userID := mmmodel.NewId() + parent, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + // A new-page draft pending under the parent, last actually edited well outside the window. + childPageID := mmmodel.NewId() + parentID := parent.Id + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, childPageID, parentID), &parentID, nil, nil) + require.NoError(t, err) + + stale := mmmodel.GetMillis() - 60*60*1000 + _, err = s.ExecBuilderForTest(s.QueryBuilderForTest(). + Update("DOCS_Draft"). + Set("UpdateAt", stale). + Set("LastActiveAt", stale). + Where(sq.Eq{"UserId": userID, "PageId": childPageID})) + require.NoError(t, err) + + // Someone else deletes the parent, which reparents the pending draft and bumps its UpdateAt. + _, err = s.DeletePage(parent.Id, space.Id, mmmodel.NewId()) + require.NoError(t, err) + + cutoff := mmmodel.GetMillis() - 5*60*1000 + editors, err := s.GetPageActiveEditors(childPageID, space.Id, cutoff) + require.NoError(t, err) + require.NotContains(t, editors, userID, + "reparenting a draft must not report its owner as an active editor") +} + +// TestUpsertDraftBumpsUpdateAtMonotonically guards the draft's UpdateAt version token: it must +// advance strictly past the stored value even when the saving node's wall clock is behind it, so a +// later autosave can never commit an UpdateAt that collides with the value a publish already +// captured (which would let the publish CAS delete the newer draft and ship older content). +func TestUpsertDraftBumpsUpdateAtMonotonically(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userID := mmmodel.NewId() + pageID := mmmodel.NewId() + + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil, nil) + require.NoError(t, err) + + // Force the stored UpdateAt ahead of the next save's wall clock. Without the monotonic bump, + // the next upsert would write a smaller UpdateAt (its own GetMillis()). + future := mmmodel.GetMillis() + 60*60*1000 + _, err = s.ExecBuilderForTest(s.QueryBuilderForTest(). + Update("DOCS_Draft"). + Set("UpdateAt", future). + Where(sq.Eq{"UserId": userID, "PageId": pageID})) + require.NoError(t, err) + + saved, _, err := s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil, nil) + require.NoError(t, err) + require.Equal(t, future+1, saved.UpdateAt, + "UpdateAt must advance to stored+1 when the incoming timestamp is not already greater") +} + +func TestDeleteDraftVersion(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userID := mmmodel.NewId() + pageID := mmmodel.NewId() + + saved, _, err := s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil, nil) + require.NoError(t, err) + + t.Run("stale version deletes nothing and leaves the draft intact", func(t *testing.T) { + deleted, delErr := s.DeleteDraftVersion(userID, pageID, saved.UpdateAt-1) + require.NoError(t, delErr) + require.False(t, deleted, "a mismatched version must not delete the row") + got, getErr := s.GetDraft(userID, pageID) + require.NoError(t, getErr, "the draft must survive a stale-version delete") + require.Equal(t, saved.UpdateAt, got.UpdateAt) + }) + + t.Run("matching version deletes the draft", func(t *testing.T) { + deleted, delErr := s.DeleteDraftVersion(userID, pageID, saved.UpdateAt) + require.NoError(t, delErr) + require.True(t, deleted, "the matching version must delete the row") + _, getErr := s.GetDraft(userID, pageID) + require.True(t, store.IsErrNotFound(getErr), "the draft must be gone") + }) + + t.Run("missing draft reports false without error", func(t *testing.T) { + deleted, delErr := s.DeleteDraftVersion(userID, mmmodel.NewId(), 1) + require.NoError(t, delErr) + require.False(t, deleted) + }) +} + +// TestPublishDraft covers the atomic publish transactions at the store boundary: the new-page +// insert-and-delete-draft path (PublishNewPageDraft), and the edit path's optimistic-lock CAS +// (PublishPageEditDraft). +func TestPublishDraft(t *testing.T) { + t.Run("new page inserts the page and deletes the draft", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + userID := mmmodel.NewId() + pageID := mmmodel.NewId() + + draft, _, err := s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil, nil) + require.NoError(t, err) + + page := &model.Page{Id: pageID, SpaceId: space.Id, Title: "Published", Body: `{"type":"doc","content":[]}`, UserId: userID} + published, err := s.PublishNewPageDraft(page, userID, space.Id, testDefaultMaxDepth, draft.UpdateAt) + require.NoError(t, err) + require.Equal(t, pageID, published.Id) + + _, getErr := s.GetDraft(userID, pageID) + require.True(t, store.IsErrNotFound(getErr), "draft must be deleted by publish") + + live, err := s.GetPage(pageID, false) + require.NoError(t, err) + require.Equal(t, "Published", live.Title) + }) + + t.Run("edit path conflicts on a stale baseline", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + userID := mmmodel.NewId() + + created, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + d := newDraft(userID, space.Id, created.Id, "") + d.BaseEditAt = created.EditAt + draft, _, err := s.UpsertDraft(d, nil, nil, nil) + require.NoError(t, err) + + title := "Edited" + body := `{"type":"doc","content":[]}` + searchText := "" + patch := &model.PagePatch{Title: &title, Body: &body, SearchText: &searchText} + + _, err = s.PublishPageEditDraft(created.Id, space.Id, patch, created.EditAt-1, false, userID, draft.UpdateAt) // stale baseline + require.True(t, store.IsErrConflict(err), "a stale baseline must conflict, got %v", err) + }) + + t.Run("edit path succeeds with a matching baseline", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + userID := mmmodel.NewId() + + created, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + d2 := newDraft(userID, space.Id, created.Id, "") + d2.BaseEditAt = created.EditAt + draft, _, err := s.UpsertDraft(d2, nil, nil, nil) + require.NoError(t, err) + + title := "Edited" + body := `{"type":"doc","content":[]}` + searchText := "" + patch := &model.PagePatch{Title: &title, Body: &body, SearchText: &searchText} + + published, err := s.PublishPageEditDraft(created.Id, space.Id, patch, created.EditAt, false, userID, draft.UpdateAt) // matching baseline + require.NoError(t, err) + require.Equal(t, "Edited", published.Title) + require.Greater(t, published.EditAt, created.EditAt, "publish advances EditAt") + + _, getErr := s.GetDraft(userID, created.Id) + require.True(t, store.IsErrNotFound(getErr), "draft must be deleted by publish") + }) + + t.Run("an autosave landing after the draft was read rolls the publish back", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + userID := mmmodel.NewId() + + created, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + d3 := newDraft(userID, space.Id, created.Id, "") + d3.BaseEditAt = created.EditAt + stale, _, err := s.UpsertDraft(d3, nil, nil, nil) + require.NoError(t, err) + + // The user's editor autosaves again after the publish path read the draft. + newer := newDraft(userID, space.Id, created.Id, "") + newer.Body = `{"type":"doc","content":[{"type":"paragraph"}]}` + newer.BaseEditAt = created.EditAt + newer, _, err = s.UpsertDraft(newer, nil, nil, nil) + require.NoError(t, err) + require.Greater(t, newer.UpdateAt, stale.UpdateAt, "the autosave must advance UpdateAt") + + title := "Published from stale content" + body := `{"type":"doc","content":[]}` + searchText := "" + patch := &model.PagePatch{Title: &title, Body: &body, SearchText: &searchText} + + _, err = s.PublishPageEditDraft(created.Id, space.Id, patch, created.EditAt, false, userID, stale.UpdateAt) + require.True(t, store.IsErrConflict(err), "publishing stale draft content must conflict, got %v", err) + + // The page must be untouched and the newer draft must survive for the client to republish. + live, err := s.GetPage(created.Id, false) + require.NoError(t, err) + require.NotEqual(t, "Published from stale content", live.Title, "the rolled-back publish must not have written the page") + + survived, err := s.GetDraft(userID, created.Id) + require.NoError(t, err, "the newer draft must survive the rolled-back publish") + require.Equal(t, newer.Body, survived.Body) + }) +} + +// TestUpsertDraftBaseEditAtWriteOnce verifies BaseEditAt is frozen at the establishing INSERT: a +// later upsert on the same (UserId, PageId) key carries a different BaseEditAt, but the stored +// (and returned) value never moves off the value the draft was established with. +func TestUpsertDraftBaseEditAtWriteOnce(t *testing.T) { + s := openTestDB(t) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + established := newDraft(userID, space.Id, page.Id, "") + established.BaseEditAt = page.EditAt + saved, _, err := s.UpsertDraft(established, nil, nil, nil) + require.NoError(t, err) + require.Equal(t, page.EditAt, saved.BaseEditAt) + + later := newDraft(userID, space.Id, page.Id, "") + later.BaseEditAt = page.EditAt + 1000 + updated, _, err := s.UpsertDraft(later, nil, nil, nil) + require.NoError(t, err) + require.Equal(t, page.EditAt, updated.BaseEditAt, + "BaseEditAt is write-once: a later upsert must not change the established baseline") + + // The persisted row (not just the returned struct) must reflect the same frozen value. + persisted, err := s.GetDraft(userID, page.Id) + require.NoError(t, err) + require.Equal(t, page.EditAt, persisted.BaseEditAt) +} + +// TestUpsertDraftPropsReplaceOrKeep verifies the whole-value replace-or-keep semantics of the props +// write-intent pointer: nil preserves the stored map untouched, a non-nil pointer replaces the whole +// map (dropping any key it doesn't carry), and a non-nil pointer to an empty map clears every key. +func TestUpsertDraftPropsReplaceOrKeep(t *testing.T) { + s := openTestDB(t) + + userID, pageID := mmmodel.NewId(), mmmodel.NewId() + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + d := newDraft(userID, space.Id, pageID, "") + d.Props = mmmodel.StringInterface{"foo": "bar"} + stored, _, err := s.UpsertDraft(d, nil, nil, &d.Props) + require.NoError(t, err) + require.Equal(t, "bar", stored.Props["foo"]) + + // A nil props pointer omits the write and preserves the stored map. + omit := newDraft(userID, space.Id, pageID, "") + afterOmit, _, err := s.UpsertDraft(omit, nil, nil, nil) + require.NoError(t, err) + require.Equal(t, "bar", afterOmit.Props["foo"], "a nil props pointer must preserve the stored map") + + // A non-nil props pointer replaces the whole map: the unrelated "foo" key set above is gone, + // not merged with the new "baz" key. + replace := newDraft(userID, space.Id, pageID, "") + replace.Props = mmmodel.StringInterface{"baz": "qux"} + afterReplace, _, err := s.UpsertDraft(replace, nil, nil, &replace.Props) + require.NoError(t, err) + require.Equal(t, "qux", afterReplace.Props["baz"]) + require.NotContains(t, afterReplace.Props, "foo", + "a non-nil props pointer must replace the whole map, not merge keys") + + // A non-nil pointer to an empty map clears every key. + toClear := newDraft(userID, space.Id, pageID, "") + emptyProps := mmmodel.StringInterface{} + cleared, _, err := s.UpsertDraft(toClear, nil, nil, &emptyProps) + require.NoError(t, err) + require.Empty(t, cleared.Props, "a non-nil pointer to an empty map must clear all keys") +} + +// TestUpsertDraftOversizedPropsRejected verifies the store rejects a draft whose Props field +// (the field Draft.IsValid actually checks) exceeds PagePropsMaxBytes, regardless of what the +// props write-intent pointer carries. This is enforced by Draft.IsValid, not by the pointer's +// contents — sizing the pointer's target (rather than draft.Props) is the App layer's job. +func TestUpsertDraftOversizedPropsRejected(t *testing.T) { + s := openTestDB(t) + + userID, pageID := mmmodel.NewId(), mmmodel.NewId() + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + d := newDraft(userID, space.Id, pageID, "") + d.Props = mmmodel.StringInterface{"k": strings.Repeat("x", model.PagePropsMaxBytes)} + _, _, err = s.UpsertDraft(d, nil, nil, &d.Props) + require.Error(t, err) + require.True(t, store.IsErrInvalidInput(err), "oversized draft.Props must be rejected by Draft.IsValid, got %v", err) +} + +// TestUpsertDraftEstablishGuardRejectsBaselineAheadOfPage verifies the establish-time guard: an +// establishing INSERT (no existing draft row) whose BaseEditAt is ahead of the live page's current +// EditAt is impossible (the client cannot have seen a version newer than the one that exists) and +// is rejected as invalid input. A baseline equal to the page's EditAt is accepted; a baseline +// behind it is not caught by this guard but is still rejected by the separate resurrection check +// (see TestUpsertDraftResurrectionClassification). +func TestUpsertDraftEstablishGuardRejectsBaselineAheadOfPage(t *testing.T) { + s := openTestDB(t) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + + t.Run("ahead of the live page is rejected", func(t *testing.T) { + page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + ahead := newDraft(userID, space.Id, page.Id, "") + ahead.BaseEditAt = page.EditAt + 1000 + _, _, err = s.UpsertDraft(ahead, nil, nil, nil) + require.Error(t, err) + var inv *store.ErrInvalidInput + require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) + require.Equal(t, "BaseEditAt", inv.Field) + }) + + t.Run("equal to the live page is accepted", func(t *testing.T) { + page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + equal := newDraft(userID, space.Id, page.Id, "") + equal.BaseEditAt = page.EditAt + _, _, err = s.UpsertDraft(equal, nil, nil, nil) + require.NoError(t, err, "an establishing baseline equal to the live page's EditAt must be accepted") + }) + + // A baseline strictly behind the live page's EditAt passes the ahead-only guard above (it is + // not "ahead"), but is still rejected — by the separate resurrection check just below the + // guard, since this is still a first-ever establish (no existing draft row) and the page + // advanced past the caller's baseline. This is a real optimistic-lock conflict, not a bug: + // the client's session is already stale on its very first save. + t.Run("behind the live page is rejected as a stale baseline, not by the ahead-only guard", func(t *testing.T) { + page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + behind := newDraft(userID, space.Id, page.Id, "") + behind.BaseEditAt = page.EditAt - 1 + _, _, err = s.UpsertDraft(behind, nil, nil, nil) + require.Error(t, err) + require.False(t, store.IsErrInvalidInput(err), "a behind baseline must not trip the ahead-only establish guard") + require.True(t, store.IsErrConflict(err), "expected ErrConflict, got %T: %v", err, err) + require.Equal(t, store.ReasonConcurrentEdit, store.ConflictReason(err)) + }) +} + +// TestUpsertDraftConcurrentFirstAutosavesSerialize verifies that two concurrent establishing +// upserts for the same (userID, pageID) on an existing page do not both take the "no existing +// draft" branch: the per-space FOR UPDATE lock (lockLiveSpace) serializes them, so only the first +// is a true establish and every later one observes the row the first inserted and is treated as an +// update — neither is falsely rejected by the establish-time guard. +func TestUpsertDraftConcurrentFirstAutosavesSerialize(t *testing.T) { + s := openTestDB(t) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + const n = 8 + start := make(chan struct{}) + var wg sync.WaitGroup + errs := make([]error, n) + wg.Add(n) + for i := range n { + go func() { + defer wg.Done() + <-start + d := newDraft(userID, space.Id, page.Id, "") + d.BaseEditAt = page.EditAt + _, _, errs[i] = s.UpsertDraft(d, nil, nil, nil) + }() + } + close(start) + wg.Wait() + + for i, uErr := range errs { + require.NoError(t, uErr, "concurrent first-autosave %d must not be falsely rejected by the establish guard", i) + } + + got, err := s.GetDraft(userID, page.Id) + require.NoError(t, err) + require.Equal(t, page.EditAt, got.BaseEditAt) +} + +// TestUpsertDraftResurrectionClassification verifies UpsertDraft distinguishes the two resurrection +// reasons: an autosave with a stale non-zero BaseEditAt behind the page's current EditAt (the page +// advanced under it) classifies as ReasonConcurrentEdit, while an autosave with no baseline (0) on a +// page id a concurrent publish just claimed classifies as ReasonConcurrentAutosave. Both fire only +// when the draft row a resurrection would recreate no longer exists (a concurrent publish consumed +// it), matching the "refuse to resurrect a consumed draft" contract in UpsertDraft. +func TestUpsertDraftResurrectionClassification(t *testing.T) { + t.Run("stale non-zero baseline behind the page classifies as concurrent edit", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + d := newDraft(userID, space.Id, page.Id, "") + d.BaseEditAt = page.EditAt + _, _, err = s.UpsertDraft(d, nil, nil, nil) + require.NoError(t, err) + + // The page is edited (advancing EditAt past the draft's baseline), and the draft is + // removed — simulating a concurrent publish that consumed it. + newTitle := "Edited concurrently" + edited, err := s.UpdatePage(page.Id, page.SpaceId, &model.PagePatch{Title: &newTitle}, page.EditAt, false, userID) + require.NoError(t, err) + require.Greater(t, edited.EditAt, page.EditAt) + require.NoError(t, s.DeleteDraft(userID, page.Id)) + + // A stale-baseline autosave tries to re-establish the now-consumed draft. + stale := newDraft(userID, space.Id, page.Id, "") + stale.BaseEditAt = page.EditAt + _, _, err = s.UpsertDraft(stale, nil, nil, nil) + require.Error(t, err) + require.True(t, store.IsErrConflict(err), "expected ErrConflict, got %T: %v", err, err) + require.Equal(t, store.ReasonConcurrentEdit, store.ConflictReason(err)) + }) + + t.Run("no baseline on a page a concurrent publish just claimed classifies as concurrent autosave", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + + pageID := mmmodel.NewId() + d := newDraft(userID, space.Id, pageID, "") + _, _, err = s.UpsertDraft(d, nil, nil, nil) + require.NoError(t, err) + + // A concurrent publish creates the page at that exact id and removes the draft. + published := newPage(space.Id, channelID, userID, "") + published.Id = pageID + _, err = s.CreatePage(published, testDefaultMaxDepth) + require.NoError(t, err) + require.NoError(t, s.DeleteDraft(userID, pageID)) + + // A baseline-less autosave tries to re-establish the now-consumed new-page draft. + stale := newDraft(userID, space.Id, pageID, "") + _, _, err = s.UpsertDraft(stale, nil, nil, nil) + require.Error(t, err) + require.True(t, store.IsErrConflict(err), "expected ErrConflict, got %T: %v", err, err) + require.Equal(t, store.ReasonConcurrentAutosave, store.ConflictReason(err)) + }) +} + +// TestUpsertDraftCountsLiveAncestorDepth pins checkNoDraftCycle's live-ancestor arithmetic: the +// live parent chain's depth counts toward model.MaxPageDepth for a new draft, including when the +// caller holds an edit draft on the live ancestor. An edit draft's stored ParentId defaults to ” +// (version bookkeeping, not a hierarchy edge), and a chain walk that followed it would skip the +// ancestor's real depth and admit drafts that can never publish within the cap. +func TestUpsertDraftCountsLiveAncestorDepth(t *testing.T) { + buildLiveChain := func(t *testing.T, s *store.Store, spaceID, channelID, userID string, depth int) *model.Page { + t.Helper() + parentID := "" + var page *model.Page + var err error + for range depth { + page, err = s.CreatePage(newPage(spaceID, channelID, userID, parentID), model.MaxPageDepth) + require.NoError(t, err) + parentID = page.Id + } + return page + } + + establishEditDraft := func(t *testing.T, s *store.Store, spaceID string, page *model.Page, userID string) { + t.Helper() + d := newDraft(userID, spaceID, page.Id, "") + d.BaseEditAt = page.EditAt + _, _, err := s.UpsertDraft(d, nil, nil, nil) + require.NoError(t, err) + } + + requireTooDeep := func(t *testing.T, err error) { + t.Helper() + require.Error(t, err) + var inv *store.ErrInvalidInput + require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) + require.Equal(t, store.ReasonDraftTooDeep, inv.Reason) + } + + t.Run("rejects a draft under a cap-deep live chain despite an edit draft on the ancestor", func(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userID := mmmodel.NewId() + + deepest := buildLiveChain(t, s, space.Id, space.ChannelId, userID, model.MaxPageDepth) + establishEditDraft(t, s, space.Id, deepest, userID) + + parentID := deepest.Id + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentID), &parentID, nil, nil) + requireTooDeep(t, err) + }) + + t.Run("accepts a draft that lands exactly at the cap below a live chain", func(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userID := mmmodel.NewId() + + deepest := buildLiveChain(t, s, space.Id, space.ChannelId, userID, model.MaxPageDepth-1) + establishEditDraft(t, s, space.Id, deepest, userID) + + parentID := deepest.Id + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentID), &parentID, nil, nil) + require.NoError(t, err) + }) + + t.Run("combined live and draft chain depth is capped across the live boundary", func(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userID := mmmodel.NewId() + + deepest := buildLiveChain(t, s, space.Id, space.ChannelId, userID, model.MaxPageDepth-2) + establishEditDraft(t, s, space.Id, deepest, userID) + + // Two new-page drafts chained below the live ancestor land exactly at the cap. + parentID := deepest.Id + d1, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentID), &parentID, nil, nil) + require.NoError(t, err) + d1ID := d1.PageId + d2, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), d1ID), &d1ID, nil, nil) + require.NoError(t, err) + + // One more level would publish past the cap, so it is rejected at draft time. + d2ID := d2.PageId + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), d2ID), &d2ID, nil, nil) + requireTooDeep(t, err) + }) +} diff --git a/server/store/migrations/000005_add_draft_lastactiveat_baseeditat.down.sql b/server/store/migrations/000005_add_draft_lastactiveat_baseeditat.down.sql new file mode 100644 index 0000000..60c74c4 --- /dev/null +++ b/server/store/migrations/000005_add_draft_lastactiveat_baseeditat.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE DOCS_Draft DROP COLUMN IF EXISTS BaseEditAt; +ALTER TABLE DOCS_Draft DROP COLUMN IF EXISTS LastActiveAt; diff --git a/server/store/migrations/000005_add_draft_lastactiveat_baseeditat.up.sql b/server/store/migrations/000005_add_draft_lastactiveat_baseeditat.up.sql new file mode 100644 index 0000000..4890412 --- /dev/null +++ b/server/store/migrations/000005_add_draft_lastactiveat_baseeditat.up.sql @@ -0,0 +1,10 @@ +-- LastActiveAt records the user's own last autosave of the draft, which is what editor presence is +-- derived from. It is distinct from UpdateAt, which can also be bumped by internal maintenance +-- writes that do not reflect user activity and would otherwise report the user as an active editor. +ALTER TABLE DOCS_Draft ADD COLUMN IF NOT EXISTS LastActiveAt BIGINT NOT NULL DEFAULT 0; + +-- BaseEditAt is the optimistic-lock baseline: the page EditAt the client saw at edit-open, compared +-- against the page's current EditAt on publish to reject concurrent-edit conflicts. It is write-once: +-- set when the draft row is first inserted and never overwritten by later autosaves. Existing rows +-- default to 0 (no baseline), which fails closed to requiring a forced publish. +ALTER TABLE DOCS_Draft ADD COLUMN IF NOT EXISTS BaseEditAt BIGINT NOT NULL DEFAULT 0; diff --git a/server/store/page_hierarchy.go b/server/store/page_hierarchy.go index ada1f86..bfbe635 100644 --- a/server/store/page_hierarchy.go +++ b/server/store/page_hierarchy.go @@ -27,6 +27,8 @@ var pageColListP = strings.Join(pageColumnsP, ", ") // These CTEs are built once at package init (inputs are compile-time constants) rather than on // every query. +// The hierarchy walks below use WITH RECURSIVE, which squirrel cannot express, so each CTE is built +// as a raw (parameterized) SQL string and consumed by callers that append their own SELECT. var ( pageDescendantsCTE = computeDescendantsCTE() diff --git a/server/store/page_move.go b/server/store/page_move.go index 981f5df..73099c4 100644 --- a/server/store/page_move.go +++ b/server/store/page_move.go @@ -247,7 +247,7 @@ func (s *Store) reindexSiblingGroup(tx *sqlx.Tx, channelID, parentID, movedPageI // cycle-safety are all re-validated under lock, so the move is safe regardless of concurrent // operations between the caller's pre-checks and this call. Cross-owner resources // (page-comment Posts, FileInfo) are not re-homed here. -func (s *Store) MovePageToSpace(pageID, sourceSpaceID, targetSpaceID string, parentPageID *string, expectedUpdateAt int64, force bool, maxDepth int) (_ *model.Page, priorParentID string, err error) { +func (s *Store) MovePageToSpace(pageID, sourceSpaceID, targetSpaceID, moverUserID string, parentPageID *string, expectedUpdateAt int64, force bool, maxDepth int) (_ *model.Page, priorParentID string, err error) { if pageID == "" { return nil, "", &ErrInvalidInput{Entity: "Page", Field: "Id", Value: pageID} } @@ -257,6 +257,11 @@ func (s *Store) MovePageToSpace(pageID, sourceSpaceID, targetSpaceID string, par if targetSpaceID == "" { return nil, "", &ErrInvalidInput{Entity: "Page", Field: "TargetSpaceId", Value: targetSpaceID} } + // moverUserID scopes the draft-quota guard in rewriteSubtreeSpace; an empty or malformed value + // would match no owner and silently skip the target-space quota check. + if !mmmodel.IsValidId(moverUserID) { + return nil, "", &ErrInvalidInput{Entity: "Page", Field: "MoverUserId", Value: moverUserID} + } tx, err := s.db.Beginx() if err != nil { @@ -272,18 +277,20 @@ func (s *Store) MovePageToSpace(pageID, sourceSpaceID, targetSpaceID string, par if firstSpace > secondSpace { firstSpace, secondSpace = secondSpace, firstSpace } - targetChannelID, lockErr := s.lockLiveSpaceChannel(tx, firstSpace) + channelA, lockErr := s.lockLiveSpaceChannel(tx, firstSpace) if lockErr != nil { return nil, "", lockErr } + channelB := channelA if secondSpace != firstSpace { - secondChannelID, lockErr := s.lockLiveSpaceChannel(tx, secondSpace) + channelB, lockErr = s.lockLiveSpaceChannel(tx, secondSpace) if lockErr != nil { return nil, "", lockErr } - if secondSpace == targetSpaceID { - targetChannelID = secondChannelID - } + } + targetChannelID := channelA + if secondSpace == targetSpaceID { + targetChannelID = channelB } // Lock the moving page, scoped to the caller's source space: a page relocated out of the URL by a @@ -362,7 +369,7 @@ func (s *Store) MovePageToSpace(pageID, sourceSpaceID, targetSpaceID string, par } // Rewrite SpaceId/ChannelId across the subtree (live rows and drafts). - if e := s.rewriteSubtreeSpace(tx, ids, targetSpaceID, targetChannelID, now); e != nil { + if e := s.rewriteSubtreeSpace(tx, ids, sourceSpaceID, targetSpaceID, targetChannelID, moverUserID, now); e != nil { return nil, "", e } @@ -414,9 +421,7 @@ func (s *Store) collectLiveSubtreeIDs(tx *sqlx.Tx, pageID string) ([]string, int if row.Depth > MaxPageHierarchyDepth { return nil, 0, &ErrLimitExceeded{Resource: "Page subtree for page_id=" + pageID + " (depth)", Limit: MaxPageHierarchyDepth} } - if row.Depth > maxRelDepth { - maxRelDepth = row.Depth - } + maxRelDepth = max(maxRelDepth, row.Depth) ids = append(ids, row.ID) } return ids, maxRelDepth, nil @@ -425,7 +430,44 @@ func (s *Store) collectLiveSubtreeIDs(tx *sqlx.Tx, pageID string) ([]string, int // rewriteSubtreeSpace re-homes the given page IDs onto // targetSpaceID/targetChannelID, chunked, within tx. It rewrites SpaceId/ChannelId across // live DOCS_Page rows, their version snapshots (OriginalId IN ids), and DOCS_Draft rows. -func (s *Store) rewriteSubtreeSpace(tx *sqlx.Tx, ids []string, targetSpaceID, targetChannelID string, now int64) error { +// Every user's drafts follow their page, not just the mover's: a draft is unpublished work its +// owner has not consented to lose, so a move must not destroy it as a side effect. +func (s *Store) rewriteSubtreeSpace(tx *sqlx.Tx, ids []string, sourceSpaceID, targetSpaceID, targetChannelID, moverUserID string, now int64) error { + // Quota guard: count the mover's drafts that will be re-homed into targetSpaceID (those in + // source that cover moved pages or sit under them as new-page children) and ensure adding + // them won't exceed MaxDraftsPerUserPerSpace in the target. This count is a lower bound for + // the total re-homed set (the cascade loop below can pick up transitively nested new-page + // drafts), so a failure here is correct, but a pass does not guarantee the cascade is safe; + // the cascade is bounded by model.MaxPageDepth and the count remains low in practice. + // + // Only the mover is quota-checked. Other users' re-homed drafts can push them past the cap in + // the target space, which is accepted: the cap is a soft storage bound, and re-homing moves + // existing rows rather than creating new ones. Failing a mover's move because an unrelated + // user sits at quota would be worse than briefly exceeding a soft cap. + // + // Unlike the re-home writes below, this count runs against the full id set in one query rather + // than in chunks: the predicate is an OR of two INs, so a draft whose PageId falls in one chunk + // and ParentId in another would be counted once per chunk. ids is bounded by + // MaxPageDescendantsLimit, well within Postgres's parameter limit, and this is a rare move op. + var movedDraftCount int + movedCountQ := s.getQueryBuilder(). + Select("COUNT(*)"). + From("DOCS_Draft"). + Where(sq.Eq{"UserId": moverUserID, "SpaceId": sourceSpaceID}). + Where(sq.Or{sq.Eq{"PageId": ids}, sq.Eq{"ParentId": ids}}) + if err := s.getBuilder(tx, &movedDraftCount, movedCountQ); err != nil { + return errors.Wrap(err, "failed to count mover drafts to re-home") + } + if movedDraftCount > 0 { + targetDraftCount, err := s.countDraftsForUser(tx, moverUserID, targetSpaceID) + if err != nil { + return errors.Wrap(err, "failed to count mover drafts in target space") + } + if targetDraftCount+movedDraftCount > model.MaxDraftsPerUserPerSpace { + return &ErrLimitExceeded{Resource: "Draft", Limit: model.MaxDraftsPerUserPerSpace, Reason: ReasonDraftQuotaExceeded} + } + } + const chunkSize = 1000 for i := 0; i < len(ids); i += chunkSize { chunk := ids[i:min(i+chunkSize, len(ids))] @@ -452,19 +494,56 @@ func (s *Store) rewriteSubtreeSpace(tx *sqlx.Tx, ids []string, targetSpaceID, ta return errors.Wrap(e, "failed to update subtree snapshots SpaceId/ChannelId") } - // Re-home drafts onto the target space: draft reads are scoped to the page's current space, - // so a draft left behind in the source space would become unreadable after the move. PageId - // matches a moved page's in-progress edit; ParentId matches a pending new-page draft parented - // within the subtree. + // Re-home every owner's drafts for the moved pages, so a draft keeps matching its page's + // space and stays readable to an owner who is a member of the target. + // + // UpdateAt uses monotonicBump so it stays a valid CAS token even when the move and a + // concurrent autosave share a millisecond boundary. SpaceId = sourceSpaceID prevents + // re-homing an unrelated draft that happens to share a PageId or ParentId with another space. + // + // LastActiveAt is reset so a re-homed draft is not reported as an active editor in the target + // space until its owner edits it there — otherwise a source-only owner's recent edit would + // surface to target members through GetPageActiveEditors for the rest of the active-editor window. draftUpd := s.getQueryBuilder(). Update("DOCS_Draft"). Set("SpaceId", targetSpaceID). - Set("UpdateAt", now). + Set("UpdateAt", monotonicBump("UpdateAt", now)). + Set("LastActiveAt", 0). + Where(sq.Eq{"SpaceId": sourceSpaceID}). Where(sq.Or{sq.Eq{"PageId": chunk}, sq.Eq{"ParentId": chunk}}) if _, e := s.execBuilder(tx, draftUpd); e != nil { - return errors.Wrap(e, "failed to update subtree drafts") + return errors.Wrap(e, "failed to re-home drafts for moved pages") + } + } + + // Cascade the space re-home to transitively-nested new-page drafts (draft B whose ParentId is + // draft A's PageId, not a live page). The chunk loop above matched only drafts whose ParentId + // was a live moved page; draft B is caught here. Draft nesting is same-owner only, so the join + // pairs each draft with its parent on UserId rather than singling out the mover. Loop until + // stable, bounded by model.MaxPageDepth which caps the draft tree depth. + // Squirrel cannot express UPDATE … FROM …, so the statement is built directly. + for range model.MaxPageDepth { + result, e := s.exec(tx, ` + UPDATE DOCS_Draft d + SET SpaceId = $1, UpdateAt = GREATEST(d.UpdateAt + 1, $2), LastActiveAt = 0 + FROM DOCS_Draft parent + WHERE d.SpaceId = $3 + AND parent.UserId = d.UserId + AND parent.SpaceId = $1 + AND parent.PageId = d.ParentId`, + targetSpaceID, now, sourceSpaceID) + if e != nil { + return errors.Wrap(e, "failed to cascade draft space to nested drafts") + } + rows, rowsErr := result.RowsAffected() + if rowsErr != nil { + return errors.Wrap(rowsErr, "failed to read rows affected for nested draft cascade") + } + if rows == 0 { + break } } + return nil } diff --git a/server/store/page_move_test.go b/server/store/page_move_test.go index 0436c79..c33d300 100644 --- a/server/store/page_move_test.go +++ b/server/store/page_move_test.go @@ -5,6 +5,7 @@ package store_test import ( "errors" + "sync" "testing" "github.com/stretchr/testify/require" @@ -120,7 +121,7 @@ func TestMovePageToSpace_Store(t *testing.T) { spaceB, err := s.CreateSpace(newSpace(chB)) require.NoError(t, err) - movedRoot, _, err := s.MovePageToSpace(root.Id, spaceA.Id, spaceB.Id, nil, root.UpdateAt, false, store.MaxPageHierarchyDepth) + movedRoot, _, err := s.MovePageToSpace(root.Id, spaceA.Id, spaceB.Id, mmmodel.NewId(), nil, root.UpdateAt, false, store.MaxPageHierarchyDepth) require.NoError(t, err) require.Equal(t, spaceB.Id, movedRoot.SpaceId, "returned page reflects the committed move") require.Equal(t, chB, movedRoot.ChannelId) @@ -158,34 +159,148 @@ func TestMovePageToSpace_Store(t *testing.T) { // An in-progress edit draft on the page, and a pending new-page draft parented under it // (its own PageId has no page row yet). - _, err = s.UpsertDraft(newDraft(user, spaceA.Id, page.Id, "")) + dEdit := newDraft(user, spaceA.Id, page.Id, "") + dEdit.BaseEditAt = page.EditAt + _, _, err = s.UpsertDraft(dEdit, nil, nil, nil) require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(user, spaceA.Id, mmmodel.NewId(), page.Id)) + parentPageID := page.Id + _, _, err = s.UpsertDraft(newDraft(user, spaceA.Id, mmmodel.NewId(), parentPageID), &parentPageID, nil, nil) require.NoError(t, err) - sourceBefore, err := s.GetDraftsForSpace(user, spaceA.Id) + sourceBefore, err := s.GetDraftsForSpace(user, spaceA.Id, 0, testDraftListLimit) require.NoError(t, err) require.Len(t, sourceBefore, 2, "both drafts are readable in the source space before the move") - _, _, err = s.MovePageToSpace(page.Id, spaceA.Id, spaceB.Id, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth) + _, _, err = s.MovePageToSpace(page.Id, spaceA.Id, spaceB.Id, user, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth) require.NoError(t, err) movedDraft, err := s.GetDraft(user, page.Id) require.NoError(t, err) require.Equal(t, spaceB.Id, movedDraft.SpaceId, "the edit draft follows the page and stays readable") - targetDrafts, err := s.GetDraftsForSpace(user, spaceB.Id) + targetDrafts, err := s.GetDraftsForSpace(user, spaceB.Id, 0, testDraftListLimit) require.NoError(t, err) require.Len(t, targetDrafts, 2, "both drafts now live in the target space") - sourceAfter, err := s.GetDraftsForSpace(user, spaceA.Id) + sourceAfter, err := s.GetDraftsForSpace(user, spaceA.Id, 0, testDraftListLimit) require.NoError(t, err) require.Empty(t, sourceAfter, "no draft remains stranded in the source space") }) + // A cross-space move re-homes every owner's draft for the moved pages, not just the mover's: + // a draft is unpublished work its owner never consented to lose, so the move preserves it and + // lets the space-membership read gate hide it from an owner who cannot reach the target space. + t.Run("re-homes another user's draft instead of deleting it", func(t *testing.T) { + s := openTestDB(t) + chA := mmmodel.NewId() + spaceA, err := s.CreateSpace(newSpace(chA)) + require.NoError(t, err) + mover := mmmodel.NewId() + other := mmmodel.NewId() + page, err := s.CreatePage(newPage(spaceA.Id, chA, mover, ""), testDefaultMaxDepth) + require.NoError(t, err) + + chB := mmmodel.NewId() + spaceB, err := s.CreateSpace(newSpace(chB)) + require.NoError(t, err) + + // A second user holds an in-progress edit draft on the same page. + otherDraft := newDraft(other, spaceA.Id, page.Id, "") + otherDraft.BaseEditAt = page.EditAt + _, _, err = s.UpsertDraft(otherDraft, nil, nil, nil) + require.NoError(t, err) + + _, _, err = s.MovePageToSpace(page.Id, spaceA.Id, spaceB.Id, mover, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth) + require.NoError(t, err) + + moved, err := s.GetDraft(other, page.Id) + require.NoError(t, err, "the other user's draft survives the move") + require.Equal(t, spaceB.Id, moved.SpaceId, "and is re-homed to the target space, not deleted") + + targetDrafts, err := s.GetDraftsForSpace(other, spaceB.Id, 0, testDraftListLimit) + require.NoError(t, err) + require.Len(t, targetDrafts, 1, "the draft is readable in the target space") + + sourceDrafts, err := s.GetDraftsForSpace(other, spaceA.Id, 0, testDraftListLimit) + require.NoError(t, err) + require.Empty(t, sourceDrafts, "nothing is stranded in the source space") + }) + + // The mover's re-homed drafts are quota-checked against the target space; other users' are not. + t.Run("rejects the move when re-homing exceeds the mover's target-space quota", func(t *testing.T) { + s := openTestDB(t) + chA := mmmodel.NewId() + spaceA, err := s.CreateSpace(newSpace(chA)) + require.NoError(t, err) + mover := mmmodel.NewId() + page, err := s.CreatePage(newPage(spaceA.Id, chA, mover, ""), testDefaultMaxDepth) + require.NoError(t, err) + // One mover draft on the page to be moved. + editDraft := newDraft(mover, spaceA.Id, page.Id, "") + editDraft.BaseEditAt = page.EditAt + _, _, err = s.UpsertDraft(editDraft, nil, nil, nil) + require.NoError(t, err) + + chB := mmmodel.NewId() + spaceB, err := s.CreateSpace(newSpace(chB)) + require.NoError(t, err) + // Fill the mover's quota in the target space, so re-homing even one more trips the cap. + for range model.MaxDraftsPerUserPerSpace { + _, _, err = s.UpsertDraft(newDraft(mover, spaceB.Id, mmmodel.NewId(), ""), nil, nil, nil) + require.NoError(t, err) + } + + _, _, err = s.MovePageToSpace(page.Id, spaceA.Id, spaceB.Id, mover, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth) + require.Error(t, err) + require.True(t, store.IsErrLimitExceeded(err), "expected ErrLimitExceeded, got %T: %v", err, err) + + // The move rolled back: the page stays in the source space. + stillA, err := s.GetPage(page.Id, false) + require.NoError(t, err) + require.Equal(t, spaceA.Id, stillA.SpaceId, "a rejected move leaves the page in the source space") + }) + + // A cross-space move must not leak presence: rewriteSubtreeSpace resets a re-homed draft's + // LastActiveAt to 0, so an owner who was an active editor in the source space is not reported + // as one in the target space until they edit the draft there again. + t.Run("re-homed draft's presence does not leak into the target space", func(t *testing.T) { + s := openTestDB(t) + chA := mmmodel.NewId() + spaceA, err := s.CreateSpace(newSpace(chA)) + require.NoError(t, err) + user := mmmodel.NewId() + page, err := s.CreatePage(newPage(spaceA.Id, chA, user, ""), testDefaultMaxDepth) + require.NoError(t, err) + + chB := mmmodel.NewId() + spaceB, err := s.CreateSpace(newSpace(chB)) + require.NoError(t, err) + + editDraft := newDraft(user, spaceA.Id, page.Id, "") + editDraft.BaseEditAt = page.EditAt + _, _, err = s.UpsertDraft(editDraft, nil, nil, nil) + require.NoError(t, err) + + windowStart := mmmodel.GetMillis() - 60*1000 + editorsBefore, err := s.GetPageActiveEditors(page.Id, spaceA.Id, windowStart) + require.NoError(t, err) + require.Equal(t, []string{user}, editorsBefore, "the user is an active editor in the source space before the move") + + _, _, err = s.MovePageToSpace(page.Id, spaceA.Id, spaceB.Id, user, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth) + require.NoError(t, err) + + movedDraft, err := s.GetDraft(user, page.Id) + require.NoError(t, err) + require.Equal(t, spaceB.Id, movedDraft.SpaceId, "the draft is re-homed to the target space") + + editorsAfter, err := s.GetPageActiveEditors(page.Id, spaceB.Id, windowStart) + require.NoError(t, err) + require.Empty(t, editorsAfter, "presence must not leak across the move: LastActiveAt was reset") + }) + t.Run("empty pageID returns invalid-input", func(t *testing.T) { s := openTestDB(t) - _, _, err := s.MovePageToSpace("", mmmodel.NewId(), mmmodel.NewId(), nil, 0, false, store.MaxPageHierarchyDepth) + _, _, err := s.MovePageToSpace("", mmmodel.NewId(), mmmodel.NewId(), mmmodel.NewId(), nil, 0, false, store.MaxPageHierarchyDepth) require.Error(t, err) var inv *store.ErrInvalidInput require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) @@ -193,7 +308,7 @@ func TestMovePageToSpace_Store(t *testing.T) { t.Run("empty sourceSpaceID returns invalid-input", func(t *testing.T) { s := openTestDB(t) - _, _, err := s.MovePageToSpace(mmmodel.NewId(), "", mmmodel.NewId(), nil, 0, false, store.MaxPageHierarchyDepth) + _, _, err := s.MovePageToSpace(mmmodel.NewId(), "", mmmodel.NewId(), mmmodel.NewId(), nil, 0, false, store.MaxPageHierarchyDepth) require.Error(t, err) var inv *store.ErrInvalidInput require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) @@ -201,7 +316,7 @@ func TestMovePageToSpace_Store(t *testing.T) { t.Run("empty targetSpaceID returns invalid-input", func(t *testing.T) { s := openTestDB(t) - _, _, err := s.MovePageToSpace(mmmodel.NewId(), mmmodel.NewId(), "", nil, 0, false, store.MaxPageHierarchyDepth) + _, _, err := s.MovePageToSpace(mmmodel.NewId(), mmmodel.NewId(), "", mmmodel.NewId(), nil, 0, false, store.MaxPageHierarchyDepth) require.Error(t, err) var inv *store.ErrInvalidInput require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) @@ -213,7 +328,7 @@ func TestMovePageToSpace_Store(t *testing.T) { spaceB, err := s.CreateSpace(newSpace(chB)) require.NoError(t, err) - _, _, err = s.MovePageToSpace(mmmodel.NewId(), mmmodel.NewId(), spaceB.Id, nil, 0, false, store.MaxPageHierarchyDepth) + _, _, err = s.MovePageToSpace(mmmodel.NewId(), mmmodel.NewId(), spaceB.Id, mmmodel.NewId(), nil, 0, false, store.MaxPageHierarchyDepth) require.Error(t, err) require.True(t, store.IsErrNotFound(err), "expected ErrNotFound, got %T: %v", err, err) }) @@ -414,7 +529,7 @@ func TestPageMutations_ScopedToSpace(t *testing.T) { }) t.Run("move-to-space with wrong source space is not found", func(t *testing.T) { - _, _, mErr := s.MovePageToSpace(page.Id, spaceB.Id, page.SpaceId, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth) + _, _, mErr := s.MovePageToSpace(page.Id, spaceB.Id, page.SpaceId, user, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth) require.True(t, store.IsErrNotFound(mErr)) }) @@ -450,3 +565,82 @@ func TestPageMutations_ScopedToSpace(t *testing.T) { require.Equal(t, child.Id, descendants[0].Id) }) } + +// TestMovePageToSpace_ConcurrentAutosaveInvariants exercises the space-row FOR UPDATE +// serialization that guards a cross-space move against a simultaneous autosave on a draft of the +// moving page. MovePageToSpace and UpsertDraft both take lockLiveSpace on the source space, so the +// two transactions serialize on the same row. The +// test does not assert which one wins — it asserts that whichever ordering the lock produces, the +// committed state is one of the legal outcomes: the page reaches the target space, and its draft is +// re-homed there exactly once, never duplicated across spaces, orphaned in the source, or left +// pointing at the wrong space. Both orderings satisfy these invariants, so the test never flakes; a +// broken lock (a lost move, a torn or duplicated draft) would fail an invariant on some interleaving. +func TestMovePageToSpace_ConcurrentAutosaveInvariants(t *testing.T) { + s := openTestDB(t) + + // Repeat so scheduling exercises both commit orderings (move-first and autosave-first) across runs. + const iterations = 12 + for i := range iterations { + chA := mmmodel.NewId() + spaceA, err := s.CreateSpace(newSpace(chA)) + require.NoError(t, err) + chB := mmmodel.NewId() + spaceB, err := s.CreateSpace(newSpace(chB)) + require.NoError(t, err) + + user := mmmodel.NewId() + page, err := s.CreatePage(newPage(spaceA.Id, chA, user, ""), testDefaultMaxDepth) + require.NoError(t, err) + + // An in-progress edit draft on the page, baselined at the page's current EditAt. + seed := newDraft(user, spaceA.Id, page.Id, "") + seed.BaseEditAt = page.EditAt + _, _, err = s.UpsertDraft(seed, nil, nil, nil) + require.NoError(t, err) + + // Race the move against an autosave on the same draft, released together. Both calls open + // their own transactions and contend on the source-space row. Errors are ignored here: an + // autosave that loses to the move is rejected (its page no longer lives in the source space), + // which is a legal outcome — the invariants below hold either way. Assertions run only on the + // main goroutine, after both finish. + start := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + <-start + _, _, _ = s.MovePageToSpace(page.Id, spaceA.Id, spaceB.Id, user, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth) + }() + go func() { + defer wg.Done() + <-start + autosave := newDraft(user, spaceA.Id, page.Id, "") + autosave.BaseEditAt = page.EditAt + _, _, _ = s.UpsertDraft(autosave, nil, nil, nil) + }() + close(start) + wg.Wait() + + // The move always commits: the autosave never touches the page row's UpdateAt, so the move's + // optimistic-lock CAS holds regardless of ordering. + gotPage, err := s.GetPage(page.Id, false) + require.NoError(t, err) + require.Equal(t, spaceB.Id, gotPage.SpaceId, "iter %d: the move must commit the page to the target space", i) + + // The draft is re-homed to the target space exactly once — never duplicated and never orphaned + // in the source. + targetDrafts, err := s.GetDraftsForSpace(user, spaceB.Id, 0, testDraftListLimit) + require.NoError(t, err) + require.Len(t, targetDrafts, 1, "iter %d: the draft must be re-homed to the target exactly once", i) + require.Equal(t, page.Id, targetDrafts[0].PageId, "iter %d: the re-homed draft is the page's draft", i) + + sourceDrafts, err := s.GetDraftsForSpace(user, spaceA.Id, 0, testDraftListLimit) + require.NoError(t, err) + require.Empty(t, sourceDrafts, "iter %d: no draft may be left behind in the source space", i) + + // The re-homed draft's SpaceId matches its page's space, so it stays readable rather than orphaned. + moved, err := s.GetDraft(user, page.Id) + require.NoError(t, err) + require.Equal(t, spaceB.Id, moved.SpaceId, "iter %d: the draft SpaceId must match the moved page", i) + } +} diff --git a/server/store/page_store.go b/server/store/page_store.go index 8e396b3..b47b92a 100644 --- a/server/store/page_store.go +++ b/server/store/page_store.go @@ -72,26 +72,32 @@ func (s *Store) CreatePage(page *model.Page, maxDepth int) (_ *model.Page, err e } defer s.finalizeTransaction(tx, &err) - // Lock the space row for the lifetime of this insert so it serializes with - // DeleteSpace: a racing delete blocks here and then cascades the new page, while - // a space already gone causes an immediate abort. - spaceLockQuery := s.getQueryBuilder(). - Select("ChannelId"). - From("DOCS_Space"). - Where(sq.Eq{"Id": page.SpaceId, "DeleteAt": 0}). - Suffix("FOR UPDATE") - var spaceChannelID string - if spErr := s.getBuilder(tx, &spaceChannelID, spaceLockQuery); spErr != nil { - if errors.Is(spErr, sql.ErrNoRows) { - return nil, &ErrNotFound{EntityName: "Space", ID: page.SpaceId} - } - return nil, errors.Wrap(spErr, "failed to lock space for page create") + created, insErr := s.insertPageTx(tx, page, maxDepth, "create depth") + if insErr != nil { + return nil, insErr + } + + if err = tx.Commit(); err != nil { + return nil, errors.Wrap(err, "commit_transaction") + } + + return created, nil +} + +// insertPageTx inserts a new page inside the caller's transaction, applying the invariants shared +// by CreatePage and the draft-publish path: it locks the space row (serializing with DeleteSpace / +// RestoreSpace) and derives ChannelId from it, re-verifies the parent is still live and enforces +// maxDepth against the parent's locked current depth, assigns the next sibling sort order, and +// inserts. depthContext labels the depth-cap error with the originating operation. A PK collision +// returns ErrConflict so a publish caller can adopt the concurrent winner. +func (s *Store) insertPageTx(tx *sqlx.Tx, page *model.Page, maxDepth int, depthContext string) (*model.Page, error) { + spaceChannelID, spErr := s.lockLiveSpaceChannel(tx, page.SpaceId) + if spErr != nil { + return nil, spErr } // Derive ChannelId from the locked space row (single source of truth) rather than trusting the caller-supplied value. page.ChannelId = spaceChannelID - // Re-verify the parent is still live under the same transaction, and enforce maxDepth against - // its locked, current depth — atomic with the insert, unlike a pre-transaction read. if page.ParentId != "" { if pErr := s.lockLiveParent(tx, page.ParentId, page.SpaceId, "Page"); pErr != nil { return nil, pErr @@ -100,7 +106,7 @@ func (s *Store) CreatePage(page *model.Page, maxDepth int) (_ *model.Page, err e if depthErr != nil { return nil, depthErr } - if capErr := depthCapError("Page parent_id="+page.ParentId+" (create depth)", parentDepth, 0, maxDepth); capErr != nil { + if capErr := depthCapError("Page parent_id="+page.ParentId+" ("+depthContext+")", parentDepth, 0, maxDepth); capErr != nil { return nil, capErr } } @@ -126,13 +132,8 @@ func (s *Store) CreatePage(page *model.Page, maxDepth int) (_ *model.Page, err e if isUniqueViolation(execErr) { return nil, &ErrConflict{Resource: "Page id=" + page.Id} } - return nil, errors.Wrap(execErr, "failed to save Page") + return nil, errors.Wrap(execErr, "failed to insert page") } - - if err = tx.Commit(); err != nil { - return nil, errors.Wrap(err, "commit_transaction") - } - return page, nil } @@ -187,6 +188,24 @@ func (s *Store) UpdatePage(pageID, spaceID string, patch *model.PagePatch, baseE } defer s.finalizeTransaction(tx, &err) + page, patchErr := s.applyPagePatchTx(tx, pageID, spaceID, patch, baseEditAt, force, lastModifiedBy) + if patchErr != nil { + return nil, patchErr + } + + if err = tx.Commit(); err != nil { + return nil, errors.Wrap(err, "commit_transaction") + } + + return page, nil +} + +// applyPagePatchTx merges patch into the live page inside the caller's transaction, applying the +// invariants shared by UpdatePage and the draft-publish edit path: it locks the live row, CASes on +// EditAt (baseEditAt is the value the caller last saw; force skips the CAS but still merges into +// the current row so untouched fields keep any concurrent edit), validates the merged row, and +// writes it with strictly-monotonic EditAt/UpdateAt. +func (s *Store) applyPagePatchTx(tx *sqlx.Tx, pageID, spaceID string, patch *model.PagePatch, baseEditAt int64, force bool, lastModifiedBy string) (*model.Page, error) { // Lock the live row so the read-modify-write is atomic. The lock (not an EditAt // predicate) is what makes the write safe, so both paths merge the patch into the value // read here; no concurrent writer can slip between this read and the UPDATE below. Scoped to @@ -209,7 +228,7 @@ func (s *Store) UpdatePage(pageID, spaceID string, patch *model.PagePatch, baseE } if !force && page.EditAt != baseEditAt { - return nil, &ErrConflict{Resource: "Page id=" + pageID + " (concurrent edit)"} + return nil, &ErrConflict{Resource: "Page id=" + pageID, Reason: ReasonConcurrentEdit} } page.Patch(patch) @@ -250,11 +269,6 @@ func (s *Store) UpdatePage(pageID, spaceID string, patch *model.PagePatch, baseE page.UpdateAt = now page.EditAt = now - - if err = tx.Commit(); err != nil { - return nil, errors.Wrap(err, "commit_transaction") - } - return &page, nil } @@ -423,10 +437,10 @@ func (s *Store) DeletePage(pageID, spaceID, userID string) (_ string, err error) // A draft is unpublished work on the page, so deleting the page ends its life; a new-page // draft parented under this page is a pending child of it, so it is reparented rather than // deleted (see reparentDraftsForPage). Both cascades run inside this transaction. - if draftErr := s.deleteDraftsForPage(tx, pageID); draftErr != nil { + if draftErr := s.deleteDraftsForPage(tx, pageID, spaceID); draftErr != nil { return "", draftErr } - if draftErr := s.reparentDraftsForPage(tx, pageID, deleted.ParentID, now); draftErr != nil { + if draftErr := s.reparentDraftsForPage(tx, pageID, deleted.ParentID, spaceID, now); draftErr != nil { return "", draftErr } @@ -536,13 +550,12 @@ func (s *Store) RestorePage(pageID, spaceID, userID string, maxDepth int) (_ *mo Set("EditAt", page.EditAt). Set("LastModifiedBy", page.LastModifiedBy). Set("ParentId", page.ParentId). - Set("SortOrder", page.SortOrder) - - restoreQuery = restoreQuery.Where(sq.And{ - sq.Eq{"Id": pageID}, - sq.Eq{"OriginalId": ""}, - sq.NotEq{"DeleteAt": 0}, - }) + Set("SortOrder", page.SortOrder). + Where(sq.And{ + sq.Eq{"Id": pageID}, + sq.Eq{"OriginalId": ""}, + sq.NotEq{"DeleteAt": 0}, + }) result, txErr := s.execBuilder(tx, restoreQuery) if txErr != nil { return nil, errors.Wrap(txErr, "failed to restore page") @@ -560,6 +573,13 @@ func (s *Store) RestorePage(pageID, spaceID, userID string, maxDepth int) (_ *mo // PageExistsInSpace reports whether pageID is a live page in spaceID, without fetching the // row — callers that only need to 404 on a missing page avoid hauling the page body. func (s *Store) PageExistsInSpace(pageID, spaceID string) (bool, error) { + return s.pageExistsInSpace(s.db, pageID, spaceID) +} + +// pageExistsInSpace is PageExistsInSpace against an explicit executor, so callers inside a +// transaction observe that transaction's own view (e.g. its uncommitted writes and row locks) +// rather than reading through a separate pooled connection. +func (s *Store) pageExistsInSpace(e sqlx.ExtContext, pageID, spaceID string) (bool, error) { if pageID == "" { return false, &ErrInvalidInput{Entity: "Page", Field: "pageID", Value: pageID} } @@ -572,7 +592,7 @@ func (s *Store) PageExistsInSpace(pageID, spaceID string) (bool, error) { Where(sq.Eq{"Id": pageID, "SpaceId": spaceID}). Where(liveNonSnapshotFilter("")) var exists int - if err := s.getBuilder(s.db, &exists, query); err != nil { + if err := s.getBuilder(e, &exists, query); err != nil { if errors.Is(err, sql.ErrNoRows) { return false, nil } @@ -704,3 +724,122 @@ func (s *Store) GetSpacePages(spaceID string, offset, limit int) ([]*model.PageS return pages, nil } + +// PublishNewPageDraft atomically inserts page and deletes the user's draft for it in a single +// transaction. A PK collision returns ErrConflict so the caller can adopt the concurrent winner +// without a half-state; the whole transaction is rolled back. +// +// draftUpdateAt pins the draft delete to the version the caller read (see deletePublishedDraftTx +// for how a version mismatch rolls the whole publish back). +func (s *Store) PublishNewPageDraft(page *model.Page, userID, spaceID string, maxDepth int, draftUpdateAt int64) (_ *model.Page, err error) { + if page == nil { + return nil, &ErrInvalidInput{Entity: "Page", Field: "page", Value: nil} + } + if userID == "" { + return nil, &ErrInvalidInput{Entity: "Draft", Field: "userID", Value: userID} + } + if spaceID == "" { + return nil, &ErrInvalidInput{Entity: "Draft", Field: "spaceID", Value: spaceID} + } + // spaceID is the space from the caller's request; the page must live in it. A mismatch means + // the caller built the page for the wrong space — reject rather than write under it. + if page.SpaceId != spaceID { + return nil, &ErrInvalidInput{Entity: "Page", Field: "SpaceId", Value: page.SpaceId} + } + + tx, err := s.db.Beginx() + if err != nil { + return nil, errors.Wrap(err, "begin_transaction") + } + defer s.finalizeTransaction(tx, &err) + + result, err := s.insertPageTx(tx, page, maxDepth, "publish depth") + if err != nil { + return nil, err + } + + if err = s.deletePublishedDraftTx(tx, userID, page.Id, spaceID, draftUpdateAt); err != nil { + return nil, err + } + + if err = tx.Commit(); err != nil { + return nil, errors.Wrap(err, "commit_transaction") + } + + return result, nil +} + +// PublishPageEditDraft atomically applies patch to the live page (pageID, spaceID) and deletes the +// user's draft for it in a single transaction, via the shared applyPagePatchTx (the same row-lock, +// EditAt CAS, and merge invariants UpdatePage applies). baseEditAt is the optimistic-lock baseline +// the caller last saw; a mismatch returns ErrConflict and rolls the whole transaction back. force +// skips the CAS, but the patch still merges into the current row, so fields it leaves untouched +// keep any concurrent edit. userID records the editor. +// +// draftUpdateAt pins the draft delete to the version the caller read (see deletePublishedDraftTx +// for how a version mismatch rolls the whole publish back). +func (s *Store) PublishPageEditDraft(pageID, spaceID string, patch *model.PagePatch, baseEditAt int64, force bool, userID string, draftUpdateAt int64) (_ *model.Page, err error) { + if pageID == "" { + return nil, &ErrInvalidInput{Entity: "Page", Field: "Id", Value: pageID} + } + if spaceID == "" { + return nil, &ErrInvalidInput{Entity: "Page", Field: "SpaceId", Value: spaceID} + } + if userID == "" { + return nil, &ErrInvalidInput{Entity: "Draft", Field: "userID", Value: userID} + } + // Validate the patch before opening the transaction (matching UpdatePage), so an invalid or + // empty patch never locks the row or bumps UpdateAt/EditAt/LastModifiedBy. + if validErr := patch.IsValid(); validErr != nil { + return nil, &ErrInvalidInput{Entity: "Page", Field: "Patch", Value: validErr.Error(), Reason: validErr.Id} + } + + tx, err := s.db.Beginx() + if err != nil { + return nil, errors.Wrap(err, "begin_transaction") + } + defer s.finalizeTransaction(tx, &err) + + result, err := s.applyPagePatchTx(tx, pageID, spaceID, patch, baseEditAt, force, userID) + if err != nil { + return nil, err + } + + if err = s.deletePublishedDraftTx(tx, userID, pageID, spaceID, draftUpdateAt); err != nil { + return nil, err + } + + if err = tx.Commit(); err != nil { + return nil, errors.Wrap(err, "commit_transaction") + } + + return result, nil +} + +// deletePublishedDraftTx deletes the just-published draft atomically with the page write in the +// caller's transaction, but only if it still holds the content the caller published. A concurrent +// autosave bumps UpdateAt, so it matches no row and the publish is rolled back — the newer draft +// survives and the client can publish it. +// +// UpdateAt is the only version token, so a bulk maintenance write that moves it without changing +// content (a page delete reparenting a pending child draft, a move-to-space re-homing it) also +// trips this CAS and surfaces a ReasonConcurrentAutosave conflict when no autosave occurred. The +// failure is safe — clean rollback, no data loss, self-heals when the client republishes — so we +// accept it rather than add a separate content-only version column for this narrow race. +func (s *Store) deletePublishedDraftTx(tx *sqlx.Tx, userID, pageID, spaceID string, draftUpdateAt int64) error { + deleteDraftQ := s.getQueryBuilder(). + Delete("DOCS_Draft"). + Where(sq.Eq{"UserId": userID, "PageId": pageID, "SpaceId": spaceID, "UpdateAt": draftUpdateAt}) + dRes, dErr := s.execBuilder(tx, deleteDraftQ) + if dErr != nil { + return errors.Wrap(dErr, "failed to delete draft on publish") + } + dRows, dRowsErr := dRes.RowsAffected() + if dRowsErr != nil { + return errors.Wrap(dRowsErr, "failed to read rows affected deleting draft on publish") + } + if dRows == 0 { + return &ErrConflict{Resource: "Draft page_id=" + pageID, Reason: ReasonConcurrentAutosave} + } + return nil +} diff --git a/server/store/space_store.go b/server/store/space_store.go index 3dcb9ff..421bf7e 100644 --- a/server/store/space_store.go +++ b/server/store/space_store.go @@ -381,6 +381,8 @@ func (s *Store) withSpaceMembershipLock(spaceID string, acquireTimeout time.Dura }() key := "space_members:" + spaceID + // The pg_try_advisory_lock / pg_advisory_unlock calls here are bare function-call SELECTs that + // squirrel does not model, so they are issued raw. // Poll with pg_try_advisory_lock rather than blocking in pg_advisory_lock: a blocking wait // canceled by the deadline races against the server granting the lock in the same instant, // which would strand a granted lock on a connection headed back to the pool. Each try diff --git a/server/store/store.go b/server/store/store.go index ca19f12..7d009f5 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -220,6 +220,8 @@ func (s *Store) execBuilder(e sqlx.ExtContext, b sq.Sqlizer) (sql.Result, error) } // advisoryXactLock takes the transaction-scoped advisory lock for key, held until tx ends. +// It is a bare pg_advisory_xact_lock function-call SELECT, which squirrel does not model, so +// it is issued raw. // hashtextextended maps the key to a single bigint, so a hash collision only over-serializes // the two colliding keys' operations — added contention, never corruption, with negligible // probability. Must be called inside tx. @@ -311,6 +313,15 @@ const ( ReasonMaxDepthExceeded = "max_depth_exceeded" ReasonSubtreeMaxDepthExceeded = "subtree_max_depth_exceeded" ReasonParentNotLive = "parent_not_live" + ReasonDraftCycle = "draft_cycle" + ReasonDraftTooDeep = "draft_too_deep" + ReasonDraftQuotaExceeded = "draft_quota_exceeded" + // ReasonPageNotLive marks an autosave whose target is not addressable in the request's space: + // the page was deleted, snapshotted, or moved out of it, the existing draft belongs to another + // space, or the page id was never reserved at all (no page row and no draft). The caller maps + // it to 404 rather than a generic 400 — from the requester's view the page does not exist in + // that space. + ReasonPageNotLive = "page_not_live" ) func (e *ErrInvalidInput) Error() string { @@ -323,12 +334,39 @@ func IsErrInvalidInput(err error) bool { return errors.As(err, &e) } -// ErrConflict is returned when a unique constraint is violated or a CAS check fails. +// InvalidInputReason returns the Reason of the ErrInvalidInput in err's chain, or "" if err is not +// an ErrInvalidInput or carries no reason. +func InvalidInputReason(err error) string { + var e *ErrInvalidInput + if errors.As(err, &e) { + return e.Reason + } + return "" +} + +// Conflict reasons let a caller tell one CAS failure from another without parsing Resource. An +// ErrConflict with no Reason is an unqualified conflict (e.g. a primary-key collision). +const ( + // ReasonConcurrentEdit: the page's EditAt no longer matches the baseline the caller published + // against — someone else edited the page. + ReasonConcurrentEdit = "concurrent_edit" + // ReasonConcurrentAutosave: the draft's version token no longer matches what the caller read — + // usually a concurrent autosave, but also a bulk write (a page delete reparenting a pending + // draft, or a move-to-space re-homing it) that bumps the token without changing content. + ReasonConcurrentAutosave = "concurrent_autosave" +) + +// ErrConflict is returned when a unique constraint is violated or a CAS check fails. Reason, when +// set, names which CAS failed so a caller can map it to a specific response. type ErrConflict struct { Resource string + Reason string } func (e *ErrConflict) Error() string { + if e.Reason != "" { + return fmt.Sprintf("conflict on %s: %s", e.Resource, e.Reason) + } return fmt.Sprintf("conflict: %s", e.Resource) } @@ -338,6 +376,16 @@ func IsErrConflict(err error) bool { return errors.As(err, &e) } +// ConflictReason returns the Reason of the ErrConflict in err's chain, or "" if err is not an +// ErrConflict or carries no reason. +func ConflictReason(err error) string { + var e *ErrConflict + if errors.As(err, &e) { + return e.Reason + } + return "" +} + // ErrLimitExceeded is returned when a result set exceeds a hard size limit. type ErrLimitExceeded struct { Resource string diff --git a/server/store/store_test.go b/server/store/store_test.go index 2211803..d9b987c 100644 --- a/server/store/store_test.go +++ b/server/store/store_test.go @@ -40,6 +40,10 @@ func restorePageErr(s *store.Store, pageID, spaceID, userID string, maxDepth int // depth cap itself (see testutil.UncappedMaxDepth). const testDefaultMaxDepth = testutil.UncappedMaxDepth +// testDraftListLimit is a limit large enough that a draft listing in these tests is never a +// partial page, so a test can assert on the whole set. +const testDraftListLimit = 100 + // openTestDB opens an isolated Postgres schema for this test run, runs migrations into it, and // returns the Store. The schema is dropped in t.Cleanup so parallel package runs never share // tables. @@ -481,7 +485,7 @@ func TestFetchDescendantRows(t *testing.T) { } func TestDepthBoundaryExact(t *testing.T) { - const maxDepth = 10 // mirrors app.MaxPageDepth; the store CTE uses 50 + const maxDepth = model.MaxPageDepth // the store CTE uses the larger MaxPageHierarchyDepth (50) s := openTestDB(t) @@ -1010,10 +1014,16 @@ func TestDeletePage(t *testing.T) { require.NoError(t, err) // Two users hold drafts for this page; both must be hard-deleted when the page is. + // UpsertDraft requires the EditAt baseline when the page row already exists. otherUserID := mmmodel.NewId() - _, err = s.UpsertDraft(newDraft(userID, space.Id, created.Id, "")) + withBaseline := func(uid string) *model.Draft { + d := newDraft(uid, space.Id, created.Id, "") + d.BaseEditAt = created.EditAt + return d + } + _, _, err = s.UpsertDraft(withBaseline(userID), nil, nil, nil) require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(otherUserID, space.Id, created.Id, "")) + _, _, err = s.UpsertDraft(withBaseline(otherUserID), nil, nil, nil) require.NoError(t, err) require.NoError(t, deletePageErr(s, created.Id, created.SpaceId, userID)) @@ -1045,6 +1055,38 @@ func TestDeletePage(t *testing.T) { require.Equal(t, parent.ParentId, gotChild.ParentId, "child must be reparented to the deleted page's parent") }) + t.Run("reparents draft UpdateAt monotonically", func(t *testing.T) { + // reparentDraftsForPage uses GREATEST(now, UpdateAt+1) rather than a plain SET UpdateAt=now. + // Without GREATEST, a reparent whose `now` was captured before a concurrent autosave could + // move UpdateAt backward, letting a stale publish CAS-delete match a row it should not touch. + parent, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + // New-page draft whose pending parent is the published page. + draftPageID := mmmodel.NewId() + parentID := parent.Id + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, draftPageID, ""), &parentID, nil, nil) + require.NoError(t, err) + + // Force the draft's stored UpdateAt ahead of wall clock, so a plain SET UpdateAt=now would move + // it backward. Only the GREATEST(now, UpdateAt+1) bump keeps it strictly advancing — a + // wall-clock-only reparent would fail the assertion below, which is what makes this a real guard. + future := mmmodel.GetMillis() + 60*60*1000 + _, err = s.ExecBuilderForTest(s.QueryBuilderForTest(). + Update("DOCS_Draft"). + Set("UpdateAt", future). + Where(sq.Eq{"UserId": userID, "PageId": draftPageID})) + require.NoError(t, err) + + // Deleting the parent triggers reparentDraftsForPage on this draft. + require.NoError(t, deletePageErr(s, parent.Id, space.Id, userID)) + + after, err := s.GetDraft(userID, draftPageID) + require.NoError(t, err) + require.Equal(t, future+1, after.UpdateAt, + "reparent must strictly advance UpdateAt to stored+1 so a stale publish CAS cannot match the reparented token") + }) + t.Run("missing page returns not-found", func(t *testing.T) { require.True(t, store.IsErrNotFound(deletePageErr(s, mmmodel.NewId(), space.Id, userID))) }) @@ -1597,515 +1639,6 @@ func TestUpdatePageRejectsNilAndEmptyPatch(t *testing.T) { require.Equal(t, created.EditAt, after.EditAt, "a rejected patch must not bump EditAt") } -// --- Draft tests --- - -func newDraft(userID, spaceID, pageID, parentID string) *model.Draft { - return &model.Draft{ - UserId: userID, - SpaceId: spaceID, - PageId: pageID, - ParentId: parentID, - Title: "Test Draft", - Body: `{"type":"doc","content":[]}`, - } -} - -func TestDraft(t *testing.T) { - t.Run("upsert then get returns the stored draft", func(t *testing.T) { - s := openTestDB(t) - userID, pageID := mmmodel.NewId(), mmmodel.NewId() - space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, spaceErr) - spaceID := space.Id - - saved, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, "")) - require.NoError(t, err) - require.NotZero(t, saved.CreateAt) - - got, err := s.GetDraft(userID, pageID) - require.NoError(t, err) - require.Equal(t, pageID, got.PageId) - require.Equal(t, "Test Draft", got.Title) - require.Equal(t, spaceID, got.SpaceId) - }) - - t.Run("upsert replaces existing row and preserves CreateAt", func(t *testing.T) { - s := openTestDB(t) - userID, pageID := mmmodel.NewId(), mmmodel.NewId() - space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, spaceErr) - spaceID := space.Id - - first, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, "")) - require.NoError(t, err) - - second := newDraft(userID, spaceID, pageID, "") - second.CreateAt = first.CreateAt - second.Title = "Updated" - _, err = s.UpsertDraft(second) - require.NoError(t, err) - - got, err := s.GetDraft(userID, pageID) - require.NoError(t, err) - require.Equal(t, "Updated", got.Title) - require.Equal(t, first.CreateAt, got.CreateAt, "CreateAt preserved across upsert") - }) - - t.Run("two users can draft the same page id", func(t *testing.T) { - s := openTestDB(t) - pageID := mmmodel.NewId() - space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, spaceErr) - spaceID := space.Id - userA, userB := mmmodel.NewId(), mmmodel.NewId() - - _, err := s.UpsertDraft(newDraft(userA, spaceID, pageID, "")) - require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userB, spaceID, pageID, "")) - require.NoError(t, err) - - gotA, err := s.GetDraft(userA, pageID) - require.NoError(t, err) - require.Equal(t, userA, gotA.UserId) - gotB, err := s.GetDraft(userB, pageID) - require.NoError(t, err) - require.Equal(t, userB, gotB.UserId) - }) - - t.Run("delete makes draft not found", func(t *testing.T) { - s := openTestDB(t) - userID, pageID := mmmodel.NewId(), mmmodel.NewId() - space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, spaceErr) - spaceID := space.Id - - _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, "")) - require.NoError(t, err) - - require.NoError(t, s.DeleteDraft(userID, pageID)) - - _, err = s.GetDraft(userID, pageID) - require.True(t, store.IsErrNotFound(err)) - }) - - t.Run("delete nonexistent draft returns not-found", func(t *testing.T) { - s := openTestDB(t) - err := s.DeleteDraft(mmmodel.NewId(), mmmodel.NewId()) - require.True(t, store.IsErrNotFound(err)) - }) - - t.Run("get nonexistent draft returns not-found", func(t *testing.T) { - s := openTestDB(t) - _, err := s.GetDraft(mmmodel.NewId(), mmmodel.NewId()) - require.True(t, store.IsErrNotFound(err)) - }) - - t.Run("drafts for space lists new-page drafts most-recent-first", func(t *testing.T) { - s := openTestDB(t) - userID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - - _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), "")) - require.NoError(t, err) - second, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), "")) - require.NoError(t, err) - - drafts, err := s.GetDraftsForSpace(userID, space.Id) - require.NoError(t, err) - require.Len(t, drafts, 2) - require.Equal(t, second.PageId, drafts[0].PageId, "most-recently-updated first") - }) - - t.Run("drafts for soft-deleted space are not listed but survive for restore", func(t *testing.T) { - s := openTestDB(t) - userID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - - draft, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), "")) - require.NoError(t, err) - - require.NoError(t, s.DeleteSpace(space.Id)) - - // While the space is soft-deleted both reads are gated to nothing... - drafts, err := s.GetDraftsForSpace(userID, space.Id) - require.NoError(t, err) - require.Empty(t, drafts, "a soft-deleted space lists no drafts") - - _, err = s.GetDraft(userID, draft.PageId) - require.True(t, store.IsErrNotFound(err), "a soft-deleted space gates GetDraft too") - - // ...but the draft row is kept (not purged), so it reappears once the space is restored. - require.NoError(t, s.RestoreSpace(space.Id)) - drafts, err = s.GetDraftsForSpace(userID, space.Id) - require.NoError(t, err) - require.Len(t, drafts, 1, "restoring the space brings its drafts back") - - kept, err := s.GetDraft(userID, draft.PageId) - require.NoError(t, err, "draft is readable again after restore") - require.Equal(t, draft.PageId, kept.PageId) - }) - - t.Run("drafts for space excludes a draft whose page lives in another space", func(t *testing.T) { - s := openTestDB(t) - userID := mmmodel.NewId() - - spaceA, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - spaceB, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - - // A live page in space B. UpsertDraft refuses to attach a space-A draft to it (see the - // write-path test below), so insert the cross-space row directly to exercise the - // read-path guard against a corrupt or legacy row. - pageInB, err := s.CreatePage(newPage(spaceB.Id, spaceB.ChannelId, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - now := mmmodel.GetMillis() - _, rawErr := s.RawExecForTest( - "INSERT INTO DOCS_Draft (UserId, SpaceId, PageId, ParentId, Title, Body, FileIds, Props, CreateAt, UpdateAt) VALUES ($1, $2, $3, '', '', '', '[]', '{}', $4, $4)", - userID, spaceA.Id, pageInB.Id, now) - require.NoError(t, rawErr) - - drafts, err := s.GetDraftsForSpace(userID, spaceA.Id) - require.NoError(t, err) - require.Empty(t, drafts, "a draft whose page belongs to another space must not be listed") - }) - - t.Run("upsert rejects a draft whose page lives in another space", func(t *testing.T) { - s := openTestDB(t) - userID := mmmodel.NewId() - - spaceA, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - spaceB, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - - pageInB, err := s.CreatePage(newPage(spaceB.Id, spaceB.ChannelId, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - - _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, pageInB.Id, "")) - require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a cross-space page, got %v", err) - }) - - t.Run("drafts for space excludes drafts on soft-deleted pages", func(t *testing.T) { - s := openTestDB(t) - channelID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(channelID)) - require.NoError(t, err) - userID := mmmodel.NewId() - - // A draft editing a live page is included. - live, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userID, space.Id, live.Id, "")) - require.NoError(t, err) - - // A draft whose page is soft-deleted is excluded. - deleted, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userID, space.Id, deleted.Id, "")) - require.NoError(t, err) - require.NoError(t, deletePageErr(s, deleted.Id, deleted.SpaceId, userID)) - - drafts, err := s.GetDraftsForSpace(userID, space.Id) - require.NoError(t, err) - require.Len(t, drafts, 1) - require.Equal(t, live.Id, drafts[0].PageId) - }) - - t.Run("drafts for space excludes drafts on version snapshots", func(t *testing.T) { - s := openTestDB(t) - channelID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(channelID)) - require.NoError(t, err) - userID := mmmodel.NewId() - - // Draft a live page, then turn that page into a version snapshot (OriginalId set, - // soft-deleted) directly. UpsertDraft refuses to attach to a snapshot, so the draft is - // written while the page is still live; the read path must then exclude it: the LEFT - // JOIN matches the snapshot row, OriginalId != '' fails the live-page predicate, and - // p.Id IS NULL is false. - snap, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userID, space.Id, snap.Id, "")) - require.NoError(t, err) - _, rawErr := s.ExecBuilderForTest(s.QueryBuilderForTest(). - Update("DOCS_Page"). - Set("OriginalId", mmmodel.NewId()). - Set("DeleteAt", mmmodel.GetMillis()). - Where(sq.Eq{"Id": snap.Id})) - require.NoError(t, rawErr) - - drafts, err := s.GetDraftsForSpace(userID, space.Id) - require.NoError(t, err) - require.Empty(t, drafts, "a draft on a version snapshot must be excluded") - }) - - t.Run("upsert rejects a draft for a soft-deleted page", func(t *testing.T) { - s := openTestDB(t) - channelID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(channelID)) - require.NoError(t, err) - userID := mmmodel.NewId() - - page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - require.NoError(t, deletePageErr(s, page.Id, page.SpaceId, userID)) - - // An autosave landing after the page was deleted must not recreate a draft for it. - _, err = s.UpsertDraft(newDraft(userID, space.Id, page.Id, "")) - require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a deleted page, got %v", err) - }) - - t.Run("upsert rejects a draft in a soft-deleted space", func(t *testing.T) { - s := openTestDB(t) - space, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - require.NoError(t, s.DeleteSpace(space.Id)) - - _, err = s.UpsertDraft(newDraft(mmmodel.NewId(), space.Id, mmmodel.NewId(), "")) - require.True(t, store.IsErrNotFound(err), "expected not-found for a deleted space, got %v", err) - }) - - t.Run("upsert accepts a new-page draft under a live parent", func(t *testing.T) { - s := openTestDB(t) - channelID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(channelID)) - require.NoError(t, err) - userID := mmmodel.NewId() - - parent, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - - saved, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parent.Id)) - require.NoError(t, err) - require.Equal(t, parent.Id, saved.ParentId) - }) - - t.Run("upsert rejects a draft whose parent does not exist", func(t *testing.T) { - s := openTestDB(t) - space, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - - _, err = s.UpsertDraft(newDraft(mmmodel.NewId(), space.Id, mmmodel.NewId(), mmmodel.NewId())) - require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a missing parent, got %v", err) - }) - - t.Run("upsert rejects a draft whose parent is soft-deleted", func(t *testing.T) { - s := openTestDB(t) - channelID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(channelID)) - require.NoError(t, err) - userID := mmmodel.NewId() - - parent, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - require.NoError(t, deletePageErr(s, parent.Id, parent.SpaceId, userID)) - - _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parent.Id)) - require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a deleted parent, got %v", err) - }) - - t.Run("upsert rejects a draft whose parent lives in another space", func(t *testing.T) { - s := openTestDB(t) - userID := mmmodel.NewId() - - spaceA, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - spaceB, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - - parentInB, err := s.CreatePage(newPage(spaceB.Id, spaceB.ChannelId, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - - _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), parentInB.Id)) - require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a cross-space parent, got %v", err) - }) - - t.Run("drafts for space is scoped to the user", func(t *testing.T) { - s := openTestDB(t) - space, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - userA, userB := mmmodel.NewId(), mmmodel.NewId() - - _, err = s.UpsertDraft(newDraft(userA, space.Id, mmmodel.NewId(), "")) - require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userB, space.Id, mmmodel.NewId(), "")) - require.NoError(t, err) - - drafts, err := s.GetDraftsForSpace(userA, space.Id) - require.NoError(t, err) - require.Len(t, drafts, 1) - require.Equal(t, userA, drafts[0].UserId) - }) - - t.Run("body, file_ids and props round-trip through the database", func(t *testing.T) { - s := openTestDB(t) - userID, pageID := mmmodel.NewId(), mmmodel.NewId() - space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, spaceErr) - spaceID := space.Id - - d := newDraft(userID, spaceID, pageID, "") - d.Body = `{"type":"doc","content":[{"type":"paragraph"}]}` - d.FileIds = mmmodel.StringArray{mmmodel.NewId(), mmmodel.NewId()} - d.Props = mmmodel.StringInterface{"k": float64(1700000000123)} - _, err := s.UpsertDraft(d) - require.NoError(t, err) - - got, err := s.GetDraft(userID, pageID) - require.NoError(t, err) - require.Equal(t, d.Body, got.Body) - require.Equal(t, d.FileIds, got.FileIds, "StringArray must round-trip through the TEXT column") - require.Equal(t, float64(1700000000123), got.Props["k"], "Props must round-trip through the jsonb column") - }) - - t.Run("empty props default to an empty map on read", func(t *testing.T) { - s := openTestDB(t) - userID, pageID := mmmodel.NewId(), mmmodel.NewId() - space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, spaceErr) - spaceID := space.Id - - _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, "")) - require.NoError(t, err) - - got, err := s.GetDraft(userID, pageID) - require.NoError(t, err) - require.NotNil(t, got.Props) - require.Empty(t, got.Props) - }) - - t.Run("upsert overwrites parent id", func(t *testing.T) { - s := openTestDB(t) - userID, pageID := mmmodel.NewId(), mmmodel.NewId() - space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, spaceErr) - spaceID := space.Id - - // Parents must be live pages in the space (UpsertDraft validates ParentId liveness). - firstPage, err := s.CreatePage(newPage(spaceID, space.ChannelId, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - secondPage, err := s.CreatePage(newPage(spaceID, space.ChannelId, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - firstParent, secondParent := firstPage.Id, secondPage.Id - - _, err = s.UpsertDraft(newDraft(userID, spaceID, pageID, firstParent)) - require.NoError(t, err) - got, err := s.GetDraft(userID, pageID) - require.NoError(t, err) - require.Equal(t, firstParent, got.ParentId) - - _, err = s.UpsertDraft(newDraft(userID, spaceID, pageID, secondParent)) - require.NoError(t, err) - got, err = s.GetDraft(userID, pageID) - require.NoError(t, err) - require.Equal(t, secondParent, got.ParentId, "second upsert must overwrite ParentId") - }) - - t.Run("title-only empty body round-trips", func(t *testing.T) { - s := openTestDB(t) - userID, pageID := mmmodel.NewId(), mmmodel.NewId() - space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, spaceErr) - spaceID := space.Id - - d := newDraft(userID, spaceID, pageID, "") - d.Title = "Title Only" - d.Body = "" - _, err := s.UpsertDraft(d) - require.NoError(t, err) - - got, err := s.GetDraft(userID, pageID) - require.NoError(t, err) - require.Equal(t, "Title Only", got.Title) - require.Equal(t, "", got.Body, "empty body must round-trip as empty string") - }) - - t.Run("drafts for space excludes other spaces for the same user", func(t *testing.T) { - s := openTestDB(t) - userID := mmmodel.NewId() - spaceA, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - spaceB, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - - _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), "")) - require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), "")) - require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userID, spaceB.Id, mmmodel.NewId(), "")) - require.NoError(t, err) - - drafts, err := s.GetDraftsForSpace(userID, spaceA.Id) - require.NoError(t, err) - require.Len(t, drafts, 2) - for _, d := range drafts { - require.Equal(t, spaceA.Id, d.SpaceId) - } - }) - - t.Run("drafts for space returns empty when user has none", func(t *testing.T) { - s := openTestDB(t) - drafts, err := s.GetDraftsForSpace(mmmodel.NewId(), mmmodel.NewId()) - require.NoError(t, err) - require.Empty(t, drafts) - }) - - t.Run("store rejects invalid ids", func(t *testing.T) { - s := openTestDB(t) - valid := mmmodel.NewId() - - // Upsert runs the full model IsValid, so a malformed (non-empty) id is rejected as - // invalid input. - _, err := s.UpsertDraft(newDraft("bad", valid, valid, "")) - require.True(t, store.IsErrInvalidInput(err), "upsert with bad user id, got %v", err) - - // Get/Delete guard only against empty ids (matching the page/space store convention); - // a non-empty but unknown id falls through to the query and returns not-found. - _, err = s.GetDraft("", valid) - require.True(t, store.IsErrInvalidInput(err), "get with empty user id, got %v", err) - - err = s.DeleteDraft(valid, "") - require.True(t, store.IsErrInvalidInput(err), "delete with empty page id, got %v", err) - }) -} - -// TestDeletePageReparentsPendingDrafts verifies that deleting a page reparents the new-page -// drafts pending under it to the deleted page's parent — mirroring live-child promotion — so a -// draft never dangles under a soft-deleted parent and stays publishable. -func TestDeletePageReparentsPendingDrafts(t *testing.T) { - s := openTestDB(t) - - channelID := mmmodel.NewId() - userID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(channelID)) - require.NoError(t, err) - - grandparent, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - parent, err := s.CreatePage(newPage(space.Id, channelID, userID, grandparent.Id), testDefaultMaxDepth) - require.NoError(t, err) - - // A new-page draft (its own page not yet created) pending as a child of parent. - newPageID := mmmodel.NewId() - _, err = s.UpsertDraft(newDraft(userID, space.Id, newPageID, parent.Id)) - require.NoError(t, err) - - require.NoError(t, deletePageErr(s, parent.Id, parent.SpaceId, userID)) - - // The draft survives and is reparented to the deleted page's parent (the grandparent), - // which the invariant guarantees is live. - got, err := s.GetDraft(userID, newPageID) - require.NoError(t, err, "pending draft must survive its parent's deletion") - require.Equal(t, grandparent.Id, got.ParentId, "draft must be reparented to the deleted page's parent") - - // The reparented draft is publishable: CreatePage with its parent now succeeds. - _, err = s.CreatePage(newPage(space.Id, channelID, userID, got.ParentId), testDefaultMaxDepth) - require.NoError(t, err, "draft's reparented parent must be a valid live parent") -} - // TestCreatePageSubtreeMissingParent verifies CreatePageSubtree rejects a root whose ParentId does // not resolve to a live page in the given space, rather than inserting an orphaned subtree. func TestCreatePageSubtreeMissingParent(t *testing.T) {