Skip to content
Open
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
5 changes: 3 additions & 2 deletions packages/bot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"deploy": "wrangler deploy -e production",
"dev": "wrangler dev --port 8787",
"start": "bun run dev",
"test": "vitest run",
"cf-typegen": "wrangler types",
"format": "biome format --write ./src",
"lint": "biome lint --write ./src",
Expand All @@ -19,10 +20,10 @@
"devDependencies": {
"@biomejs/biome": "^2.3.12",
"@cloudflare/vitest-pool-workers": "^0.12.4",
"@types/bun": "latest",
"typescript": "^5.5.2",
"vitest": "~3.2.0",
"wrangler": "^4.67.0",
"@types/bun": "latest"
"wrangler": "^4.67.0"
},
"dependencies": {
"@shorter2/types": "workspace:*",
Expand Down
16 changes: 11 additions & 5 deletions packages/bot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,16 @@ app.post("/", async (c) => {
const slug = subcommand.options?.find((opt) => opt.name === "alias")
?.value as string;

await deleteLink(slug, c.env.SHORTER_API_KEY);
return sendChannelMessage(
`Shortlink https://s.acmcsuf.com/${slug} deleted successfully`,
);
try {
await deleteLink(slug, c.env.SHORTER_API_KEY);
return sendChannelMessage(
`Shortlink https://s.acmcsuf.com/${slug} deleted successfully`,
);
} catch (error: unknown) {
return sendChannelMessage(
`Failed to delete shortlink: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}

// ==== Update Subcommand ====================================================================
Expand Down Expand Up @@ -161,7 +167,7 @@ app.post("/", async (c) => {
`now redirects with HTTP ${resp.isPermanent ? 301 : 302}`,
);
return sendChannelMessage(
`Shortlink created: ${parts.join(" and ")}`,
`Shortlink updated: ${parts.join(" and ")}`,
);
} catch (error: unknown) {
return sendChannelMessage(
Expand Down
23 changes: 14 additions & 9 deletions packages/bot/src/shorter-client.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { env } from "cloudflare:workers";
import type {
CreateLinkDto,
CreateLinkInputDto,
UpdateLinkDto,
UpdateLinkInputDto,
} from "@shorter/service";
import { ShortlinkModel, ShortlinkUpdateRequest } from "@shorter2/types";
import type { z } from "zod";

const endpoint = `${env.SHORTER_ENDPOINT}/links`;
type ShortlinkDto = z.infer<typeof ShortlinkModel>;
type CreateLinkInputDto = {
slug?: string;
url: string;
isPermanent?: boolean;
};
type UpdateLinkInputDto = z.input<typeof ShortlinkUpdateRequest>;
type UpdateLinkDto = Pick<ShortlinkDto, "url" | "isPermanent">;

const endpoint = `${env.SHORTER_ENDPOINT}/_links`;

const setHeaders = (authToken: string) => {
return {
Expand All @@ -18,7 +23,7 @@ const setHeaders = (authToken: string) => {
export async function addLink(
link: CreateLinkInputDto,
authToken: string,
): Promise<CreateLinkDto> {
): Promise<ShortlinkDto> {
const response = await fetch(endpoint, {
method: "POST",
headers: setHeaders(authToken),
Expand All @@ -30,7 +35,7 @@ export async function addLink(
throw new Error(`HTTP ${response.status}: ${errText}`);
}

const data = (await response.json()) as { success: boolean; link: CreateLinkDto };
const data = (await response.json()) as { success: boolean; link: ShortlinkDto };
return data.link;
}

Expand Down
209 changes: 209 additions & 0 deletions packages/bot/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { InteractionResponseType, InteractionType } from "discord-interactions";
import { beforeEach, describe, expect, it, vi } from "vitest";

const verifyKeyMock = vi.fn();
const addLinkMock = vi.fn();
const deleteLinkMock = vi.fn();
const updateLinkMock = vi.fn();

vi.mock("discord-interactions", async () => {
const actual = await vi.importActual<typeof import("discord-interactions")>(
"discord-interactions",
);
return {
...actual,
verifyKey: (...args: unknown[]) => verifyKeyMock(...args),
};
});

vi.mock("../src/shorter-client", () => ({
addLink: (...args: unknown[]) => addLinkMock(...args),
deleteLink: (...args: unknown[]) => deleteLinkMock(...args),
updateLink: (...args: unknown[]) => updateLinkMock(...args),
}));

const { default: app } = await import("../src/index");

function createEnv(overrides: Partial<Env> = {}): Env {
return {
DISCORD_PUBLIC_KEY: "public-key",
DISCORD_GUILD_ID: "allowed-guild",
SHORTER_API_KEY: "test-api-key",
SHORTER_ENDPOINT: "https://s.acmcsuf.com",
...overrides,
} as Env;
}

function commandBody(
subcommandName: string,
options: Array<{ name: string; value: string | boolean }> = [],
overrides: Record<string, unknown> = {},
) {
return JSON.stringify({
type: InteractionType.APPLICATION_COMMAND,
guild_id: "allowed-guild",
data: {
name: "shorter2",
options: [
{
name: subcommandName,
type: 1,
options,
},
],
},
...overrides,
});
}

async function postInteraction(body: string, env: Env = createEnv()) {
return app.request(
"/",
{
method: "POST",
headers: {
"x-signature-ed25519": "signature",
"x-signature-timestamp": "timestamp",
"content-type": "application/json",
},
body,
},
env,
);
}

beforeEach(() => {
vi.clearAllMocks();
verifyKeyMock.mockResolvedValue(true);
});

describe("bot interaction handler", () => {
it("rejects requests with an invalid Discord signature", async () => {
verifyKeyMock.mockResolvedValue(false);

const response = await postInteraction(JSON.stringify({ type: InteractionType.PING }));

expect(response.status).toBe(401);
expect(await response.text()).toBe("Bad request signature.");
});

it("responds to Discord ping interactions", async () => {
const response = await postInteraction(
JSON.stringify({ type: InteractionType.PING }),
);

expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
type: InteractionResponseType.PONG,
});
});

it("blocks commands from the wrong guild before mutating resources", async () => {
const response = await postInteraction(
commandBody("add"),
createEnv({ DISCORD_GUILD_ID: "different-guild" }),
);

expect(addLinkMock).not.toHaveBeenCalled();
await expect(response.json()).resolves.toEqual({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "Error: resource cannot be modified from this Discord server",
},
});
});

it("rejects invalid add destinations without calling the service", async () => {
const response = await postInteraction(
commandBody("add", [{ name: "destination", value: "ftp://example.com" }]),
);

expect(addLinkMock).not.toHaveBeenCalled();
await expect(response.json()).resolves.toEqual({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content:
"Error: invalid URL. Does the destination URL start with `http://` or `https://`?",
},
});
});

it("surfaces service failures when creating links", async () => {
addLinkMock.mockRejectedValue(new Error("HTTP 409: duplicate slug"));

const response = await postInteraction(
commandBody("add", [
{ name: "destination", value: "https://example.com" },
{ name: "alias", value: "example" },
]),
);

expect(addLinkMock).toHaveBeenCalledWith(
{
slug: "example",
url: "https://example.com",
isPermanent: undefined,
},
"test-api-key",
);
await expect(response.json()).resolves.toEqual({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "Failed to create shortlink: HTTP 409: duplicate slug",
},
});
});

it("rejects update commands that do not include any changes", async () => {
const response = await postInteraction(
commandBody("update", [{ name: "alias", value: "example" }]),
);

expect(updateLinkMock).not.toHaveBeenCalled();
await expect(response.json()).resolves.toEqual({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "Error: no modifications to shortlink provided",
},
});
});

it("reports delete failures as channel messages instead of throwing", async () => {
deleteLinkMock.mockRejectedValue(new Error("HTTP 404: missing"));

const response = await postInteraction(
commandBody("delete", [{ name: "alias", value: "missing" }]),
);

expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "Failed to delete shortlink: HTTP 404: missing",
},
});
});

it("describes update results with the updated redirect mode", async () => {
updateLinkMock.mockResolvedValue({
url: "https://example.com/new",
isPermanent: true,
});

const response = await postInteraction(
commandBody("update", [
{ name: "alias", value: "example" },
{ name: "destination", value: "https://example.com/new" },
{ name: "is_permanent", value: true },
]),
);

await expect(response.json()).resolves.toEqual({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content:
"Shortlink updated: https://s.acmcsuf.com/example -> https://example.com/new and now redirects with HTTP 301",
},
});
});
});
81 changes: 81 additions & 0 deletions packages/bot/test/shorter-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const fetchMock = vi.fn();

vi.mock("cloudflare:workers", () => ({
env: {
SHORTER_ENDPOINT: "https://s.acmcsuf.com",
},
}));

const { addLink, deleteLink, updateLink } = await import("../src/shorter-client");

beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});

describe("shorter client", () => {
it("posts new links to the protected service endpoint", async () => {
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
success: true,
link: {
slug: "example",
url: "https://example.com",
isPermanent: false,
},
}),
{ status: 200 },
),
);

const link = await addLink(
{ slug: "example", url: "https://example.com" },
"api-key",
);

expect(fetchMock).toHaveBeenCalledWith("https://s.acmcsuf.com/_links", {
method: "POST",
headers: {
Authorization: "Bearer api-key",
"Content-Type": "application/json",
},
body: JSON.stringify({
slug: "example",
url: "https://example.com",
}),
});
expect(link.slug).toBe("example");
});

it("uses the protected slug route for deletes", async () => {
fetchMock.mockResolvedValue(new Response(null, { status: 204 }));

await deleteLink("example", "api-key");

expect(fetchMock).toHaveBeenCalledWith(
"https://s.acmcsuf.com/_links/example",
{
method: "DELETE",
headers: {
Authorization: "Bearer api-key",
"Content-Type": "application/json",
},
},
);
});

it("throws the upstream status and body when updates fail", async () => {
fetchMock.mockResolvedValue(
new Response("not found", {
status: 404,
}),
);

await expect(
updateLink("missing", { url: "https://example.com/new" }, "api-key"),
).rejects.toThrow("HTTP 404: not found");
});
});
7 changes: 7 additions & 0 deletions packages/bot/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
},
});