diff --git a/__tests__/message-handler.test.ts b/__tests__/message-handler.test.ts index 14ccc0b..d8a2d93 100644 --- a/__tests__/message-handler.test.ts +++ b/__tests__/message-handler.test.ts @@ -510,7 +510,7 @@ describe("onMessage org verify actions", () => { const verificationToken = "FLEETYARDS-ABCDEFGHIJ"; const orgPage = (history: string, manifesto = "Ours.") => - `
Intro.
${manifesto}
Intro.
${manifesto}
Our board.
${verificationToken}
`), + preview: orgPage(`Our board.
${verificationToken}
`), + }); + + const result = await send({ action: "org-verify-write", sid: "MARU", token: verificationToken }); + + expect(result.payload).toEqual({ sid: "MARU", changed: false }); + expect(posted(fetch, "/api/orgs/publishDraft")).toEqual([]); + }); + + it("puts the draft back when another edit arrives before publishing", async () => { + let previewReads = 0; + let draft = "Our board."; + const fetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + const target = String(url); + if (target.endsWith("/admin/content")) return new Response(contentPage(draft)); + if (target.endsWith("/api/orgs/saveDraft")) { + draft = JSON.parse(String(init?.body)).history; + return json({ success: 1 }); + } + if (target.endsWith("/admin/preview")) { + previewReads += 1; + return new Response( + previewReads === 1 + ? orgPage("Our board.
") + : orgPage("Our board.
", "Someone else's draft.") + ); + } + if (target.endsWith("/en/orgs/MARU")) return new Response(orgPage("Our board.
")); + return json({ success: 1 }); + }); + + const result = await send({ action: "org-verify-write", sid: "MARU", token: verificationToken }); + + expect(result.code).toBe(409); + expect(posted(fetch, "/api/orgs/saveDraft")).toEqual([ + { symbol: "MARU", history: `Our board.\n\n${verificationToken}` }, + { symbol: "MARU", history: "Our board." }, + ]); + expect(posted(fetch, "/api/orgs/publishDraft")).toEqual([]); + }); + + it("answers 409 for a token it did not place", async () => { + mockRsi({ + content: contentPage(`${verificationToken}\n\nOur board.`), + live: orgPage(`${verificationToken}
Our board.
`), + }); + + const result = await send({ action: "org-verify-remove", sid: "MARU", token: verificationToken }); + + expect(result.code).toBe(409); + }); + + it("leaves an officer's history edit made during the run alone", async () => { + let contentReads = 0; + let previewReads = 0; + const fetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => { + const target = String(url); + if (target.endsWith("/admin/content")) { + contentReads += 1; + return new Response( + contentPage( + contentReads === 1 ? "Our board." : `Our new board.\n\n${verificationToken}` + ) + ); + } + if (target.endsWith("/admin/preview")) { + previewReads += 1; + return new Response( + orgPage(previewReads === 1 ? "Our board.
" : "Our new board.
") + ); + } + if (target.endsWith("/en/orgs/MARU")) return new Response(orgPage("Our board.
")); + return json({ success: 1 }); + }); + + const result = await send({ action: "org-verify-write", sid: "MARU", token: verificationToken }); + + expect(result).toMatchObject({ code: 409, payload: { changed: true } }); + expect(posted(fetch, "/api/orgs/saveDraft")).toHaveLength(1); + expect(posted(fetch, "/api/orgs/publishDraft")).toEqual([]); + }); + + it("says so when putting the draft back was refused", async () => { + let previewReads = 0; + let saves = 0; + vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + const target = String(url); + if (target.endsWith("/admin/content")) { + return new Response( + contentPage(saves === 0 ? "Our board." : `Our board.\n\n${verificationToken}`) + ); + } + if (target.endsWith("/admin/preview")) { + previewReads += 1; + return new Response( + orgPage("Our board.
", previewReads === 1 ? "Ours." : "Half done.") + ); + } + if (target.endsWith("/en/orgs/MARU")) return new Response(orgPage("Our board.
")); + if (target.endsWith("/api/orgs/saveDraft")) { + saves += 1; + return json(saves === 1 ? { success: 1 } : { success: 0, msg: "ErrCsrf" }); + } + return json({ success: 1 }); + }); + + const result = await send({ action: "org-verify-write", sid: "MARU", token: verificationToken }); + + expect(result).toMatchObject({ code: 502, payload: { changed: true } }); + }); + + it("refuses to remove a token another section shows", async () => { + const fetch = mockRsi({ + content: contentPage(`Our board.\n\n${verificationToken}`), + live: orgPage(`Our board.
${verificationToken}
`, verificationToken), + preview: orgPage(`Our board.
${verificationToken}
`, verificationToken), + }); + + const result = await send({ action: "org-verify-remove", sid: "MARU", token: verificationToken }); + + expect(result.code).toBe(409); + expect(posted(fetch, "/api/orgs/saveDraft")).toEqual([]); + }); + + it("lets a remove wait for a write to the same org", async () => { + const order: string[] = []; + let releaseWrite: () => void = () => {}; + let draft = "Our board."; + vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + const target = String(url); + if (target.endsWith("/admin/content")) return new Response(contentPage(draft)); + if (target.endsWith("/admin/preview")) return new Response(orgPage("Our board.
")); + if (target.endsWith("/en/orgs/MARU")) return new Response(orgPage("Our board.
")); + if (target.endsWith("/api/orgs/saveDraft")) { + const { history } = JSON.parse(String(init?.body)); + order.push(history.includes(verificationToken) ? "save-token" : "save-clean"); + if (history.includes(verificationToken)) { + await new PromiseOur board.
\n\nFLEETYARDS-ABCDEFGHIJ
' ) ) - ).toEqual(["Welcome aboard.", "Our board.", "Our manifesto."]); + ).toEqual( + new Map([ + ["introduction", "Welcome aboard.
"], + ["history", "Our board.
"], + ["manifesto", "Our manifesto.
"], + ]) + ); + }); + + it("reads a section past the divs Textile nests in it", () => { + expect( + contentSections(orgPage("Inner
After
")) + ?.get("history") + ).toBe('Inner
After
'); }); it("refuses a page without text blocks", () => { - expect(contentBlocks("Our board. 
Our board. 
Same
Edited
"), + orgPage("Same
Original
") + ) + ).toBe(true); + }); + + it("reads a section one page leaves out as empty", () => { + const withEmptyCharter = `${orgPage("Our board.
")}Our board.
")) + ).toBe(false); + }); + it("refuses to compare a page it cannot read", () => { expect(hasPendingChanges("", orgPage("x
"))).toBeNull(); }); }); + +describe("tokenOnPage", () => { + const token = "FLEETYARDS-ABCDEFGHIJ"; + + it("finds a token Textile split with a span in the history", () => { + expect( + tokenOnPage( + orgPage('Our board.
FLEETYARDS-ABCDEFGHIJ
'), + token, + "history" + ) + ).toEqual({ inField: true, elsewhere: false }); + }); + + it("tells a token in another section apart", () => { + expect( + tokenOnPage(orgPage("Our board.
", token), token, "history") + ).toEqual({ inField: false, elsewhere: true }); + }); + + it("refuses a page it cannot read", () => { + expect(tokenOnPage("", token, "history")).toBeNull(); + }); +}); diff --git a/lib/bio.ts b/lib/bio.ts index f24edd5..f60edb7 100644 --- a/lib/bio.ts +++ b/lib/bio.ts @@ -1,6 +1,8 @@ +import { appendToken, removeAppendedToken } from "./tokens"; + export const BIO_MAX_LENGTH = 1024; -export const VERIFICATION_TOKEN_PATTERN = /^FLEETYARDS-[A-Z0-9]{10}$/; +export { VERIFICATION_TOKEN_PATTERN } from "./tokens"; const ENTITIES: Record\s*<\/p>/g, "") + .replace(/\s+/g, " ") + .trim(), + ]) + ); } // Whether the org's draft holds changes besides FleetYards tokens: RSI // publishes the whole draft at once, so writing then would publish them too. +// A section one page leaves out counts as empty. export function hasPendingChanges( previewHtml: string, publicHtml: string ): boolean | null { - const draft = contentBlocks(previewHtml); - const live = contentBlocks(publicHtml); + const draft = contentSections(previewHtml); + const live = contentSections(publicHtml); if (!draft || !live) return null; - return ( - draft.length !== live.length || - draft.some((block, index) => block !== live[index]) + return [...new Set([...draft.keys(), ...live.keys()])].some( + (section) => (draft.get(section) ?? "") !== (live.get(section) ?? "") ); } + +// Where a rendered org page shows the token, Textile's markup aside: in the +// section the extension writes, or anywhere else, which it never wrote and +// cannot take out. Null for a page it cannot read. +export function tokenOnPage( + html: string, + token: string, + field: string +): { inField: boolean; elsewhere: boolean } | null { + const sections = rawSections(html); + if (!sections) return null; + + const shows = (markup: string) => markup.replace(/<[^>]*>/g, "").includes(token); + + return { + inField: shows(sections.get(field) ?? ""), + elsewhere: [...sections].some( + ([name, markup]) => name !== field && shows(markup) + ), + }; +} diff --git a/lib/tokens.ts b/lib/tokens.ts new file mode 100644 index 0000000..0ee1f4a --- /dev/null +++ b/lib/tokens.ts @@ -0,0 +1,21 @@ +export const VERIFICATION_TOKEN_PATTERN = /^FLEETYARDS-[A-Z0-9]{10}$/; + +// A verification token goes after the text, a blank line apart, and only that +// is ever taken out again: the same token placed elsewhere by hand is the +// owner's to remove. +export function appendToken(text: string, token: string) { + if (text.includes(token)) return { text, changed: false }; + + return { text: text ? `${text}\n\n${token}` : token, changed: true }; +} + +export function removeAppendedToken(text: string, token: string) { + if (text === token) return { text: "", changed: true }; + + const suffix = `\n\n${token}`; + if (text.endsWith(suffix)) { + return { text: text.slice(0, -suffix.length), changed: true }; + } + + return { text, changed: false }; +}