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
133 changes: 133 additions & 0 deletions __tests__/message-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ describe("onMessage", () => {
"syncBuybackPricing",
"verify-write",
"verify-remove",
"org-verify-write",
"org-verify-remove",
],
},
});
Expand Down Expand Up @@ -504,6 +506,137 @@ describe("onMessage verify actions", () => {
});
});

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>`;

const contentPage = (history: string) =>
`<title>Description - Admin</title><textarea name="history">\n${history}</textarea>`;

type Rsi = {
content?: string;
preview?: string;
live?: string;
save?: unknown;
publish?: unknown;
};

const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status });

const mockRsi = ({
content = contentPage("Our board."),
preview = orgPage("<p>Our board.</p>"),
live = orgPage("<p>Our board.</p>"),
save = { success: 1 },
publish = { success: 1 },
}: Rsi = {}) =>
vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
const target = String(url);
if (target.endsWith("/en/orgs/MARU/admin/content")) return new Response(content);
if (target.endsWith("/en/orgs/MARU/admin/preview")) return new Response(preview);
if (target.endsWith("/en/orgs/MARU")) return new Response(live);
if (target.endsWith("/api/orgs/saveDraft")) return json(save);
if (target.endsWith("/api/orgs/publishDraft")) return json(publish);
throw new Error(`unexpected ${target}`);
});

const send = async (message: object) => {
const sendResponse = vi.fn();
await onMessage(
JSON.stringify(message),
sendResponse,
vi.fn().mockResolvedValue("rsi-token"),
"1.2.3"
);
return JSON.parse(sendResponse.mock.calls[0]![0]);
};

const posted = (fetch: ReturnType<typeof mockRsi>, path: string) =>
fetch.mock.calls
.filter(([url]) => String(url).endsWith(path))
.map(([, init]) => JSON.parse(String(init?.body)));

it("appends the token to the history and publishes it", async () => {
const fetch = mockRsi();

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

expect(result).toEqual({
code: 200,
action: "org-verify-write",
payload: { sid: "MARU", changed: true },
});
expect(posted(fetch, "/api/orgs/saveDraft")).toEqual([
{ symbol: "MARU", history: `Our board.\n\n${verificationToken}` },
]);
expect(posted(fetch, "/api/orgs/publishDraft")).toEqual([{ symbol: "MARU" }]);
});

it("answers 403 for an account without rights on the org", async () => {
const fetch = mockRsi({ content: "<title>Access denied - Roberts Space Industries</title>" });

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

expect(result.code).toBe(403);
expect(posted(fetch, "/api/orgs/saveDraft")).toEqual([]);
});

it("answers 409 while another edit waits in the draft", async () => {
const fetch = mockRsi({ preview: orgPage("<p>Our board.</p>", "Half done.") });

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

expect(result.code).toBe(409);
expect(posted(fetch, "/api/orgs/saveDraft")).toEqual([]);
expect(posted(fetch, "/api/orgs/publishDraft")).toEqual([]);
});

it("answers 422 for org pages it cannot read", async () => {
const fetch = mockRsi({ preview: "<html></html>" });

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

expect(result.code).toBe(422);
expect(posted(fetch, "/api/orgs/saveDraft")).toEqual([]);
});

it("does not publish when saving the draft was refused", async () => {
const fetch = mockRsi({ save: { success: 0, msg: "ErrCsrf" } });

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

expect(result.code).toBe(502);
expect(posted(fetch, "/api/orgs/publishDraft")).toEqual([]);
});

it("refuses an SID that is not one", async () => {
const fetch = vi.spyOn(globalThis, "fetch");

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

expect(result.code).toBe(400);
expect(fetch).not.toHaveBeenCalled();
});

it("removes the token it appended and publishes again", async () => {
const fetch = mockRsi({
content: contentPage(`Our board.\n\n${verificationToken}`),
live: orgPage(`<p>Our board.</p><p>${verificationToken}</p>`),
});

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

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

describe("handleResponse", () => {
it("posts message for fleetyards.net origin", () => {
const postMessage = vi.fn();
Expand Down
88 changes: 88 additions & 0 deletions __tests__/org.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, it, expect } from "vitest";
import {
contentBlocks,
hasPendingChanges,
isAccessDenied,
parseDraftField,
} from "@/lib/org";

// Trimmed from a live org page.
const orgPage = (history: string, manifesto = "Our manifesto.") => `
<title>Maru Inc. [MARU] - Organizations</title>
<div class="content block intro"><div class="markitup-text"><p>Welcome aboard.</p></div></div>
<div class="content-tab active" id="tab-history">
<h2 class="tab-title">History</h2>
<div class="markitup-text">${history}</div>
</div>
<div class="content-tab" id="tab-manifesto">
<h2 class="tab-title">Manifesto</h2>
<div class="markitup-text"><p>${manifesto}</p></div>
</div>`;

const contentPage = (history: string) => `
<title>Description - Admin - Maru Inc. [MARU]</title>
<textarea name="introduction" class="js-text-editor" maxlength="300">Welcome aboard.</textarea>
<textarea name="history" class="js-text-editor">
${history}</textarea>`;

describe("isAccessDenied", () => {
it("reads RSI's page for an account without rights", () => {
expect(isAccessDenied("<title>Access denied - Roberts Space Industries</title>")).toBe(true);
});

it("leaves the admin page alone", () => {
expect(isAccessDenied(contentPage("History"))).toBe(false);
});
});

describe("parseDraftField", () => {
it("reads the field's raw text, formatting included", () => {
expect(
parseDraftField(contentPage("h1. Our story\n\n*bold* &amp; more"), "history")
).toBe("h1. Our story\n\n*bold* & more");
});

it("refuses a page without the field", () => {
expect(parseDraftField("<title>Something else</title>", "history")).toBeNull();
});
});

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

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

describe("hasPendingChanges", () => {
it("sees nothing pending when only tokens differ", () => {
expect(
hasPendingChanges(
orgPage("<p>Our board.</p>"),
orgPage("<p>Our board.</p><p>FLEETYARDS-ABCDEFGHIJ</p>")
)
).toBe(false);
});

it("sees another officer's unpublished edit", () => {
expect(
hasPendingChanges(
orgPage("<p>Our board.</p>", "A new manifesto."),
orgPage("<p>Our board.</p>")
)
).toBe(true);
});

it("refuses to compare a page it cannot read", () => {
expect(hasPendingChanges("<html></html>", orgPage("<p>x</p>"))).toBeNull();
});
});
10 changes: 7 additions & 3 deletions lib/bio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const ENTITIES: Record<string, string> = {

// Null for an entity it does not know: written back as it stands, it would
// show up in the bio as literal text.
function decodeEntities(text: string): string | null {
export function decodeEntities(text: string): string | null {
let unknown = false;

const decoded = text.replace(
Expand Down Expand Up @@ -72,11 +72,15 @@ export function parseBio(html: string): string | null {

export class BioTooLongError extends Error {}

export function withToken(bio: string, token: string) {
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 > BIO_MAX_LENGTH) throw new BioTooLongError();
if (next.length > maxLength) throw new BioTooLongError();

return { bio: next, added: true };
}
Expand Down
Loading
Loading