disconnect clients on document deletion

This commit is contained in:
david 2026-09-05 20:22:27 +03:00
parent deec687aba
commit 7476941e1f
18 changed files with 1458 additions and 1275 deletions

View File

@ -157,7 +157,7 @@ Component build/test/qa commands are listed in the nested CLAUDE.md files.
## Cross-component contracts
- **Front door**: Caddy on host `:8080`. `/core/*` is path-stripped and proxied to core (`:8080`); `/auth-realtime/*` is path-stripped and proxied to auth-realtime (`:8081`, so `/auth-realtime/api/...` and `/auth-realtime/hocuspocus` publicly); everything else goes to the web SSR container. The frontend reaches the core API via `NUXT_PUBLIC_CORE_API_BASE_HTTP_URL` (`…:8080/core`) and auth/realtime via `NUXT_PUBLIC_AUTH_REALTIME_API_BASE_*_URL` (`…:8080/auth-realtime`).
- **Trust boundary**: core has four surfaces. `/api/...` requires a session. `/api/x/...` (core) and `/api/internal/...` (auth-realtime) have no auth and must never be exposed by the reverse proxy (the Caddyfile blocks both at the front door) — they exist for service-to-service calls (auth-realtime ↔ core). Three of core's `/api/x` routes are internal *and* session-authed: fork (`POST .../branches`), branch update (`PUT .../branches/{branchId}`) and merge (`PUT .../merge`) act for a person but are reachable only through auth-realtime, which first flushes the branch's pending Hocuspocus store so core reads what the editors show, forwards the caller's session for core to authorize, and resets the branch's connections after a protection change. `/api/apps/...` is public and sessionless: GitHub and Slack deliver webhooks and OAuth callbacks there, and every request proves itself with the provider's signature or an encrypted state parameter. `/api/mcp` is the MCP surface: OAuth 2.1 bearer tokens issued by auth-realtime (which is the authorization server via `@better-auth/mcp`), validated per request against auth-realtime's internal MCP session endpoint; the Caddyfile also routes the front-door `/.well-known/oauth-*` discovery documents and `/api/auth/*` OAuth endpoints to auth-realtime.
- **Trust boundary**: core has four surfaces. `/api/...` requires a session. `/api/x/...` (core) and `/api/internal/...` (auth-realtime) have no auth and must never be exposed by the reverse proxy (the Caddyfile blocks both at the front door) — they exist for service-to-service calls (auth-realtime ↔ core). Four of core's `/api/x` routes are internal *and* session-authed: fork (`POST .../branches`), branch update (`PUT .../branches/{branchId}`), merge (`PUT .../merge`) and branch delete (`DELETE .../branches/{branchId}`) act for a person but are reachable only through auth-realtime, which first flushes the branch's pending Hocuspocus store so core reads what the editors show, forwards the caller's session for core to authorize, and resets the branch's connections after a protection change or a delete. `/api/apps/...` is public and sessionless: GitHub and Slack deliver webhooks and OAuth callbacks there, and every request proves itself with the provider's signature or an encrypted state parameter. `/api/mcp` is the MCP surface: OAuth 2.1 bearer tokens issued by auth-realtime (which is the authorization server via `@better-auth/mcp`), validated per request against auth-realtime's internal MCP session endpoint; the Caddyfile also routes the front-door `/.well-known/oauth-*` discovery documents and `/api/auth/*` OAuth endpoints to auth-realtime.
- **Session validation**: core's auth middleware validates sessions by calling auth-realtime's `/api/auth/get-session`; auth-realtime owns the Better Auth schema.
- **Yjs invariant**: the Hocuspocus `documentName` is `"<documentId>-<branchIdentifier>"`, split on the first `-`. Never seed or merge one Y.Doc from another with `Y.applyUpdate` — use `replaceYdocContent` (`server/auth-realtime/src/ydocument.ts`). Read the document-storage section of [server/CLAUDE.md](server/CLAUDE.md) before touching branch content anywhere.
- **System writes**: a protected branch refuses every persist but core's own. Core marks the batches it originates with `edit.Client.Apply`'s `system` argument, which only a caller acting for core itself may set, auth-realtime opens the direct connection under that origin, and `onStoreDocument` marks the persist system only while every change in the window came from core. Protection itself is enforced where a write enters the document — `onAuthenticate` makes a connection to a protected branch read-only — because the persist carries whole-document state and could otherwise smuggle an edit it never authorised.

View File

@ -86,6 +86,11 @@ const CORE_INTERNAL_ENDPOINTS = [
method: "PUT",
path: "/core/api/x/documents/doc-e2e/branches/branch-e2e",
},
{
name: "a branch deletion",
method: "DELETE",
path: "/core/api/x/documents/doc-e2e/branches/branch-e2e",
},
] as const
const AUTH_REALTIME_INTERNAL_ENDPOINTS = [

View File

@ -89,7 +89,7 @@ Every organization is seeded at initialization with a Prometheus data source nam
- `onLoadDocument` / `onStoreDocument` round-trip through core's internal `/api/x/documents/{id}/branch/{branchId}` endpoints. **Do not** use `Y.applyUpdate` to seed a freshly-created doc with content from another doc — the differing `clientID`s CRDT-merge and silently duplicate content. The codebase's pattern is `replaceYdocContent` in `server/auth-realtime/src/ydocument.ts`, which deep-clones `Y.XmlElement`s (preserving non-string attrs that `XmlElement.clone()` drops). Read the comments on `onLoadDocument` in `server/auth-realtime/src/hocuspocus.ts` and on `applyMergeToOpenDocument` in `server/auth-realtime/src/routes.ts` before touching this area.
- **The main branch is identified by its `"default"` flag, not by its name.** The tree join and `FetchMainBranchContent` key on the flag; `UpdateDocumentBranch` refuses to rename the default branch so the two never disagree. Anything that creates a document's first branch — `NewDocument`, `Duplicate` — must set `Default: true`, and forks must not.
- **Maintainers accumulate; nothing removes them.** The `maintainers` field on a branch update is *who edited in that persist*, not the document's maintainer set — auth-realtime's store hook sends the current session's editors and system writes send none. `UpsertDocumentMaintainers` only ever adds, so a diff against the stored set would drop everyone not editing at that moment. Removing a maintainer needs a path of its own; there is none today.
- **Fork, branch update and merge go through auth-realtime.** The web client calls `POST /documents/:documentId/branches`, `PUT /documents/:documentId/branches/:branchId` and `PUT /documents/:documentId/merge` on auth-realtime, which flushes the affected branch's pending Hocuspocus store (both sides for a merge; the `-default` alias too for the default branch) and refuses the operation if that store fails, then proxies to core's session-authed group under `/api/x` with the caller's headers. Core reads the branch's stored row, so without the flush a fork copies stale content, a merge reads it, and a protect toggle makes the pending user store be refused. After core confirms a protection change, auth-realtime drops the socket of every client on the branch's document (`resetConnections` in `hocuspocus.ts`): read-only is decided per connection in `onAuthenticate`, so each client reconnects with the permission the branch now has. Hocuspocus's own `closeConnections` is not that: it sends a close *message* on the still-open multiplexed socket, and the provider leaves the document closed until the page reloads. A merge then **directly mutates the in-memory target-branch Y.Doc** via `replaceYdocContent` (not `applyUpdate`) and immediately persists `rawContent` back through `/api/x/.../branch/:branchId` so a server restart can't reset the clientID and duplicate content on reconnect.
- **Fork, branch update, merge and branch delete go through auth-realtime.** The web client calls `POST /documents/:documentId/branches`, `PUT /documents/:documentId/branches/:branchId`, `PUT /documents/:documentId/merge` and `DELETE /documents/:documentId/branches/:branchId` on auth-realtime, which flushes the affected branch's pending Hocuspocus store (both sides for a merge; the `-default` alias too for the default branch) and refuses the operation if that store fails, then proxies to core's session-authed group under `/api/x` with the caller's headers. Core reads the branch's stored row, so without the flush a fork copies stale content, a merge reads it, and a protect toggle makes the pending user store be refused. After core confirms a protection change, auth-realtime drops the socket of every client on the branch's document (`resetConnections` in `hocuspocus.ts`): read-only is decided per connection in `onAuthenticate`, so each client reconnects with the permission the branch now has. Hocuspocus's own `closeConnections` is not that: it sends a close *message* on the still-open multiplexed socket, and the provider leaves the document closed until the page reloads. A delete flushes nothing — there is no row left to store into — but resets the branch's connections the same way once core confirms it, since a client still on the branch would otherwise keep editing a live document of a branch that is gone, with every later store refused. A merge then **directly mutates the in-memory target-branch Y.Doc** via `replaceYdocContent` (not `applyUpdate`) and immediately persists `rawContent` back through `/api/x/.../branch/:branchId` so a server restart can't reset the clientID and duplicate content on reconnect.
## External resource lifecycle (files, hooks)

View File

@ -9,6 +9,7 @@ function stubHttp(response: HttpResponse = { status: 200, data: null }) {
get: vi.fn().mockResolvedValue(response),
post: vi.fn().mockResolvedValue(response),
put: vi.fn().mockResolvedValue(response),
delete: vi.fn().mockResolvedValue(response),
}
}
@ -343,4 +344,26 @@ describe("createCoreClient", () => {
expect(result.status).toBe(200)
})
})
describe("deleteBranch", () => {
it("deletes core's internal branch route with the caller's headers", async ({
expect,
}) => {
const http = stubHttp({ status: 204, data: null })
const headers = new AxiosHeaders()
headers.set("cookie", "auth.session=abc")
const result = await createCoreClient(
BASE_URL,
http,
).deleteBranch("doc-1", "branch-1", { headers })
expect(http.delete).toHaveBeenCalledTimes(1)
expect(http.delete).toHaveBeenCalledWith(
"http://core:8080/api/x/documents/doc-1/branches/branch-1",
{ headers },
)
expect(result.status).toBe(204)
})
})
})

View File

@ -77,6 +77,7 @@ export interface HttpClient {
data?: unknown,
config?: RequestOptions,
): Promise<HttpResponse>
delete(url: string, config?: RequestOptions): Promise<HttpResponse>
}
// every call this service makes into core. Transport only: a failed
@ -122,6 +123,11 @@ export interface CoreClient {
request: BranchRequest,
options: RequestOptions,
): Promise<HttpResponse>
deleteBranch(
documentId: string,
branchId: string,
options: RequestOptions,
): Promise<HttpResponse>
}
export function createCoreClient(
@ -220,5 +226,12 @@ export function createCoreClient(
options,
)
},
async deleteBranch(documentId, branchId, options) {
return http.delete(
`${internal}/documents/${documentId}/branches/${branchId}`,
options,
)
},
}
}

View File

@ -564,6 +564,70 @@ describe("createRoutes", () => {
})
})
describe("DELETE /documents/:documentId/branches/:branchId", () => {
it("forwards the deletion to core with the caller's headers, without a flush", async ({
expect,
}) => {
const { app, core, flushDocument } = build()
const res = await app.request(
"/documents/doc1/branches/b1",
{
method: "DELETE",
headers: { Cookie: "auth.session=abc" },
},
)
expect(res.status).toBe(204)
expect(flushDocument).toHaveBeenCalledTimes(0)
const [documentId, branchId, options] =
core.deleteBranch.mock.calls[0] ?? []
expect(documentId).toBe("doc1")
expect(branchId).toBe("b1")
expect(options?.headers?.get("cookie")).toBe(
"auth.session=abc",
)
})
it("drops the connections still on the deleted branch", async ({
expect,
}) => {
const { app, resetConnections } = build()
await app.request("/documents/doc1/branches/b1", {
method: "DELETE",
})
expect(
resetConnections.mock.calls.map(
(call) => call[0],
),
).toEqual(["doc1-b1"])
})
it("keeps the connections when core refuses the deletion", async ({
expect,
}) => {
const core = stubCore()
core.deleteBranch.mockResolvedValue({
status: 409,
data: { code: "document.last_branch" },
})
const { app, resetConnections } = build({ core })
const res = await app.request(
"/documents/doc1/branches/b1",
{ method: "DELETE" },
)
expect(res.status).toBe(409)
expect(await res.json()).toEqual({
code: "document.last_branch",
})
expect(resetConnections).toHaveBeenCalledTimes(0)
})
})
describe("PUT /documents/:documentId/merge", () => {
it("flushes the source and then the target before core merges", async ({
expect,

View File

@ -357,6 +357,40 @@ export function createRoutes({
)
})
// a delete stores nothing — there is no row left to store into — but
// the clients still on the branch hold a live document of it and would
// keep editing one that is gone, every later store refused. Once core
// confirms, each is dropped and finds no branch to reconnect to.
app.delete("/documents/:documentId/branches/:branchId", async (c) => {
const documentId = c.req.param("documentId")
const branchId = c.req.param("branchId")
const response = await calledCore(
c,
"failed to delete branch",
() =>
core.deleteBranch(documentId, branchId, {
headers: toAxiosHeaders(
c.req.raw.headers,
),
}),
)
if (response instanceof Response) {
return response
}
if (response.status === 204) {
resetConnections(`${documentId}-${branchId}`)
return c.body(null, 204)
}
return c.json(
response.data,
response.status as ContentfulStatusCode,
)
})
// a merge reads the source branch's row and rewrites the target's;
// both are flushed so neither side is behind its editors. The
// in-memory Y.Doc mutation below only runs when core responds 200,

View File

@ -64,6 +64,10 @@ export function stubCore(): StubCore {
status: 200,
data: { branchId: "branch-1" },
}),
deleteBranch: vi.fn().mockResolvedValue({
status: 204,
data: null,
}),
}
}

View File

@ -0,0 +1,667 @@
package document
import (
"context"
"errors"
"log/slog"
"net/http"
"slices"
"github.com/guregu/null/v5"
"github.com/oxynote/oxynote/server/core/internal/apps/webchange"
documentCore "github.com/oxynote/oxynote/server/core/internal/document"
"github.com/oxynote/oxynote/server/core/internal/document/hook"
"github.com/oxynote/oxynote/server/core/internal/search"
"github.com/oxynote/oxynote/server/core/internal/server/internal/auth"
"github.com/oxynote/oxynote/server/core/pkg/errutil"
"github.com/oxynote/oxynote/server/core/pkg/httpserver"
"github.com/oxynote/oxynote/server/core/pkg/logutil"
"github.com/rs/xid"
)
// UpdateDocumentBranch handles updating the name and protection status of a document branch.
func (h *Handler) UpdateDocumentBranch(w http.ResponseWriter, r *http.Request) {
session, ok := auth.RequireSession(h.log, w, r)
if !ok {
return
}
id, err := httpserver.ExtractNamedID(r, "documentId")
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
branchID, err := httpserver.ExtractNamedID(r, "branchId")
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
doc, err := h.db.FetchDocumentByBranchID(r.Context(), branchID, session.ActiveOrganizationID)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if doc.ID != id {
httpserver.RespondError(h.log, w, ErrBranchMismatch)
return
}
var ui struct {
Name null.String `json:"name"`
Protected null.Bool `json:"protected"`
}
if err := httpserver.DecodeJSON(r, &ui); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
// the main branch is found by name in the tree and content queries, and
// a rename would make the document look nameless and contentless.
if doc.Default && ui.Name.Valid && ui.Name.String != doc.BranchName {
httpserver.RespondError(h.log, w, ErrDefaultBranchRename)
return
}
ndoc := doc.ApplyBranchUpdate(ui.Name, ui.Protected, session.UserID)
var tx Tx
if err := h.db.BeginTx(r.Context(), &tx); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
defer tx.Rollback() //nolint:errcheck // error provides no meaningful info
if err := tx.UpdateDocumentBranchMetadata(r.Context(), ndoc); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
// the index carries the branch name on every entry of the branch.
if ndoc.BranchName != doc.BranchName {
if err := h.searchJobs.Enqueue(r.Context(), tx, search.BlocksDiff(doc.Search(), ndoc.Search())); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
}
if err := tx.Commit(); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if h.metadata.changeCallback != nil {
h.metadata.changeCallback(session.ActiveOrganizationID, ndoc)
}
httpserver.Respond(
h.log,
w,
ndoc,
http.StatusOK,
)
}
// FetchDocumentBranchesUnsafe handles the retrieval of all branches for a document
// without organization ownership checks. Intended for internal system use only.
func (h *Handler) FetchDocumentBranchesUnsafe(w http.ResponseWriter, r *http.Request) {
id, err := httpserver.ExtractNamedID(r, "documentId")
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
branches, err := h.db.FetchDocumentBranchesUnsafe(r.Context(), id)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
httpserver.Respond(
h.log,
w,
branches,
http.StatusOK,
)
}
// FetchDocumentBranchByIDUnsafe handles the retrieval of a document branch by its ID
// without organization ownership checks. Intended for internal system use only.
func (h *Handler) FetchDocumentBranchByIDUnsafe(w http.ResponseWriter, r *http.Request) {
branchID, err := httpserver.ExtractNamedID(r, "branchId")
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
doc, err := h.db.FetchDocumentUnsafeByBranchID(r.Context(), branchID)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
httpserver.Respond(
h.log,
w,
doc,
http.StatusOK,
)
}
// UpdateDocumentBranchByIDUnsafe handles the update of a document branch content by branch ID
// without organization ownership checks. Intended for internal system use only.
func (h *Handler) UpdateDocumentBranchByIDUnsafe(w http.ResponseWriter, r *http.Request) {
branchID, err := httpserver.ExtractNamedID(r, "branchId")
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
doc, err := h.db.FetchDocumentUnsafeByBranchID(r.Context(), branchID)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
var ui documentCore.UpdateInput
if err = httpserver.DecodeJSON(r, &ui); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
ui.Branch = doc.BranchName
ndoc, err := doc.ApplyUpdate(ui)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
var tx Tx
err = h.db.BeginTx(r.Context(), &tx)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
defer tx.Rollback() //nolint:errcheck // error provides no meaningful info
if err = tx.UpdateDocument(r.Context(), ndoc); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
maintainers, err := tx.FetchDocumentMaintainers(r.Context(), doc.ID, doc.OrganizationID)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
// the update carries the editors of one persist, so the set is only
// ever added to. Diffing it against the stored maintainers would drop
// everyone who happens not to be editing right now.
var maintainersAdded bool
for _, maintainer := range ui.Maintainers {
if !slices.Contains(maintainers, maintainer) {
maintainersAdded = true
break
}
}
if maintainersAdded {
if err = tx.UpsertDocumentMaintainers(
r.Context(),
doc.ID,
doc.OrganizationID,
ui.Maintainers,
); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
}
if err = h.searchJobs.Enqueue(
r.Context(),
tx,
search.BlocksDiff(doc.Search(), ndoc.Search()),
); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
err = tx.Commit()
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if h.metadata.changeCallback != nil {
h.metadata.changeCallback(doc.OrganizationID, ndoc)
}
if maintainersAdded && h.maintainers.changeCallback != nil {
h.maintainers.changeCallback(doc.OrganizationID, doc.ID)
}
httpserver.Respond(
h.log,
w,
ndoc,
http.StatusOK,
)
}
// MergeBranches merges the content of a source branch into a target branch.
// The target branch's hooks are detached for the hook manager to tear down
// and replaced with copies from the source.
// Target branch comments are cleared. Source branch reviewer approvals are promoted.
func (h *Handler) MergeBranches(w http.ResponseWriter, r *http.Request) {
session, ok := auth.RequireSession(h.log, w, r)
if !ok {
return
}
id, err := httpserver.ExtractNamedID(r, "documentId")
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
var inp struct {
FromBranchID xid.ID `json:"fromBranchId"`
ToBranchID xid.ID `json:"toBranchId"`
}
if err = httpserver.DecodeJSON(r, &inp); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
// a self-merge would soft-delete the branch's hooks and comments and
// then copy the hooks back from the same, now hook-less, branch —
// permanently destroying both.
if inp.FromBranchID == inp.ToBranchID {
httpserver.RespondError(h.log, w, errutil.New(http.StatusBadRequest, "document.branch_self_merge", "cannot merge a branch into itself"))
return
}
fromDoc, err := h.db.FetchDocumentByBranchID(r.Context(), inp.FromBranchID, session.ActiveOrganizationID)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
toDoc, err := h.db.FetchDocumentByBranchID(r.Context(), inp.ToBranchID, session.ActiveOrganizationID)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if fromDoc.ID != id || toDoc.ID != id {
httpserver.RespondError(h.log, w, ErrBranchMismatch)
return
}
ndoc := toDoc.MergeBranch(fromDoc.Branch, session.UserID)
var tx Tx
if err := h.db.BeginTx(r.Context(), &tx); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
defer tx.Rollback() //nolint:errcheck // error provides no meaningful info
if err := tx.DetachDocumentHooksByBranchID(r.Context(), toDoc.BranchID, session.ActiveOrganizationID); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if err := tx.DeleteDocumentCommentsByBranchID(r.Context(), toDoc.BranchID, session.ActiveOrganizationID); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if err := tx.UpdateDocument(r.Context(), ndoc); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if err := tx.ReplaceBranchTags(r.Context(), session.ActiveOrganizationID, fromDoc.BranchID, toDoc.BranchID); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if err := h.searchJobs.Enqueue(r.Context(), tx, search.BlocksDiff(toDoc.Search(), ndoc.Search())); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if err := tx.PromoteBranchApprovals(r.Context(), fromDoc.BranchID, toDoc.BranchID, session.ActiveOrganizationID); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if err := tx.Commit(); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
h.copyHooksToBranch(
r.Context(),
fromDoc.BranchID,
toDoc.BranchID,
toDoc.ID,
session.ActiveOrganizationID,
nil,
)
if h.metadata.changeCallback != nil {
h.metadata.changeCallback(session.ActiveOrganizationID, ndoc)
}
if h.reviewers.changeCallback != nil {
h.reviewers.changeCallback(session.ActiveOrganizationID, toDoc.ID)
}
httpserver.Respond(
h.log,
w,
ndoc,
http.StatusOK,
)
}
// FetchDocumentBranches handles the retrieval of all branches for a document.
func (h *Handler) FetchDocumentBranches(w http.ResponseWriter, r *http.Request) {
session, ok := auth.RequireSession(h.log, w, r)
if !ok {
return
}
docID, err := httpserver.ExtractNamedID(r, "documentId")
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
branches, err := h.db.FetchDocumentBranches(r.Context(), docID, session.ActiveOrganizationID)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
httpserver.Respond(
h.log,
w,
branches,
http.StatusOK,
)
}
// CreateDocumentBranch handles the creation of a new branch forked from an existing source branch.
// Hooks from the source branch are copied to the new branch.
func (h *Handler) CreateDocumentBranch(w http.ResponseWriter, r *http.Request) {
session, ok := auth.RequireSession(h.log, w, r)
if !ok {
return
}
id, err := httpserver.ExtractNamedID(r, "documentId")
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
var inp struct {
Branch string `json:"branch"`
SourceBranchID xid.ID `json:"sourceBranchId"`
}
if err = httpserver.DecodeJSON(r, &inp); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if inp.Branch == "" {
httpserver.RespondError(h.log, w, httpserver.ErrInvalidForm)
return
}
sourceDoc, err := h.db.FetchDocumentByBranchID(r.Context(), inp.SourceBranchID, session.ActiveOrganizationID)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if sourceDoc.ID != id {
httpserver.RespondError(h.log, w, ErrBranchMismatch)
return
}
newDoc := sourceDoc.Fork(inp.Branch, session.UserID)
var tx Tx
if err = h.db.BeginTx(r.Context(), &tx); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
defer tx.Rollback() //nolint:errcheck // error provides no meaningful info
if err = tx.InsertDocumentBranch(r.Context(), newDoc); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if err := tx.CopyBranchTags(r.Context(), session.ActiveOrganizationID, sourceDoc.BranchID, newDoc.BranchID); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if err := h.searchJobs.Enqueue(r.Context(), tx, search.BlocksDiff(nil, newDoc.Search())); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if err := tx.Commit(); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
h.copyHooksToBranch(
r.Context(),
sourceDoc.BranchID,
newDoc.BranchID,
sourceDoc.ID,
session.ActiveOrganizationID,
nil,
)
httpserver.Respond(
h.log,
w,
newDoc,
http.StatusCreated,
)
}
// DeleteDocumentBranch handles the deletion of a specific document branch by ID.
// Returns 409 if it is the last remaining branch for the document.
func (h *Handler) DeleteDocumentBranch(w http.ResponseWriter, r *http.Request) {
session, ok := auth.RequireSession(h.log, w, r)
if !ok {
return
}
id, err := httpserver.ExtractNamedID(r, "documentId")
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
branchID, err := httpserver.ExtractNamedID(r, "branchId")
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
branchDoc, err := h.db.FetchDocumentByBranchID(r.Context(), branchID, session.ActiveOrganizationID)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if branchDoc.ID != id {
httpserver.RespondError(h.log, w, ErrBranchMismatch)
return
}
if branchDoc.Default {
httpserver.RespondError(h.log, w, errutil.New(http.StatusConflict, "document.default_branch", "cannot delete the default branch"))
return
}
count, err := h.db.CountDocumentBranches(r.Context(), branchDoc.ID, session.ActiveOrganizationID)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if count <= 1 {
httpserver.RespondError(h.log, w, errutil.New(http.StatusConflict, "document.last_branch", "cannot delete the last branch"))
return
}
var tx Tx
if err := h.db.BeginTx(r.Context(), &tx); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
defer tx.Rollback() //nolint:errcheck // error provides no meaningful info
if err := tx.DeleteDocumentBranchByID(r.Context(), branchID, session.ActiveOrganizationID); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if err := h.searchJobs.Enqueue(r.Context(), tx, search.BlocksDifference{
RemovedBranches: []search.BranchRemoval{{DocumentID: branchDoc.ID, BranchID: branchID}},
}); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if err := tx.Commit(); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
httpserver.Respond(
h.log,
w,
nil,
http.StatusNoContent,
)
}
// copyHooksToBranch fetches all hooks from fromBranchID and re-creates them on
// toBranchID with fresh state, once the target is committed. Creating a hook
// creates its external resource as a side effect, so the copy cannot run
// inside the caller's transaction: a rollback cannot take a changedetection.io
// watcher back, and the row that would have pointed at it is gone. A failed
// insert tears the just-created resource down again for the same reason. A
// failure stops the copy and leaves the target standing with fewer hooks than
// its source, which the critical log reports.
//
// A branch whose content was duplicated carries fresh block uids, so the
// caller passes the old-to-new uid map and a hook anchored to a block is
// re-anchored through it; a hook whose block the map does not name has
// nothing to point at on the target and is dropped. A nil map keeps every
// block id as it is, which is right for a fork or a merge.
func (h *Handler) copyHooksToBranch(
ctx context.Context,
fromBranchID, toBranchID, documentID xid.ID,
organizationID string,
uids map[string]string,
) {
hooks, err := h.db.FetchDocumentHooksByBranchID(ctx, fromBranchID, organizationID)
if err != nil {
logutil.Critical(h.log, err).Error(
"cannot fetch the hooks to copy",
slog.String("branch_id", fromBranchID.String()),
)
return
}
inp := hook.NewInput(organizationID, h.githubMan, h.webchangeClient)
for _, hk := range hooks {
blockID := hk.BlockID
if uids != nil && blockID.Valid {
uid, ok := uids[blockID.String]
if !ok {
continue
}
blockID = null.StringFrom(uid)
}
newHk, err := hook.NewHook(ctx, hook.CreateInput{
Type: hk.Type,
BranchID: toBranchID,
BlockID: blockID,
Settings: hk.Settings,
}, documentID, toBranchID, organizationID, inp)
if err != nil {
// a hook whose integration is not configured cannot get its
// external resource; dropping it from the copy beats failing
// the whole fork or merge over it.
if errors.Is(err, webchange.ErrNotConfigured) {
h.log.With("hook_id", hk.ID).
Warn("skipping hook copy: its integration is not configured")
continue
}
logutil.Critical(h.log, err).Error(
"cannot re-create the hook on the target branch",
slog.String("hook_id", hk.ID.String()),
slog.String("branch_id", toBranchID.String()),
)
return
}
if err := h.db.InsertDocumentHook(ctx, *newHk); err != nil {
if derr := newHk.Delete(ctx, inp); derr != nil {
logutil.Critical(h.log, derr).Error(
"cannot delete the hook external resource after a failed insert",
slog.String("hook_id", newHk.ID.String()),
)
}
logutil.Critical(h.log, err).Error(
"cannot insert the copied hook",
slog.String("hook_id", newHk.ID.String()),
slog.String("branch_id", toBranchID.String()),
)
return
}
}
}

View File

@ -3,7 +3,6 @@ package document
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@ -11,7 +10,6 @@ import (
"github.com/guregu/null/v5"
documentCore "github.com/oxynote/oxynote/server/core/internal/document"
"github.com/oxynote/oxynote/server/core/internal/document/file"
hookCore "github.com/oxynote/oxynote/server/core/internal/document/hook"
"github.com/oxynote/oxynote/server/core/internal/document/hook/processor"
"github.com/oxynote/oxynote/server/core/internal/search"
@ -416,8 +414,8 @@ func Test_Handler_MergeBranches(t *testing.T) {
},
Tx: &TxMock{},
Body: validBody,
RespCode: http.StatusBadRequest,
RespJSON: `{"code":"document.branch_mismatch","message":"branches must belong to the same document"}`,
RespCode: http.StatusNotFound,
RespJSON: `{"code":"document.branch_mismatch","message":"branch does not belong to the document"}`,
},
"Branches of another document": {
DB: &DBMock{FetchDocumentByBranchIDFunc: fetchOtherDocByBranch},
@ -605,492 +603,6 @@ func Test_Handler_MergeBranches(t *testing.T) {
}
}
func Test_Handler_DeleteDocument(t *testing.T) {
fetchStored := func(context.Context, xid.ID, string, string) (*documentCore.Document, error) {
return storedDoc(), nil
}
cc := map[string]struct {
DB *DBMock
Tx *TxMock
BeginErr error
NoSession bool
OmitDoc bool
RespCode int
Committed int
TreeCbs int
}{
"No session in context": {
DB: &DBMock{},
Tx: &TxMock{},
NoSession: true,
RespCode: http.StatusUnauthorized,
},
"Missing document ID parameter": {
DB: &DBMock{},
Tx: &TxMock{},
OmitDoc: true,
RespCode: http.StatusNotFound,
},
"Document fetch error": {
DB: &DBMock{
FetchDocumentFunc: func(context.Context, xid.ID, string, string) (*documentCore.Document, error) {
return nil, errors.New("boom")
},
},
Tx: &TxMock{},
RespCode: http.StatusInternalServerError,
},
"Hook fetch error": {
DB: &DBMock{
FetchDocumentFunc: fetchStored,
FetchDocumentHooksByDocumentIDFunc: func(context.Context, xid.ID, string) ([]hookCore.Hook, error) {
return nil, errors.New("boom")
},
},
Tx: &TxMock{},
RespCode: http.StatusInternalServerError,
},
"Hook cleanup error": {
DB: &DBMock{
FetchDocumentFunc: fetchStored,
FetchDocumentHooksByDocumentIDFunc: func(context.Context, xid.ID, string) ([]hookCore.Hook, error) {
return []hookCore.Hook{storedHook("bogus")}, nil
},
},
Tx: &TxMock{},
RespCode: http.StatusInternalServerError,
},
"Transaction start error": {
DB: &DBMock{FetchDocumentFunc: fetchStored},
Tx: &TxMock{},
BeginErr: errors.New("boom"),
RespCode: http.StatusInternalServerError,
},
"Document deletion error": {
DB: &DBMock{FetchDocumentFunc: fetchStored},
Tx: &TxMock{
DeleteDocumentFunc: func(context.Context, xid.ID, string) ([]xid.ID, error) {
return nil, errors.New("boom")
},
},
RespCode: http.StatusInternalServerError,
},
"Search removal enqueue error": {
DB: &DBMock{FetchDocumentFunc: fetchStored},
Tx: &TxMock{
DeleteDocumentFunc: func(context.Context, xid.ID, string) ([]xid.ID, error) {
return []xid.ID{_documentID}, nil
},
InsertDocumentSearchJobFunc: func(context.Context, search.BlocksDifference) error {
return errors.New("boom")
},
},
RespCode: http.StatusInternalServerError,
},
"Commit error": {
DB: &DBMock{FetchDocumentFunc: fetchStored},
Tx: &TxMock{
CommitFunc: func() error {
return errors.New("boom")
},
},
RespCode: http.StatusInternalServerError,
Committed: 1,
},
"Successful deletion with hook cleanup": {
DB: &DBMock{
FetchDocumentFunc: fetchStored,
FetchDocumentHooksByDocumentIDFunc: func(context.Context, xid.ID, string) ([]hookCore.Hook, error) {
return []hookCore.Hook{storedHook(hookCore.TypeScheduledReminder)}, nil
},
},
Tx: &TxMock{
DeleteDocumentFunc: func(_ context.Context, id xid.ID, _ string) ([]xid.ID, error) {
return []xid.ID{id}, nil
},
},
RespCode: http.StatusOK,
Committed: 1,
TreeCbs: 1,
},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
hdl, cnt := newTestHandler(withTx(c.DB, c.Tx, c.BeginErr), &fakePublisher{})
rec := httptest.NewRecorder()
hdl.DeleteDocument(rec, newRequest(http.MethodDelete, "", c.NoSession, c.OmitDoc, true))
assert.Equal(t, c.RespCode, rec.Code)
assert.Len(t, c.Tx.CommitCalls(), c.Committed)
assert.Equal(t, c.TreeCbs, cnt.tree)
if c.RespCode == http.StatusOK {
require.Len(t, c.Tx.DeleteDocumentCalls(), 1)
assert.Equal(t, _documentID, c.Tx.DeleteDocumentCalls()[0].ID)
// the removal of the returned subtree ids rides the same
// transaction as the delete.
ff := c.Tx.InsertDocumentSearchJobCalls()
require.Len(t, ff, 1)
assert.Equal(t, []xid.ID{_documentID}, ff[0].Diff.RemovedDocuments)
}
})
}
}
func Test_Handler_DuplicateDocument(t *testing.T) {
fetchStored := func(context.Context, xid.ID, string, string) (*documentCore.Document, error) {
return storedDoc(), nil
}
// insertAwareTx returns the freshly inserted duplicate in the parent
// subtree so the sort-index swap can find it.
insertAwareTx := func() *TxMock {
tx := &TxMock{}
tx.FetchDocumentTreeByDocumentParentIDFunc = func(context.Context, null.Value[xid.ID], string) (documentCore.Summaries, error) {
return documentCore.Summaries{{ID: tx.InsertDocumentCalls()[0].Doc.ID}}, nil
}
return tx
}
// fetchStoredWithImage serves a document whose content holds an image
// served by that same document, which is what duplication has to copy.
fetchStoredWithImage := func(context.Context, xid.ID, string, string) (*documentCore.Document, error) {
doc := storedDoc()
doc.Content = documentCore.RootBlock{
Type: documentCore.BlockNodeDoc,
Content: []documentCore.Block{
{
Type: documentCore.BlockNodeImageBlock,
Attrs: documentCore.Attributes{
"uid": "img1",
"src": "https://app.test/core" + fmt.Sprintf(documentCore.FilePathFormat, _documentID, "img1"),
},
},
},
}
return doc, nil
}
// imageDB serves the document with the image plus the file row that
// image refers to, which is what the copy is made from.
imageDB := func() *DBMock {
return &DBMock{
FetchDocumentFunc: fetchStoredWithImage,
FetchDocumentFileFunc: func(_ context.Context, id, organizationID string) (*file.File, error) {
f := file.NewFile(id, file.LocationDocument, "key", _documentID, organizationID)
return &f, nil
},
}
}
cc := map[string]struct {
DB *DBMock
Tx *TxMock
Storer *StorerMock
BeginErr error
NoSession bool
OmitDoc bool
RespCode int
Committed int
TreeCbs int
Copies int
CopiedHooks int
}{
"No session in context": {
DB: &DBMock{},
Tx: &TxMock{},
NoSession: true,
RespCode: http.StatusUnauthorized,
},
"Missing document ID parameter": {
DB: &DBMock{},
Tx: &TxMock{},
OmitDoc: true,
RespCode: http.StatusNotFound,
},
"Document fetch error": {
DB: &DBMock{
FetchDocumentFunc: func(context.Context, xid.ID, string, string) (*documentCore.Document, error) {
return nil, errors.New("boom")
},
},
Tx: &TxMock{},
RespCode: http.StatusInternalServerError,
},
"Transaction start error": {
DB: &DBMock{FetchDocumentFunc: fetchStored},
Tx: &TxMock{},
BeginErr: errors.New("boom"),
RespCode: http.StatusInternalServerError,
},
"Duplicate insertion error": {
DB: &DBMock{FetchDocumentFunc: fetchStored},
Tx: &TxMock{
InsertDocumentFunc: func(context.Context, documentCore.Document) error {
return errors.New("boom")
},
},
RespCode: http.StatusInternalServerError,
},
"Tag copy error": {
DB: &DBMock{FetchDocumentFunc: fetchStored},
Tx: &TxMock{
CopyBranchTagsFunc: func(context.Context, string, xid.ID, xid.ID) error {
return errors.New("boom")
},
},
RespCode: http.StatusInternalServerError,
},
"Sort index swap error": {
DB: &DBMock{FetchDocumentFunc: fetchStored},
Tx: &TxMock{},
RespCode: http.StatusBadRequest,
},
"Commit error": {
DB: &DBMock{FetchDocumentFunc: fetchStored},
Tx: func() *TxMock {
tx := insertAwareTx()
tx.CommitFunc = func() error {
return errors.New("boom")
}
return tx
}(),
RespCode: http.StatusInternalServerError,
Committed: 1,
},
"Successful duplication": {
DB: &DBMock{FetchDocumentFunc: fetchStored},
Tx: insertAwareTx(),
RespCode: http.StatusCreated,
Committed: 1,
TreeCbs: 1,
},
"Files are copied along with the document": {
DB: imageDB(),
Tx: insertAwareTx(),
Storer: &StorerMock{},
RespCode: http.StatusCreated,
Committed: 1,
TreeCbs: 1,
Copies: 1,
},
"Failing file copy still yields the duplicate": {
DB: imageDB(),
Tx: insertAwareTx(),
Storer: &StorerMock{
CopyFunc: func(context.Context, string, string, string, string) error {
return errors.New("boom")
},
},
RespCode: http.StatusCreated,
Committed: 1,
TreeCbs: 1,
Copies: 1,
},
"Hooks are copied along with the document": {
DB: func() *DBMock {
blockHook := storedHook(hookCore.TypeScheduledReminder)
blockHook.BlockID = null.StringFrom("img1")
db := imageDB()
db.FetchDocumentHooksByBranchIDFunc = func(context.Context, xid.ID, string) ([]hookCore.Hook, error) {
return []hookCore.Hook{blockHook}, nil
}
return db
}(),
Tx: insertAwareTx(),
Storer: &StorerMock{},
RespCode: http.StatusCreated,
Committed: 1,
TreeCbs: 1,
Copies: 1,
CopiedHooks: 1,
},
"Failing hook copy still yields the duplicate": {
DB: func() *DBMock {
db := &DBMock{FetchDocumentFunc: fetchStored}
db.FetchDocumentHooksByBranchIDFunc = func(context.Context, xid.ID, string) ([]hookCore.Hook, error) {
return nil, errors.New("boom")
}
return db
}(),
Tx: insertAwareTx(),
RespCode: http.StatusCreated,
Committed: 1,
TreeCbs: 1,
},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
hdl, cnt := newTestHandler(withTx(c.DB, c.Tx, c.BeginErr), &fakePublisher{})
if c.Storer != nil {
hdl.storer = c.Storer
}
rec := httptest.NewRecorder()
hdl.DuplicateDocument(rec, newRequest(http.MethodPost, "", c.NoSession, c.OmitDoc, true))
assert.Equal(t, c.RespCode, rec.Code)
assert.Len(t, c.Tx.CommitCalls(), c.Committed)
assert.Equal(t, c.TreeCbs, cnt.tree)
if c.Storer != nil {
assert.Len(t, c.Storer.CopyCalls(), c.Copies)
assert.Len(t, c.DB.InsertDocumentFileCalls(), c.Copies)
}
if c.RespCode == http.StatusCreated {
// the duplicate is a new document owned by the caller.
require.Len(t, c.Tx.InsertDocumentCalls(), 1)
dupl := c.Tx.InsertDocumentCalls()[0].Doc
assert.NotEqual(t, _documentID, dupl.ID)
assert.Equal(t, []string{"u1"}, c.Tx.UpsertDocumentMaintainersCalls()[0].MaintainerIDs)
// the copy's default branch takes the source's tags before
// the commit.
require.Len(t, c.Tx.CopyBranchTagsCalls(), 1)
assert.Equal(t, _branchID, c.Tx.CopyBranchTagsCalls()[0].FromBranchID)
assert.Equal(t, dupl.BranchID, c.Tx.CopyBranchTagsCalls()[0].ToBranchID)
// the hooks are copied once the commit is through, since
// creating one creates its watcher.
assert.Empty(t, c.Tx.InsertDocumentHookCalls())
require.Len(t, c.DB.InsertDocumentHookCalls(), c.CopiedHooks)
// a hook anchored to a block follows the block's regenerated
// uid rather than pointing at the source's.
for _, call := range c.DB.InsertDocumentHookCalls() {
assert.Equal(t, null.ValueFrom(dupl.BranchID), call.Hk.BranchID)
assert.NotEqual(t, null.StringFrom("img1"), call.Hk.BlockID)
assert.True(t, call.Hk.BlockID.Valid)
}
}
})
}
}
func Test_Handler_copyDocumentFiles(t *testing.T) {
toDocumentID := xid.New()
fileDB := func() *DBMock {
return &DBMock{
FetchDocumentFileFunc: func(_ context.Context, id, organizationID string) (*file.File, error) {
f := file.NewFile(id, file.LocationComment, "key", _documentID, organizationID)
return &f, nil
},
}
}
cc := map[string]struct {
DB *DBMock
Storer *StorerMock
Files map[string]string
Inserts int
Copies int
}{
"Nothing to copy": {
DB: fileDB(),
Storer: &StorerMock{},
Files: map[string]string{},
},
"File row is missing": {
DB: &DBMock{
FetchDocumentFileFunc: func(context.Context, string, string) (*file.File, error) {
return nil, errors.New("boom")
},
},
Storer: &StorerMock{},
Files: map[string]string{"old-1": "new-1"},
},
"Error returned by db.InsertDocumentFile": {
DB: func() *DBMock {
db := fileDB()
db.InsertDocumentFileFunc = func(context.Context, file.File) error {
return errors.New("boom")
}
return db
}(),
Storer: &StorerMock{},
Files: map[string]string{"old-1": "new-1"},
Inserts: 1,
},
"Error returned by storer.Copy": {
DB: fileDB(),
Storer: &StorerMock{
CopyFunc: func(context.Context, string, string, string, string) error {
return errors.New("boom")
},
},
Files: map[string]string{"old-1": "new-1"},
Inserts: 1,
Copies: 1,
},
"Successful copy": {
DB: fileDB(),
Storer: &StorerMock{},
Files: map[string]string{"old-1": "new-1"},
Inserts: 1,
Copies: 1,
},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
hdl, _ := newTestHandler(c.DB, &fakePublisher{})
hdl.storer = c.Storer
hdl.copyDocumentFiles(context.Background(), c.Files, _documentID, toDocumentID, "org1")
ff := c.DB.InsertDocumentFileCalls()
require.Len(t, ff, c.Inserts)
cf := c.Storer.CopyCalls()
require.Len(t, cf, c.Copies)
if c.Inserts == 0 {
return
}
// the copy owns its row, keyed under the new document.
assert.Equal(t, "new-1", ff[0].F.ID)
assert.Equal(t, file.LocationComment, ff[0].F.Location)
assert.Equal(t, file.Key("org1", toDocumentID, "new-1"), ff[0].F.StorageKey)
assert.Equal(t, toDocumentID, ff[0].F.DocumentID.V)
if c.Copies == 0 {
return
}
assert.Equal(t, file.Folder("org1", _documentID), cf[0].SrcFolder)
assert.Equal(t, "old-1", cf[0].SrcID)
assert.Equal(t, file.Folder("org1", toDocumentID), cf[0].DstFolder)
assert.Equal(t, "new-1", cf[0].DstID)
})
}
}
func Test_Handler_FetchDocumentBranches(t *testing.T) {
cc := map[string]struct {
DB *DBMock
@ -1546,8 +1058,7 @@ func Test_Handler_copyHooksToBranch(t *testing.T) {
hdl, _ := newTestHandler(db, &fakePublisher{})
err := hdl.copyHooksToBranch(context.Background(), _branchID2, _branchID, _documentID, "org1", nil)
require.NoError(t, err)
hdl.copyHooksToBranch(context.Background(), _branchID2, _branchID, _documentID, "org1", nil)
ff := db.InsertDocumentHookCalls()
require.Len(t, ff, 1)
@ -1575,7 +1086,7 @@ func Test_Handler_copyHooksToBranch(t *testing.T) {
hdl, _ := newTestHandler(db, &fakePublisher{})
err := hdl.copyHooksToBranch(
hdl.copyHooksToBranch(
context.Background(),
_branchID2,
_branchID,
@ -1583,7 +1094,6 @@ func Test_Handler_copyHooksToBranch(t *testing.T) {
"org1",
map[string]string{"old-uid": "new-uid"},
)
require.NoError(t, err)
ff := db.InsertDocumentHookCalls()
require.Len(t, ff, 2)
@ -1607,10 +1117,176 @@ func Test_Handler_copyHooksToBranch(t *testing.T) {
hdl, _ := newTestHandler(db, &fakePublisher{})
err := hdl.copyHooksToBranch(context.Background(), _branchID2, _branchID, _documentID, "org1", nil)
require.Error(t, err)
hdl.copyHooksToBranch(context.Background(), _branchID2, _branchID, _documentID, "org1", nil)
// the failure stops the copy rather than working through the rest of
// the branch's hooks and creating a watcher for each.
require.Len(t, db.InsertDocumentHookCalls(), 1)
}
func Test_Handler_UpdateDocumentBranch(t *testing.T) {
validBody := `{"name":"Renamed","protected":true}`
cc := map[string]struct {
DB *DBMock
Tx *TxMock
NoSession bool
OmitDoc bool
Body string
WantName string
RespCode int
Updated int
Metadata int
Jobs int
}{
"No session in context": {
DB: &DBMock{},
NoSession: true,
Body: validBody,
RespCode: http.StatusUnauthorized,
},
"Missing document ID parameter": {
DB: &DBMock{},
OmitDoc: true,
Body: validBody,
RespCode: http.StatusNotFound,
},
"Branch document fetch error": {
DB: &DBMock{
FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*documentCore.Document, error) {
return nil, errors.New("boom")
},
},
Body: validBody,
RespCode: http.StatusInternalServerError,
},
"Branch of another document": {
DB: &DBMock{
FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*documentCore.Document, error) {
doc := branchDoc(_branchID)
doc.ID = xid.New()
return doc, nil
},
},
Body: validBody,
RespCode: http.StatusNotFound,
},
"Invalid JSON body": {
DB: &DBMock{
FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*documentCore.Document, error) {
return storedDoc(), nil
},
},
Body: "{",
RespCode: http.StatusBadRequest,
},
"Metadata update error": {
DB: &DBMock{
FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*documentCore.Document, error) {
return branchDoc(_branchID), nil
},
},
Tx: &TxMock{
UpdateDocumentBranchMetadataFunc: func(context.Context, documentCore.Document) error {
return errors.New("boom")
},
},
Body: validBody,
RespCode: http.StatusInternalServerError,
Updated: 1,
},
"Search job insert error": {
DB: &DBMock{
FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*documentCore.Document, error) {
return branchDoc(_branchID), nil
},
},
Tx: &TxMock{
InsertDocumentSearchJobFunc: func(context.Context, search.BlocksDifference) error {
return errors.New("boom")
},
},
Body: validBody,
RespCode: http.StatusInternalServerError,
Updated: 1,
Jobs: 1,
},
// the main branch is found by its flag in the tree and content
// queries, but its name is what the user sees; renaming it would
// leave the document looking nameless.
"Renaming the default branch is rejected": {
DB: &DBMock{
FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*documentCore.Document, error) {
return storedDoc(), nil
},
},
Body: validBody,
RespCode: http.StatusBadRequest,
},
"Protecting the default branch is allowed": {
DB: &DBMock{
FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*documentCore.Document, error) {
return storedDoc(), nil
},
},
Body: `{"protected":true}`,
WantName: documentCore.DefaultBranch,
RespCode: http.StatusOK,
Updated: 1,
Metadata: 1,
},
"Successful update": {
DB: &DBMock{
FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*documentCore.Document, error) {
return branchDoc(_branchID), nil
},
},
Body: validBody,
WantName: "Renamed",
RespCode: http.StatusOK,
Updated: 1,
Metadata: 1,
Jobs: 1,
},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
tx := c.Tx
if tx == nil {
tx = &TxMock{}
}
hdl, cnt := newTestHandler(withTx(c.DB, tx, nil), &fakePublisher{})
rec := httptest.NewRecorder()
hdl.UpdateDocumentBranch(rec, newRequest(http.MethodPut, c.Body, c.NoSession, c.OmitDoc, false))
assert.Equal(t, c.RespCode, rec.Code)
assert.Len(t, tx.UpdateDocumentBranchMetadataCalls(), c.Updated)
assert.Len(t, tx.InsertDocumentSearchJobCalls(), c.Jobs)
assert.Equal(t, c.Metadata, cnt.metadata)
if c.RespCode == http.StatusOK {
ff := tx.UpdateDocumentBranchMetadataCalls()
require.NotEmpty(t, ff)
assert.Equal(t, c.WantName, ff[0].Doc.BranchName)
assert.True(t, ff[0].Doc.Protected)
assert.Len(t, tx.CommitCalls(), 1)
}
// a rename rewrites every entry of the branch, since each one
// carries the branch name.
if c.Jobs == 1 && c.RespCode == http.StatusOK {
diff := tx.InsertDocumentSearchJobCalls()[0].Diff
require.Len(t, diff.Updated, 1)
assert.Equal(t, _branchID.String()+"-docname", diff.Updated[0].ID)
assert.Equal(t, "Renamed", diff.Updated[0].BranchName)
}
})
}
}

View File

@ -6,7 +6,6 @@ import (
"log/slog"
"net/http"
"net/url"
"slices"
"github.com/guregu/null/v5"
"github.com/oxynote/oxynote/server/core/internal/apps/github"
@ -568,246 +567,6 @@ func (h *Handler) CreateDocument(w http.ResponseWriter, r *http.Request) {
)
}
// UpdateDocumentBranch handles updating the name and protection status of a document branch.
func (h *Handler) UpdateDocumentBranch(w http.ResponseWriter, r *http.Request) {
session, ok := auth.RequireSession(h.log, w, r)
if !ok {
return
}
id, err := httpserver.ExtractNamedID(r, "documentId")
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
branchID, err := httpserver.ExtractNamedID(r, "branchId")
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
doc, err := h.db.FetchDocumentByBranchID(r.Context(), branchID, session.ActiveOrganizationID)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if doc.ID != id {
httpserver.RespondError(h.log, w, ErrBranchMismatch)
return
}
var ui struct {
Name null.String `json:"name"`
Protected null.Bool `json:"protected"`
}
if err := httpserver.DecodeJSON(r, &ui); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
// the main branch is found by name in the tree and content queries, and
// a rename would make the document look nameless and contentless.
if doc.Default && ui.Name.Valid && ui.Name.String != doc.BranchName {
httpserver.RespondError(h.log, w, ErrDefaultBranchRename)
return
}
ndoc := doc.ApplyBranchUpdate(ui.Name, ui.Protected, session.UserID)
var tx Tx
if err := h.db.BeginTx(r.Context(), &tx); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
defer tx.Rollback() //nolint:errcheck // error provides no meaningful info
if err := tx.UpdateDocumentBranchMetadata(r.Context(), ndoc); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
// the index carries the branch name on every entry of the branch.
if ndoc.BranchName != doc.BranchName {
if err := h.searchJobs.Enqueue(r.Context(), tx, search.BlocksDiff(doc.Search(), ndoc.Search())); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
}
if err := tx.Commit(); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if h.metadata.changeCallback != nil {
h.metadata.changeCallback(session.ActiveOrganizationID, ndoc)
}
httpserver.Respond(
h.log,
w,
ndoc,
http.StatusOK,
)
}
// FetchDocumentBranchesUnsafe handles the retrieval of all branches for a document
// without organization ownership checks. Intended for internal system use only.
func (h *Handler) FetchDocumentBranchesUnsafe(w http.ResponseWriter, r *http.Request) {
id, err := httpserver.ExtractNamedID(r, "documentId")
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
branches, err := h.db.FetchDocumentBranchesUnsafe(r.Context(), id)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
httpserver.Respond(
h.log,
w,
branches,
http.StatusOK,
)
}
// FetchDocumentBranchByIDUnsafe handles the retrieval of a document branch by its ID
// without organization ownership checks. Intended for internal system use only.
func (h *Handler) FetchDocumentBranchByIDUnsafe(w http.ResponseWriter, r *http.Request) {
branchID, err := httpserver.ExtractNamedID(r, "branchId")
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
doc, err := h.db.FetchDocumentUnsafeByBranchID(r.Context(), branchID)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
httpserver.Respond(
h.log,
w,
doc,
http.StatusOK,
)
}
// UpdateDocumentBranchByIDUnsafe handles the update of a document branch content by branch ID
// without organization ownership checks. Intended for internal system use only.
func (h *Handler) UpdateDocumentBranchByIDUnsafe(w http.ResponseWriter, r *http.Request) {
branchID, err := httpserver.ExtractNamedID(r, "branchId")
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
doc, err := h.db.FetchDocumentUnsafeByBranchID(r.Context(), branchID)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
var ui documentCore.UpdateInput
if err = httpserver.DecodeJSON(r, &ui); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
ui.Branch = doc.BranchName
ndoc, err := doc.ApplyUpdate(ui)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
var tx Tx
err = h.db.BeginTx(r.Context(), &tx)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
defer tx.Rollback() //nolint:errcheck // error provides no meaningful info
if err = tx.UpdateDocument(r.Context(), ndoc); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
maintainers, err := tx.FetchDocumentMaintainers(r.Context(), doc.ID, doc.OrganizationID)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
// the update carries the editors of one persist, so the set is only
// ever added to. Diffing it against the stored maintainers would drop
// everyone who happens not to be editing right now.
var maintainersAdded bool
for _, maintainer := range ui.Maintainers {
if !slices.Contains(maintainers, maintainer) {
maintainersAdded = true
break
}
}
if maintainersAdded {
if err = tx.UpsertDocumentMaintainers(
r.Context(),
doc.ID,
doc.OrganizationID,
ui.Maintainers,
); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
}
if err = h.searchJobs.Enqueue(
r.Context(),
tx,
search.BlocksDiff(doc.Search(), ndoc.Search()),
); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
err = tx.Commit()
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if h.metadata.changeCallback != nil {
h.metadata.changeCallback(doc.OrganizationID, ndoc)
}
if maintainersAdded && h.maintainers.changeCallback != nil {
h.maintainers.changeCallback(doc.OrganizationID, doc.ID)
}
httpserver.Respond(
h.log,
w,
ndoc,
http.StatusOK,
)
}
// UpdateBranchReviewApproval handles setting the current user's approval state on a branch.
func (h *Handler) UpdateBranchReviewApproval(w http.ResponseWriter, r *http.Request) {
session, ok := auth.RequireSession(h.log, w, r)
@ -874,140 +633,6 @@ func (h *Handler) UpdateBranchReviewApproval(w http.ResponseWriter, r *http.Requ
)
}
// MergeBranches merges the content of a source branch into a target branch.
// The target branch's hooks are detached for the hook manager to tear down
// and replaced with copies from the source.
// Target branch comments are cleared. Source branch reviewer approvals are promoted.
func (h *Handler) MergeBranches(w http.ResponseWriter, r *http.Request) {
session, ok := auth.RequireSession(h.log, w, r)
if !ok {
return
}
id, err := httpserver.ExtractNamedID(r, "documentId")
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
var inp struct {
FromBranchID xid.ID `json:"fromBranchId"`
ToBranchID xid.ID `json:"toBranchId"`
}
if err = httpserver.DecodeJSON(r, &inp); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
// a self-merge would soft-delete the branch's hooks and comments and
// then copy the hooks back from the same, now hook-less, branch —
// permanently destroying both.
if inp.FromBranchID == inp.ToBranchID {
httpserver.RespondError(h.log, w, errutil.New(http.StatusBadRequest, "document.branch_self_merge", "cannot merge a branch into itself"))
return
}
fromDoc, err := h.db.FetchDocumentByBranchID(r.Context(), inp.FromBranchID, session.ActiveOrganizationID)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
toDoc, err := h.db.FetchDocumentByBranchID(r.Context(), inp.ToBranchID, session.ActiveOrganizationID)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if fromDoc.ID != toDoc.ID {
httpserver.RespondError(h.log, w, errutil.New(http.StatusBadRequest, "document.branch_mismatch", "branches must belong to the same document"))
return
}
if toDoc.ID != id {
httpserver.RespondError(h.log, w, ErrBranchMismatch)
return
}
ndoc := toDoc.MergeBranch(fromDoc.Branch, session.UserID)
var tx Tx
if err := h.db.BeginTx(r.Context(), &tx); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
defer tx.Rollback() //nolint:errcheck // error provides no meaningful info
if err := tx.DetachDocumentHooksByBranchID(r.Context(), toDoc.BranchID, session.ActiveOrganizationID); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if err := tx.DeleteDocumentCommentsByBranchID(r.Context(), toDoc.BranchID, session.ActiveOrganizationID); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if err := tx.UpdateDocument(r.Context(), ndoc); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if err := tx.ReplaceBranchTags(r.Context(), session.ActiveOrganizationID, fromDoc.BranchID, toDoc.BranchID); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if err := h.searchJobs.Enqueue(r.Context(), tx, search.BlocksDiff(toDoc.Search(), ndoc.Search())); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if err := tx.PromoteBranchApprovals(r.Context(), fromDoc.BranchID, toDoc.BranchID, session.ActiveOrganizationID); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if err := tx.Commit(); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
// the hooks are copied after the commit: creating one creates its
// external watcher too, and a rollback cannot take that back.
if err := h.copyHooksToBranch(
r.Context(),
fromDoc.BranchID,
toDoc.BranchID,
toDoc.ID,
session.ActiveOrganizationID,
nil,
); err != nil {
logutil.Critical(h.log, err).Error(
"cannot copy hooks to the merged branch",
slog.String("branch_id", toDoc.BranchID.String()),
)
}
if h.metadata.changeCallback != nil {
h.metadata.changeCallback(session.ActiveOrganizationID, ndoc)
}
if h.reviewers.changeCallback != nil {
h.reviewers.changeCallback(session.ActiveOrganizationID, toDoc.ID)
}
httpserver.Respond(
h.log,
w,
ndoc,
http.StatusOK,
)
}
// DeleteDocument handles the deletion of a document by its ID.
func (h *Handler) DeleteDocument(w http.ResponseWriter, r *http.Request) {
session, ok := auth.RequireSession(h.log, w, r)
@ -1118,21 +743,14 @@ func (h *Handler) DuplicateDocument(w http.ResponseWriter, r *http.Request) {
return
}
// the hooks are copied after the commit: creating one creates its
// external watcher too, and a rollback cannot take that back.
if err := h.copyHooksToBranch(
h.copyHooksToBranch(
r.Context(),
doc.BranchID,
duplDoc.BranchID,
duplDoc.ID,
session.ActiveOrganizationID,
uids,
); err != nil {
logutil.Critical(h.log, err).Error(
"cannot copy hooks to the duplicated document",
slog.String("branch_id", duplDoc.BranchID.String()),
)
}
)
h.copyDocumentFiles(r.Context(), files, id, duplDoc.ID, session.ActiveOrganizationID)
@ -1207,283 +825,6 @@ func (h *Handler) copyDocumentFiles(
}
}
// FetchDocumentBranches handles the retrieval of all branches for a document.
func (h *Handler) FetchDocumentBranches(w http.ResponseWriter, r *http.Request) {
session, ok := auth.RequireSession(h.log, w, r)
if !ok {
return
}
docID, err := httpserver.ExtractNamedID(r, "documentId")
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
branches, err := h.db.FetchDocumentBranches(r.Context(), docID, session.ActiveOrganizationID)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
httpserver.Respond(
h.log,
w,
branches,
http.StatusOK,
)
}
// CreateDocumentBranch handles the creation of a new branch forked from an existing source branch.
// Hooks from the source branch are copied to the new branch.
func (h *Handler) CreateDocumentBranch(w http.ResponseWriter, r *http.Request) {
session, ok := auth.RequireSession(h.log, w, r)
if !ok {
return
}
id, err := httpserver.ExtractNamedID(r, "documentId")
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
var inp struct {
Branch string `json:"branch"`
SourceBranchID xid.ID `json:"sourceBranchId"`
}
if err = httpserver.DecodeJSON(r, &inp); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if inp.Branch == "" {
httpserver.RespondError(h.log, w, httpserver.ErrInvalidForm)
return
}
sourceDoc, err := h.db.FetchDocumentByBranchID(r.Context(), inp.SourceBranchID, session.ActiveOrganizationID)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if sourceDoc.ID != id {
httpserver.RespondError(h.log, w, ErrBranchMismatch)
return
}
newDoc := sourceDoc.Fork(inp.Branch, session.UserID)
var tx Tx
if err = h.db.BeginTx(r.Context(), &tx); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
defer tx.Rollback() //nolint:errcheck // error provides no meaningful info
if err = tx.InsertDocumentBranch(r.Context(), newDoc); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if err := tx.CopyBranchTags(r.Context(), session.ActiveOrganizationID, sourceDoc.BranchID, newDoc.BranchID); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if err := h.searchJobs.Enqueue(r.Context(), tx, search.BlocksDiff(nil, newDoc.Search())); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if err := tx.Commit(); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
// the hooks are copied after the commit: creating one creates its
// external watcher too, and a rollback cannot take that back.
if err := h.copyHooksToBranch(
r.Context(),
sourceDoc.BranchID,
newDoc.BranchID,
sourceDoc.ID,
session.ActiveOrganizationID,
nil,
); err != nil {
logutil.Critical(h.log, err).Error(
"cannot copy hooks to the forked branch",
slog.String("branch_id", newDoc.BranchID.String()),
)
}
httpserver.Respond(
h.log,
w,
newDoc,
http.StatusCreated,
)
}
// DeleteDocumentBranch handles the deletion of a specific document branch by ID.
// Returns 409 if it is the last remaining branch for the document.
func (h *Handler) DeleteDocumentBranch(w http.ResponseWriter, r *http.Request) {
session, ok := auth.RequireSession(h.log, w, r)
if !ok {
return
}
id, err := httpserver.ExtractNamedID(r, "documentId")
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
branchID, err := httpserver.ExtractNamedID(r, "branchId")
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
branchDoc, err := h.db.FetchDocumentByBranchID(r.Context(), branchID, session.ActiveOrganizationID)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if branchDoc.ID != id {
httpserver.RespondError(h.log, w, ErrBranchMismatch)
return
}
if branchDoc.Default {
httpserver.RespondError(h.log, w, errutil.New(http.StatusConflict, "document.default_branch", "cannot delete the default branch"))
return
}
count, err := h.db.CountDocumentBranches(r.Context(), branchDoc.ID, session.ActiveOrganizationID)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if count <= 1 {
httpserver.RespondError(h.log, w, errutil.New(http.StatusConflict, "document.last_branch", "cannot delete the last branch"))
return
}
var tx Tx
if err := h.db.BeginTx(r.Context(), &tx); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
defer tx.Rollback() //nolint:errcheck // error provides no meaningful info
if err := tx.DeleteDocumentBranchByID(r.Context(), branchID, session.ActiveOrganizationID); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if err := h.searchJobs.Enqueue(r.Context(), tx, search.BlocksDifference{
RemovedBranches: []search.BranchRemoval{{DocumentID: branchDoc.ID, BranchID: branchID}},
}); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if err := tx.Commit(); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
httpserver.Respond(
h.log,
w,
nil,
http.StatusNoContent,
)
}
// copyHooksToBranch fetches all hooks from fromBranchID and re-creates them on
// toBranchID with fresh state. This is handler-level business logic; the DB
// layer is not involved in the re-creation decision.
//
// Creating a hook creates its external resource as a side effect, so this runs
// outside the caller's transaction: a rollback cannot take a changedetection.io
// watcher back, and the row that would have pointed at it is gone. A failed
// insert tears the watcher down again for the same reason.
//
// A branch whose content was duplicated carries fresh block uids, so the
// caller passes the old-to-new uid map and a hook anchored to a block is
// re-anchored through it; a hook whose block the map does not name has
// nothing to point at on the target and is dropped. A nil map keeps every
// block id as it is, which is right for a fork or a merge.
func (h *Handler) copyHooksToBranch(
ctx context.Context,
fromBranchID, toBranchID, documentID xid.ID,
organizationID string,
uids map[string]string,
) error {
hooks, err := h.db.FetchDocumentHooksByBranchID(ctx, fromBranchID, organizationID)
if err != nil {
return err
}
inp := hook.NewInput(organizationID, h.githubMan, h.webchangeClient)
for _, hk := range hooks {
// a url-watcher cannot get its changedetection.io watcher without
// the integration configured; dropping it from the copy beats
// failing the whole fork or merge over it.
if hk.Type == hook.TypeURLWatcher && !h.webchangeClient.Configured() {
h.log.With("hook_id", hk.ID).
Warn("skipping url-watcher hook copy: changedetection is not configured")
continue
}
blockID := hk.BlockID
if uids != nil && blockID.Valid {
uid, ok := uids[blockID.String]
if !ok {
continue
}
blockID = null.StringFrom(uid)
}
newHk, err := hook.NewHook(ctx, hook.CreateInput{
Type: hk.Type,
BranchID: toBranchID,
BlockID: blockID,
Settings: hk.Settings,
}, documentID, toBranchID, organizationID, inp)
if err != nil {
return err
}
if err := h.db.InsertDocumentHook(ctx, *newHk); err != nil {
if derr := newHk.Delete(ctx, inp); derr != nil {
logutil.Critical(h.log, derr).Error(
"cannot delete the hook external resource after a failed insert",
slog.String("hook_id", newHk.ID.String()),
)
}
return err
}
}
return nil
}
// upsertBranchReviewer records the reviewer's approval state, inserting the
// row when the user is not yet a reviewer of the branch. PreviouslyApproved
// is never written here: UpdateBranchReviewer persists currently_approved

View File

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
@ -13,6 +14,8 @@ import (
"github.com/guregu/null/v5"
"github.com/oxynote/oxynote/server/core/internal/apps/webchange"
documentCore "github.com/oxynote/oxynote/server/core/internal/document"
"github.com/oxynote/oxynote/server/core/internal/document/file"
hookCore "github.com/oxynote/oxynote/server/core/internal/document/hook"
"github.com/oxynote/oxynote/server/core/internal/notification"
"github.com/oxynote/oxynote/server/core/internal/search"
"github.com/oxynote/oxynote/server/core/internal/server/internal/auth"
@ -1390,130 +1393,114 @@ func Test_Handler_CreateDocument(t *testing.T) {
}
}
func Test_Handler_UpdateDocumentBranch(t *testing.T) {
validBody := `{"name":"Renamed","protected":true}`
func Test_Handler_DeleteDocument(t *testing.T) {
fetchStored := func(context.Context, xid.ID, string, string) (*documentCore.Document, error) {
return storedDoc(), nil
}
cc := map[string]struct {
DB *DBMock
Tx *TxMock
BeginErr error
NoSession bool
OmitDoc bool
Body string
WantName string
RespCode int
Updated int
Metadata int
Jobs int
Committed int
TreeCbs int
}{
"No session in context": {
DB: &DBMock{},
Tx: &TxMock{},
NoSession: true,
Body: validBody,
RespCode: http.StatusUnauthorized,
},
"Missing document ID parameter": {
DB: &DBMock{},
Tx: &TxMock{},
OmitDoc: true,
Body: validBody,
RespCode: http.StatusNotFound,
},
"Branch document fetch error": {
"Document fetch error": {
DB: &DBMock{
FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*documentCore.Document, error) {
FetchDocumentFunc: func(context.Context, xid.ID, string, string) (*documentCore.Document, error) {
return nil, errors.New("boom")
},
},
Body: validBody,
Tx: &TxMock{},
RespCode: http.StatusInternalServerError,
},
"Branch of another document": {
"Hook fetch error": {
DB: &DBMock{
FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*documentCore.Document, error) {
doc := branchDoc(_branchID)
doc.ID = xid.New()
return doc, nil
FetchDocumentFunc: fetchStored,
FetchDocumentHooksByDocumentIDFunc: func(context.Context, xid.ID, string) ([]hookCore.Hook, error) {
return nil, errors.New("boom")
},
},
Body: validBody,
RespCode: http.StatusNotFound,
},
"Invalid JSON body": {
DB: &DBMock{
FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*documentCore.Document, error) {
return storedDoc(), nil
},
},
Body: "{",
RespCode: http.StatusBadRequest,
},
"Metadata update error": {
DB: &DBMock{
FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*documentCore.Document, error) {
return branchDoc(_branchID), nil
},
},
Tx: &TxMock{
UpdateDocumentBranchMetadataFunc: func(context.Context, documentCore.Document) error {
return errors.New("boom")
},
},
Body: validBody,
Tx: &TxMock{},
RespCode: http.StatusInternalServerError,
Updated: 1,
},
"Search job insert error": {
"Hook cleanup error": {
DB: &DBMock{
FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*documentCore.Document, error) {
return branchDoc(_branchID), nil
FetchDocumentFunc: fetchStored,
FetchDocumentHooksByDocumentIDFunc: func(context.Context, xid.ID, string) ([]hookCore.Hook, error) {
return []hookCore.Hook{storedHook("bogus")}, nil
},
},
Tx: &TxMock{},
RespCode: http.StatusInternalServerError,
},
"Transaction start error": {
DB: &DBMock{FetchDocumentFunc: fetchStored},
Tx: &TxMock{},
BeginErr: errors.New("boom"),
RespCode: http.StatusInternalServerError,
},
"Document deletion error": {
DB: &DBMock{FetchDocumentFunc: fetchStored},
Tx: &TxMock{
DeleteDocumentFunc: func(context.Context, xid.ID, string) ([]xid.ID, error) {
return nil, errors.New("boom")
},
},
RespCode: http.StatusInternalServerError,
},
"Search removal enqueue error": {
DB: &DBMock{FetchDocumentFunc: fetchStored},
Tx: &TxMock{
DeleteDocumentFunc: func(context.Context, xid.ID, string) ([]xid.ID, error) {
return []xid.ID{_documentID}, nil
},
InsertDocumentSearchJobFunc: func(context.Context, search.BlocksDifference) error {
return errors.New("boom")
},
},
Body: validBody,
RespCode: http.StatusInternalServerError,
Updated: 1,
Jobs: 1,
},
// the main branch is found by its flag in the tree and content
// queries, but its name is what the user sees; renaming it would
// leave the document looking nameless.
"Renaming the default branch is rejected": {
DB: &DBMock{
FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*documentCore.Document, error) {
return storedDoc(), nil
"Commit error": {
DB: &DBMock{FetchDocumentFunc: fetchStored},
Tx: &TxMock{
CommitFunc: func() error {
return errors.New("boom")
},
},
Body: validBody,
RespCode: http.StatusBadRequest,
RespCode: http.StatusInternalServerError,
Committed: 1,
},
"Protecting the default branch is allowed": {
"Successful deletion with hook cleanup": {
DB: &DBMock{
FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*documentCore.Document, error) {
return storedDoc(), nil
FetchDocumentFunc: fetchStored,
FetchDocumentHooksByDocumentIDFunc: func(context.Context, xid.ID, string) ([]hookCore.Hook, error) {
return []hookCore.Hook{storedHook(hookCore.TypeScheduledReminder)}, nil
},
},
Body: `{"protected":true}`,
WantName: documentCore.DefaultBranch,
RespCode: http.StatusOK,
Updated: 1,
Metadata: 1,
},
"Successful update": {
DB: &DBMock{
FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*documentCore.Document, error) {
return branchDoc(_branchID), nil
Tx: &TxMock{
DeleteDocumentFunc: func(_ context.Context, id xid.ID, _ string) ([]xid.ID, error) {
return []xid.ID{id}, nil
},
},
Body: validBody,
WantName: "Renamed",
RespCode: http.StatusOK,
Updated: 1,
Metadata: 1,
Jobs: 1,
RespCode: http.StatusOK,
Committed: 1,
TreeCbs: 1,
},
}
@ -1521,38 +1508,373 @@ func Test_Handler_UpdateDocumentBranch(t *testing.T) {
t.Run(cn, func(t *testing.T) {
t.Parallel()
tx := c.Tx
if tx == nil {
tx = &TxMock{}
}
hdl, cnt := newTestHandler(withTx(c.DB, tx, nil), &fakePublisher{})
hdl, cnt := newTestHandler(withTx(c.DB, c.Tx, c.BeginErr), &fakePublisher{})
rec := httptest.NewRecorder()
hdl.UpdateDocumentBranch(rec, newRequest(http.MethodPut, c.Body, c.NoSession, c.OmitDoc, false))
hdl.DeleteDocument(rec, newRequest(http.MethodDelete, "", c.NoSession, c.OmitDoc, true))
assert.Equal(t, c.RespCode, rec.Code)
assert.Len(t, tx.UpdateDocumentBranchMetadataCalls(), c.Updated)
assert.Len(t, tx.InsertDocumentSearchJobCalls(), c.Jobs)
assert.Equal(t, c.Metadata, cnt.metadata)
assert.Len(t, c.Tx.CommitCalls(), c.Committed)
assert.Equal(t, c.TreeCbs, cnt.tree)
if c.RespCode == http.StatusOK {
ff := tx.UpdateDocumentBranchMetadataCalls()
require.NotEmpty(t, ff)
assert.Equal(t, c.WantName, ff[0].Doc.BranchName)
assert.True(t, ff[0].Doc.Protected)
assert.Len(t, tx.CommitCalls(), 1)
}
require.Len(t, c.Tx.DeleteDocumentCalls(), 1)
assert.Equal(t, _documentID, c.Tx.DeleteDocumentCalls()[0].ID)
// a rename rewrites every entry of the branch, since each one
// carries the branch name.
if c.Jobs == 1 && c.RespCode == http.StatusOK {
diff := tx.InsertDocumentSearchJobCalls()[0].Diff
require.Len(t, diff.Updated, 1)
assert.Equal(t, _branchID.String()+"-docname", diff.Updated[0].ID)
assert.Equal(t, "Renamed", diff.Updated[0].BranchName)
// the removal of the returned subtree ids rides the same
// transaction as the delete.
ff := c.Tx.InsertDocumentSearchJobCalls()
require.Len(t, ff, 1)
assert.Equal(t, []xid.ID{_documentID}, ff[0].Diff.RemovedDocuments)
}
})
}
}
func Test_Handler_DuplicateDocument(t *testing.T) {
fetchStored := func(context.Context, xid.ID, string, string) (*documentCore.Document, error) {
return storedDoc(), nil
}
// insertAwareTx returns the freshly inserted duplicate in the parent
// subtree so the sort-index swap can find it.
insertAwareTx := func() *TxMock {
tx := &TxMock{}
tx.FetchDocumentTreeByDocumentParentIDFunc = func(context.Context, null.Value[xid.ID], string) (documentCore.Summaries, error) {
return documentCore.Summaries{{ID: tx.InsertDocumentCalls()[0].Doc.ID}}, nil
}
return tx
}
// fetchStoredWithImage serves a document whose content holds an image
// served by that same document, which is what duplication has to copy.
fetchStoredWithImage := func(context.Context, xid.ID, string, string) (*documentCore.Document, error) {
doc := storedDoc()
doc.Content = documentCore.RootBlock{
Type: documentCore.BlockNodeDoc,
Content: []documentCore.Block{
{
Type: documentCore.BlockNodeImageBlock,
Attrs: documentCore.Attributes{
"uid": "img1",
"src": "https://app.test/core" + fmt.Sprintf(documentCore.FilePathFormat, _documentID, "img1"),
},
},
},
}
return doc, nil
}
// imageDB serves the document with the image plus the file row that
// image refers to, which is what the copy is made from.
imageDB := func() *DBMock {
return &DBMock{
FetchDocumentFunc: fetchStoredWithImage,
FetchDocumentFileFunc: func(_ context.Context, id, organizationID string) (*file.File, error) {
f := file.NewFile(id, file.LocationDocument, "key", _documentID, organizationID)
return &f, nil
},
}
}
cc := map[string]struct {
DB *DBMock
Tx *TxMock
Storer *StorerMock
BeginErr error
NoSession bool
OmitDoc bool
RespCode int
Committed int
TreeCbs int
Copies int
CopiedHooks int
}{
"No session in context": {
DB: &DBMock{},
Tx: &TxMock{},
NoSession: true,
RespCode: http.StatusUnauthorized,
},
"Missing document ID parameter": {
DB: &DBMock{},
Tx: &TxMock{},
OmitDoc: true,
RespCode: http.StatusNotFound,
},
"Document fetch error": {
DB: &DBMock{
FetchDocumentFunc: func(context.Context, xid.ID, string, string) (*documentCore.Document, error) {
return nil, errors.New("boom")
},
},
Tx: &TxMock{},
RespCode: http.StatusInternalServerError,
},
"Transaction start error": {
DB: &DBMock{FetchDocumentFunc: fetchStored},
Tx: &TxMock{},
BeginErr: errors.New("boom"),
RespCode: http.StatusInternalServerError,
},
"Duplicate insertion error": {
DB: &DBMock{FetchDocumentFunc: fetchStored},
Tx: &TxMock{
InsertDocumentFunc: func(context.Context, documentCore.Document) error {
return errors.New("boom")
},
},
RespCode: http.StatusInternalServerError,
},
"Tag copy error": {
DB: &DBMock{FetchDocumentFunc: fetchStored},
Tx: &TxMock{
CopyBranchTagsFunc: func(context.Context, string, xid.ID, xid.ID) error {
return errors.New("boom")
},
},
RespCode: http.StatusInternalServerError,
},
"Sort index swap error": {
DB: &DBMock{FetchDocumentFunc: fetchStored},
Tx: &TxMock{},
RespCode: http.StatusBadRequest,
},
"Commit error": {
DB: &DBMock{FetchDocumentFunc: fetchStored},
Tx: func() *TxMock {
tx := insertAwareTx()
tx.CommitFunc = func() error {
return errors.New("boom")
}
return tx
}(),
RespCode: http.StatusInternalServerError,
Committed: 1,
},
"Successful duplication": {
DB: &DBMock{FetchDocumentFunc: fetchStored},
Tx: insertAwareTx(),
RespCode: http.StatusCreated,
Committed: 1,
TreeCbs: 1,
},
"Files are copied along with the document": {
DB: imageDB(),
Tx: insertAwareTx(),
Storer: &StorerMock{},
RespCode: http.StatusCreated,
Committed: 1,
TreeCbs: 1,
Copies: 1,
},
"Failing file copy still yields the duplicate": {
DB: imageDB(),
Tx: insertAwareTx(),
Storer: &StorerMock{
CopyFunc: func(context.Context, string, string, string, string) error {
return errors.New("boom")
},
},
RespCode: http.StatusCreated,
Committed: 1,
TreeCbs: 1,
Copies: 1,
},
"Hooks are copied along with the document": {
DB: func() *DBMock {
blockHook := storedHook(hookCore.TypeScheduledReminder)
blockHook.BlockID = null.StringFrom("img1")
db := imageDB()
db.FetchDocumentHooksByBranchIDFunc = func(context.Context, xid.ID, string) ([]hookCore.Hook, error) {
return []hookCore.Hook{blockHook}, nil
}
return db
}(),
Tx: insertAwareTx(),
Storer: &StorerMock{},
RespCode: http.StatusCreated,
Committed: 1,
TreeCbs: 1,
Copies: 1,
CopiedHooks: 1,
},
"Failing hook copy still yields the duplicate": {
DB: func() *DBMock {
db := &DBMock{FetchDocumentFunc: fetchStored}
db.FetchDocumentHooksByBranchIDFunc = func(context.Context, xid.ID, string) ([]hookCore.Hook, error) {
return nil, errors.New("boom")
}
return db
}(),
Tx: insertAwareTx(),
RespCode: http.StatusCreated,
Committed: 1,
TreeCbs: 1,
},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
hdl, cnt := newTestHandler(withTx(c.DB, c.Tx, c.BeginErr), &fakePublisher{})
if c.Storer != nil {
hdl.storer = c.Storer
}
rec := httptest.NewRecorder()
hdl.DuplicateDocument(rec, newRequest(http.MethodPost, "", c.NoSession, c.OmitDoc, true))
assert.Equal(t, c.RespCode, rec.Code)
assert.Len(t, c.Tx.CommitCalls(), c.Committed)
assert.Equal(t, c.TreeCbs, cnt.tree)
if c.Storer != nil {
assert.Len(t, c.Storer.CopyCalls(), c.Copies)
assert.Len(t, c.DB.InsertDocumentFileCalls(), c.Copies)
}
if c.RespCode == http.StatusCreated {
// the duplicate is a new document owned by the caller.
require.Len(t, c.Tx.InsertDocumentCalls(), 1)
dupl := c.Tx.InsertDocumentCalls()[0].Doc
assert.NotEqual(t, _documentID, dupl.ID)
assert.Equal(t, []string{"u1"}, c.Tx.UpsertDocumentMaintainersCalls()[0].MaintainerIDs)
// the copy's default branch takes the source's tags before
// the commit.
require.Len(t, c.Tx.CopyBranchTagsCalls(), 1)
assert.Equal(t, _branchID, c.Tx.CopyBranchTagsCalls()[0].FromBranchID)
assert.Equal(t, dupl.BranchID, c.Tx.CopyBranchTagsCalls()[0].ToBranchID)
// the hooks are copied once the commit is through, since
// creating one creates its watcher.
assert.Empty(t, c.Tx.InsertDocumentHookCalls())
require.Len(t, c.DB.InsertDocumentHookCalls(), c.CopiedHooks)
// a hook anchored to a block follows the block's regenerated
// uid rather than pointing at the source's.
for _, call := range c.DB.InsertDocumentHookCalls() {
assert.Equal(t, null.ValueFrom(dupl.BranchID), call.Hk.BranchID)
assert.NotEqual(t, null.StringFrom("img1"), call.Hk.BlockID)
assert.True(t, call.Hk.BlockID.Valid)
}
}
})
}
}
func Test_Handler_copyDocumentFiles(t *testing.T) {
toDocumentID := xid.New()
fileDB := func() *DBMock {
return &DBMock{
FetchDocumentFileFunc: func(_ context.Context, id, organizationID string) (*file.File, error) {
f := file.NewFile(id, file.LocationComment, "key", _documentID, organizationID)
return &f, nil
},
}
}
cc := map[string]struct {
DB *DBMock
Storer *StorerMock
Files map[string]string
Inserts int
Copies int
}{
"Nothing to copy": {
DB: fileDB(),
Storer: &StorerMock{},
Files: map[string]string{},
},
"File row is missing": {
DB: &DBMock{
FetchDocumentFileFunc: func(context.Context, string, string) (*file.File, error) {
return nil, errors.New("boom")
},
},
Storer: &StorerMock{},
Files: map[string]string{"old-1": "new-1"},
},
"Error returned by db.InsertDocumentFile": {
DB: func() *DBMock {
db := fileDB()
db.InsertDocumentFileFunc = func(context.Context, file.File) error {
return errors.New("boom")
}
return db
}(),
Storer: &StorerMock{},
Files: map[string]string{"old-1": "new-1"},
Inserts: 1,
},
"Error returned by storer.Copy": {
DB: fileDB(),
Storer: &StorerMock{
CopyFunc: func(context.Context, string, string, string, string) error {
return errors.New("boom")
},
},
Files: map[string]string{"old-1": "new-1"},
Inserts: 1,
Copies: 1,
},
"Successful copy": {
DB: fileDB(),
Storer: &StorerMock{},
Files: map[string]string{"old-1": "new-1"},
Inserts: 1,
Copies: 1,
},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
hdl, _ := newTestHandler(c.DB, &fakePublisher{})
hdl.storer = c.Storer
hdl.copyDocumentFiles(context.Background(), c.Files, _documentID, toDocumentID, "org1")
ff := c.DB.InsertDocumentFileCalls()
require.Len(t, ff, c.Inserts)
cf := c.Storer.CopyCalls()
require.Len(t, cf, c.Copies)
if c.Inserts == 0 {
return
}
// the copy owns its row, keyed under the new document.
assert.Equal(t, "new-1", ff[0].F.ID)
assert.Equal(t, file.LocationComment, ff[0].F.Location)
assert.Equal(t, file.Key("org1", toDocumentID, "new-1"), ff[0].F.StorageKey)
assert.Equal(t, toDocumentID, ff[0].F.DocumentID.V)
if c.Copies == 0 {
return
}
assert.Equal(t, file.Folder("org1", _documentID), cf[0].SrcFolder)
assert.Equal(t, "old-1", cf[0].SrcID)
assert.Equal(t, file.Folder("org1", toDocumentID), cf[0].DstFolder)
assert.Equal(t, "new-1", cf[0].DstID)
})
}
}

View File

@ -146,16 +146,19 @@ func (s *Server) internalRouter() chi.Router {
})
// the branch operations that read or rewrite a branch's stored
// content. They are internal so that only auth-realtime, which
// first stores what the editors still hold, can start one — yet
// they act for a person, so the session it forwards is required
// and authorizes the change exactly as on the public surface.
// content, or remove a branch editors may still be connected to.
// They are internal so that only auth-realtime, which first stores
// what the editors still hold and afterwards drops the connections
// a change invalidated, can start one — yet they act for a person,
// so the session it forwards is required and authorizes the change
// exactly as on the public surface.
sr.Group(func(ssr chi.Router) {
ssr.Use(auth.Middleware(s.log, s.opts.Auth, s.client))
ssr.Use(s.handlers.document.RequireDocumentAccess)
ssr.Put("/merge", s.handlers.document.MergeBranches)
ssr.Post("/branches", s.handlers.document.CreateDocumentBranch)
ssr.Put("/branches/{branchId}", s.handlers.document.UpdateDocumentBranch)
ssr.Delete("/branches/{branchId}", s.handlers.document.DeleteDocumentBranch)
})
})
@ -299,7 +302,6 @@ func (s *Server) router() chi.Router {
ssr.Route("/branches", func(sssr chi.Router) {
sssr.Get("/", s.handlers.document.FetchDocumentBranches)
sssr.Route("/{branchId}", func(ssssr chi.Router) {
ssssr.Delete("/", s.handlers.document.DeleteDocumentBranch)
ssssr.Put("/review-approve", s.handlers.document.UpdateBranchReviewApproval)
ssssr.Post("/blocks/{blockUid}/run", s.handlers.block.RunBlock)
ssssr.Route("/reviewers", func(sssssr chi.Router) {

View File

@ -85,11 +85,13 @@ func Test_Server_httpRouter(t *testing.T) {
// the branch operations are reachable only through auth-realtime,
// which stores the editors' pending changes before core reads the
// branch: a public mount would let a client skip that.
// branch and drops the connections a delete leaves behind: a public
// mount would let a client skip that.
internalOnly := map[string]string{
"PUT /api/x/documents/{documentId}/merge": "PUT /api/*/documents/{documentId}/merge",
"POST /api/x/documents/{documentId}/branches": "POST /api/*/documents/{documentId}/branches/",
"PUT /api/x/documents/{documentId}/branches/{branchId}": "PUT /api/*/documents/{documentId}/branches/{branchId}/",
"PUT /api/x/documents/{documentId}/merge": "PUT /api/*/documents/{documentId}/merge",
"POST /api/x/documents/{documentId}/branches": "POST /api/*/documents/{documentId}/branches/",
"PUT /api/x/documents/{documentId}/branches/{branchId}": "PUT /api/*/documents/{documentId}/branches/{branchId}/",
"DELETE /api/x/documents/{documentId}/branches/{branchId}": "DELETE /api/*/documents/{documentId}/branches/{branchId}/",
}
for internal, public := range internalOnly {
@ -97,7 +99,6 @@ func Test_Server_httpRouter(t *testing.T) {
assert.False(t, routes[public], "%s must not be publicly routable", public)
}
// the branch reads and the delete stay public alongside them.
// the branch reads stay public alongside them.
assert.True(t, routes["GET /api/*/documents/{documentId}/branches/"])
assert.True(t, routes["DELETE /api/*/documents/{documentId}/branches/{branchId}/"])
}

View File

@ -35,6 +35,14 @@ const providers = vi.hoisted(() => {
}
})
const { showToastMessageMock } = vi.hoisted(() => {
return { showToastMessageMock: vi.fn() }
})
vi.mock("~/components/toast", () => {
return { showToastMessage: showToastMessageMock }
})
vi.mock("@hocuspocus/provider", () => {
function FakeProvider(options: ProviderOptions): ProviderEntry {
const entry: ProviderEntry = {
@ -243,10 +251,13 @@ describe("<DocumentContainer>", { concurrent: false }, () => {
expect(redirectToLogin).toHaveBeenCalledTimes(1)
})
it("reports an error the realtime service sends", async ({ expect }) => {
it("tells the reader about an error the realtime service sends", async ({
expect,
}) => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {
return undefined
})
showToastMessageMock.mockClear()
await mountContainer()
providerFor(BRANCH_ID).options.onStateless({
@ -254,6 +265,8 @@ describe("<DocumentContainer>", { concurrent: false }, () => {
})
expect(consoleError).toHaveBeenCalledTimes(1)
expect(showToastMessageMock).toHaveBeenCalledTimes(1)
expect(showToastMessageMock.mock.calls[0]?.[0]).toBe("error")
})
it("stays quiet for any other realtime message", async ({ expect }) => {
@ -262,11 +275,14 @@ describe("<DocumentContainer>", { concurrent: false }, () => {
})
await mountContainer()
showToastMessageMock.mockClear()
providerFor(BRANCH_ID).options.onStateless({
payload: JSON.stringify({ type: "info" }),
})
expect(consoleError).toHaveBeenCalledTimes(0)
expect(showToastMessageMock).toHaveBeenCalledTimes(0)
})
it("keeps a protected branch read-only", async ({ expect }) => {

View File

@ -6,6 +6,7 @@ import ContentEditor from "./ContentEditor.vue"
import NameEditor from "./NameEditor.vue"
import DiffEditor from "./diff/DiffEditor.vue"
import { redirectToLogin } from "~/plugins/03.api-fetch"
import { showToastMessage } from "~/components/toast"
import { refreshGapDecorationsInBackground } from "./drag-handle/gap-decorations"
import { editorCaretColors } from "~/assets/css"
@ -223,8 +224,12 @@ function createBranchProvider(
onStateless({ payload }) {
const data = JSON.parse(payload) as { type?: string }
if (data.type === "error") {
// TODO show toast
console.error(`Error in provider ${streamName}:`, data)
showToastMessage(
"error",
t("editor.realtime-error.title"),
t("editor.realtime-error.description"),
)
}
},
onConnect: () => {

View File

@ -775,9 +775,15 @@ export default function () {
return
}
await $coreAPIClient(`/api/documents/${docId}/branches/${branchId}`, {
method: "DELETE",
})
// auth-realtime drops the connections still on the branch once
// core has removed it, so nobody keeps editing a branch that is
// gone.
await $authRealtimeAPIClient(
`/api/documents/${docId}/branches/${branchId}`,
{
method: "DELETE",
},
)
},
async onSuccess(_data, { docId, branchId }) {
if (!isXid(docId) || !isXid(branchId)) {

View File

@ -1,6 +1,10 @@
{
"editor": {
"unknown-user-caret": "Unknown",
"realtime-error": {
"title": "Your latest changes could not be saved",
"description": "Reload the page to keep editing"
},
"placeholders": {
"name-default": "New Page",
"content": {