test: verify share bot API contract

This commit is contained in:
2026-08-16 01:19:20 +07:00 Unverified
parent c9f01231c6
commit f32171a576
3 changed files with 161 additions and 56 deletions
+19 -56
View File
@@ -1,11 +1,13 @@
import { timingSafeEqual } from "node:crypto";
import { cdnUrl } from "@/lib/cdn-images";
import { ensureDiscordUser } from "@/lib/auth/discord-user";
import { getCanonicalUrl, getShareBotSecret } from "@/lib/config/server";
import { getDiscordSenderProfile } from "@/lib/discord/discord";
import {
DiscordProfileLookupError,
getDiscordSenderProfile,
} from "@/lib/discord/discord";
buildBotShareResponse,
hasValidShareBotBearer,
rejectSuppliedBotProfile,
resolveBotSenderProfile,
} from "@/lib/share/bot";
import { createShare } from "@/lib/share/create";
import { ShareHttpError, shareErrorResponse } from "@/lib/share/http-error";
import { enforceShareRateLimit } from "@/lib/share/rate-limit";
@@ -19,19 +21,13 @@ export const dynamic = "force-dynamic";
function hasValidBearerToken(request: Request) {
const authorization = request.headers.get("authorization");
if (!authorization?.startsWith("Bearer ")) return false;
let expected: string;
try {
expected = getShareBotSecret();
} catch {
throw new ShareHttpError("Share bot API is not configured", 503);
}
const supplied = authorization.slice("Bearer ".length);
const suppliedBytes = Buffer.from(supplied);
const expectedBytes = Buffer.from(expected);
return suppliedBytes.length === expectedBytes.length &&
timingSafeEqual(suppliedBytes, expectedBytes);
return hasValidShareBotBearer(authorization, expected);
}
export async function POST(request: Request) {
@@ -53,19 +49,7 @@ export async function POST(request: Request) {
} catch {
throw new ShareHttpError("Malformed multipart form data", 400);
}
for (const field of [
"displayName",
"avatarUrl",
"authorName",
"authorAvatarUrl",
]) {
if (formData.has(field)) {
throw new ShareHttpError(
`Profile field ${field} must not be supplied`,
400,
);
}
}
rejectSuppliedBotProfile(formData);
const file = formData.get("file");
const senderDiscordId = formData.get("senderDiscordId");
if (!(file instanceof File)) {
@@ -83,21 +67,10 @@ export async function POST(request: Request) {
validateOptionalImage(formData.get("image")),
]);
const description = validateShareDescription(formData.get("description"));
let profile;
try {
profile = await getDiscordSenderProfile(senderDiscordId);
} catch (error) {
if (error instanceof DiscordProfileLookupError) {
throw new ShareHttpError("Discord profile lookup is unavailable", 503);
}
throw error;
}
if (!profile) {
throw new ShareHttpError(
"Discord sender was not found in the configured guild",
422
);
}
const profile = await resolveBotSenderProfile(
senderDiscordId,
getDiscordSenderProfile,
);
await enforceShareRateLimit({
key: `sender:${senderDiscordId}`,
@@ -123,24 +96,14 @@ export async function POST(request: Request) {
avatarUrl: profile.avatarUrl,
},
});
const url = `${baseUrl}/share/${share.id}`;
return Response.json(
{
success: true,
share: {
id: share.id,
url,
author: {
displayName,
avatarUrl: profile.avatarUrl,
},
imageUrl: share.imageCdnId
? `${baseUrl}${cdnUrl(share.imageCdnId)}`
: null,
createdAt: share.createdAt.toISOString(),
},
},
buildBotShareResponse({
baseUrl,
share,
displayName,
avatarUrl: profile.avatarUrl,
imagePath: share.imageCdnId ? cdnUrl(share.imageCdnId) : null,
}),
{ status: 201 }
);
} catch (error) {
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, test } from "bun:test";
import {
buildBotShareResponse,
hasValidShareBotBearer,
rejectSuppliedBotProfile,
resolveBotSenderProfile,
} from "@/lib/share/bot";
describe("share bot contract", () => {
test("requires an exact bearer secret", () => {
expect(hasValidShareBotBearer(null, "secret")).toBeFalse();
expect(hasValidShareBotBearer("Basic secret", "secret")).toBeFalse();
expect(hasValidShareBotBearer("Bearer wrong", "secret")).toBeFalse();
expect(hasValidShareBotBearer("Bearer secret", "secret")).toBeTrue();
});
test("rejects request-provided identity snapshots", () => {
const formData = new FormData();
formData.set("displayName", "Untrusted name");
expect(() => rejectSuppliedBotProfile(formData)).toThrow("must not be supplied");
});
test("distinguishes an invalid guild sender from lookup downtime", async () => {
await expect(
resolveBotSenderProfile("1", async () => null),
).rejects.toMatchObject({ status: 422 });
await expect(
resolveBotSenderProfile("1", async () => {
throw new Error("Discord unavailable");
}),
).rejects.toMatchObject({ status: 503 });
});
test("returns the documented successful response shape", () => {
expect(
buildBotShareResponse({
baseUrl: "https://example.com",
share: {
id: "a1b2c3d4",
createdAt: new Date("2026-08-16T00:00:00.000Z"),
},
displayName: "Example User",
avatarUrl: "https://cdn.discordapp.com/avatar.png",
imagePath: "/api/cdn/image-1",
}),
).toEqual({
success: true,
share: {
id: "a1b2c3d4",
url: "https://example.com/share/a1b2c3d4",
author: {
displayName: "Example User",
avatarUrl: "https://cdn.discordapp.com/avatar.png",
},
imageUrl: "https://example.com/api/cdn/image-1",
createdAt: "2026-08-16T00:00:00.000Z",
},
});
});
});
+82
View File
@@ -0,0 +1,82 @@
import { timingSafeEqual } from "node:crypto";
import { ShareHttpError } from "@/lib/share/http-error";
export type BotSenderProfile = {
id: string;
username: string;
globalName: string | null;
avatarUrl: string | null;
};
const FORBIDDEN_PROFILE_FIELDS = [
"displayName",
"avatarUrl",
"authorName",
"authorAvatarUrl",
] as const;
export function hasValidShareBotBearer(
authorization: string | null,
expected: string,
) {
if (!authorization?.startsWith("Bearer ")) return false;
const suppliedBytes = Buffer.from(authorization.slice("Bearer ".length));
const expectedBytes = Buffer.from(expected);
return suppliedBytes.length === expectedBytes.length &&
timingSafeEqual(suppliedBytes, expectedBytes);
}
export function rejectSuppliedBotProfile(formData: FormData) {
for (const field of FORBIDDEN_PROFILE_FIELDS) {
if (formData.has(field)) {
throw new ShareHttpError(
`Profile field ${field} must not be supplied`,
400,
);
}
}
}
export async function resolveBotSenderProfile(
discordId: string,
lookup: (discordId: string) => Promise<BotSenderProfile | null>,
) {
let profile: BotSenderProfile | null;
try {
profile = await lookup(discordId);
} catch {
throw new ShareHttpError("Discord profile lookup is unavailable", 503);
}
if (!profile) {
throw new ShareHttpError(
"Discord sender was not found in the configured guild",
422,
);
}
return profile;
}
export function buildBotShareResponse({
baseUrl,
share,
displayName,
avatarUrl,
imagePath,
}: {
baseUrl: string;
share: { id: string; createdAt: Date };
displayName: string;
avatarUrl: string | null;
imagePath: string | null;
}) {
return {
success: true as const,
share: {
id: share.id,
url: `${baseUrl}/share/${share.id}`,
author: { displayName, avatarUrl },
imageUrl: imagePath ? `${baseUrl}${imagePath}` : null,
createdAt: share.createdAt.toISOString(),
},
};
}