Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 171 additions & 1 deletion __tests__/message-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,7 @@ describe("onMessage org verify actions", () => {
const verificationToken = "FLEETYARDS-ABCDEFGHIJ";

const orgPage = (history: string, manifesto = "Ours.") =>
`<div class="markitup-text"><p>Intro.</p></div><div class="markitup-text">${history}</div><div class="markitup-text"><p>${manifesto}</p></div>`;
`<div class="markitup-text"><p>Intro.</p></div><div id="tab-history"><div class="markitup-text">${history}</div></div><div id="tab-manifesto"><div class="markitup-text"><p>${manifesto}</p></div></div>`;

const contentPage = (history: string) =>
`<title>Description - Admin</title><textarea name="history">\n${history}</textarea>`;
Expand Down Expand Up @@ -621,6 +621,176 @@ describe("onMessage org verify actions", () => {
expect(fetch).not.toHaveBeenCalled();
});

it("publishes a token a failed run left in the draft", async () => {
const fetch = mockRsi({
content: contentPage(`Our board.\n\n${verificationToken}`),
});

const result = await send({ action: "org-verify-write", sid: "MARU", token: verificationToken });

expect(result.payload).toEqual({ sid: "MARU", changed: true });
expect(posted(fetch, "/api/orgs/saveDraft")).toEqual([]);
expect(posted(fetch, "/api/orgs/publishDraft")).toEqual([{ symbol: "MARU" }]);
});

it("does nothing for a token already in the draft and live", async () => {
const fetch = mockRsi({
content: contentPage(`Our board.\n\n${verificationToken}`),
live: orgPage(`<p>Our board.</p><p>${verificationToken}</p>`),
preview: orgPage(`<p>Our board.</p><p>${verificationToken}</p>`),
});

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("<p>Our board.</p>")
: orgPage("<p>Our board.</p>", "Someone else's draft.")
);
}
if (target.endsWith("/en/orgs/MARU")) return new Response(orgPage("<p>Our board.</p>"));
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(`<p>${verificationToken}</p><p>Our board.</p>`),
});

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 ? "<p>Our board.</p>" : "<p>Our new board.</p>")
);
}
if (target.endsWith("/en/orgs/MARU")) return new Response(orgPage("<p>Our board.</p>"));
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("<p>Our board.</p>", previewReads === 1 ? "Ours." : "Half done.")
);
}
if (target.endsWith("/en/orgs/MARU")) return new Response(orgPage("<p>Our board.</p>"));
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(`<p>Our board.</p><p>${verificationToken}</p>`, verificationToken),
preview: orgPage(`<p>Our board.</p><p>${verificationToken}</p>`, 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("<p>Our board.</p>"));
if (target.endsWith("/en/orgs/MARU")) return new Response(orgPage("<p>Our board.</p>"));
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 Promise<void>((resolve) => (releaseWrite = resolve));
}
draft = history;
}
return json({ success: 1 });
});

const write = send({ action: "org-verify-write", sid: "MARU", token: verificationToken });
await vi.waitFor(() => expect(order).toEqual(["save-token"]));
const remove = send({ action: "org-verify-remove", sid: "MARU", token: verificationToken });
releaseWrite();
await write;
await remove;

expect(order).toEqual(["save-token", "save-clean"]);
});

it("removes the token it appended and publishes again", async () => {
const fetch = mockRsi({
content: contentPage(`Our board.\n\n${verificationToken}`),
Expand Down
76 changes: 70 additions & 6 deletions __tests__/org.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { describe, it, expect } from "vitest";
import {
contentBlocks,
contentSections,
hasPendingChanges,
isAccessDenied,
parseDraftField,
tokenOnPage,
} from "@/lib/org";

// Trimmed from a live org page.
Expand Down Expand Up @@ -47,19 +48,32 @@ describe("parseDraftField", () => {
});
});

describe("contentBlocks", () => {
it("reads every text block as plain text without tokens", () => {
describe("contentSections", () => {
it("reads every section by name, without tokens", () => {
expect(
contentBlocks(
contentSections(
orgPage(
'<p>Our board.</p>\n\n<p><span class="caps">FLEETYARDS</span>-ABCDEFGHIJ</p>'
)
)
).toEqual(["Welcome aboard.", "Our board.", "Our manifesto."]);
).toEqual(
new Map([
["introduction", "<p>Welcome aboard.</p>"],
["history", "<p>Our board.</p>"],
["manifesto", "<p>Our manifesto.</p>"],
])
);
});

it("reads a section past the divs Textile nests in it", () => {
expect(
contentSections(orgPage("<div class=\"note\"><p>Inner</p></div><p>After</p>"))
?.get("history")
).toBe('<div class="note"><p>Inner</p></div><p>After</p>');
});

it("refuses a page without text blocks", () => {
expect(contentBlocks("<title>Access denied</title>")).toBeNull();
expect(contentSections("<title>Access denied</title>")).toBeNull();
});
});

Expand All @@ -82,7 +96,57 @@ describe("hasPendingChanges", () => {
).toBe(true);
});

it("sees a change that only touches markup", () => {
expect(
hasPendingChanges(
orgPage('<p>Our board. <img src="new.png" /></p>'),
orgPage('<p>Our board. <img src="old.png" /></p>')
)
).toBe(true);
});

it("sees a change after a nested div", () => {
expect(
hasPendingChanges(
orgPage("<div><p>Same</p></div><p>Edited</p>"),
orgPage("<div><p>Same</p></div><p>Original</p>")
)
).toBe(true);
});

it("reads a section one page leaves out as empty", () => {
const withEmptyCharter = `${orgPage("<p>Our board.</p>")}<div class="content-tab" id="tab-charter"><div class="markitup-text"></div></div>`;

expect(
hasPendingChanges(withEmptyCharter, orgPage("<p>Our board.</p>"))
).toBe(false);
});

it("refuses to compare a page it cannot read", () => {
expect(hasPendingChanges("<html></html>", orgPage("<p>x</p>"))).toBeNull();
});
});

describe("tokenOnPage", () => {
const token = "FLEETYARDS-ABCDEFGHIJ";

it("finds a token Textile split with a span in the history", () => {
expect(
tokenOnPage(
orgPage('<p>Our board.</p><p><span class="caps">FLEETYARDS</span>-ABCDEFGHIJ</p>'),
token,
"history"
)
).toEqual({ inField: true, elsewhere: false });
});

it("tells a token in another section apart", () => {
expect(
tokenOnPage(orgPage("<p>Our board.</p>", token), token, "history")
).toEqual({ inField: false, elsewhere: true });
});

it("refuses a page it cannot read", () => {
expect(tokenOnPage("<html></html>", token, "history")).toBeNull();
});
});
29 changes: 9 additions & 20 deletions lib/bio.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
amp: "&",
Expand Down Expand Up @@ -72,28 +74,15 @@ export function parseBio(html: string): string | null {

export class BioTooLongError extends Error {}

export function withToken(
bio: string,
token: string,
maxLength = BIO_MAX_LENGTH
) {
if (bio.includes(token)) return { bio, added: false };

const next = bio ? `${bio}\n\n${token}` : token;
if (next.length > maxLength) throw new BioTooLongError();
export function withToken(bio: string, token: string) {
const { text, changed } = appendToken(bio, token);
if (text.length > BIO_MAX_LENGTH) throw new BioTooLongError();

return { bio: next, added: true };
return { bio: text, added: changed };
}

// Only the token as withToken appended it: one the user put elsewhere in their
// bio themselves is theirs to remove.
export function withoutToken(bio: string, token: string) {
if (bio === token) return { bio: "", removed: true };

const suffix = `\n\n${token}`;
if (bio.endsWith(suffix)) {
return { bio: bio.slice(0, -suffix.length), removed: true };
}
const { text, changed } = removeAppendedToken(bio, token);

return { bio, removed: false };
return { bio: text, removed: changed };
}
Loading
Loading