test: verify share bot API contract
This commit is contained in:
+19
-56
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user