feat: add attachment upload and download flow
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
199
src/attachment-service.ts
Normal file
199
src/attachment-service.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import type { Env } from "./env";
|
||||
import type { CurrentUser } from "./auth-session";
|
||||
import { HttpProblem } from "./problem";
|
||||
import { newId } from "./ids";
|
||||
import { optionalString, requiredString } from "./json";
|
||||
import { getObjectOrNull, objectKeyFor, putObject } from "./object-store";
|
||||
import {
|
||||
findAttachmentForUser,
|
||||
insertAttachment,
|
||||
insertAuditEvent,
|
||||
markAvailable,
|
||||
markUploaded,
|
||||
softDeleteAttachment,
|
||||
type AttachmentRecord,
|
||||
} from "./attachment-store";
|
||||
|
||||
const DEFAULT_MAX_UPLOAD_BYTES = 52_428_800;
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function attachmentNotFound(): HttpProblem {
|
||||
return new HttpProblem(404, "attachment-not-found", "Attachment was not found");
|
||||
}
|
||||
|
||||
function parseMaxUploadBytes(env: Env): number {
|
||||
const parsed = Number.parseInt(env.MAX_UPLOAD_BYTES ?? "", 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_UPLOAD_BYTES;
|
||||
}
|
||||
|
||||
async function recordAudit(
|
||||
env: Env,
|
||||
user: CurrentUser,
|
||||
attachmentId: string,
|
||||
action: string,
|
||||
detail: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await insertAuditEvent(env.DB, {
|
||||
id: newId("aud"),
|
||||
user_id: user.id,
|
||||
attachment_id: attachmentId,
|
||||
actor_user_id: user.id,
|
||||
action,
|
||||
detail_json: JSON.stringify(detail),
|
||||
created_at: nowIso(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createAttachment(
|
||||
env: Env,
|
||||
user: CurrentUser,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<AttachmentRecord> {
|
||||
const filename = requiredString(body, "filename");
|
||||
const content_type = requiredString(body, "content_type");
|
||||
const visibility = optionalString(body, "visibility") ?? "private";
|
||||
const description = optionalString(body, "description") ?? null;
|
||||
const category_id = optionalString(body, "categoryId") ?? optionalString(body, "category_id") ?? null;
|
||||
const now = nowIso();
|
||||
const id = newId("att");
|
||||
|
||||
const record: AttachmentRecord = {
|
||||
id,
|
||||
user_id: user.id,
|
||||
status: "upload_pending",
|
||||
filename,
|
||||
content_type,
|
||||
byte_size: null,
|
||||
checksum_sha256: null,
|
||||
visibility,
|
||||
category_id,
|
||||
description,
|
||||
object_key: null,
|
||||
etag: null,
|
||||
created_by_user_id: user.id,
|
||||
updated_by_user_id: user.id,
|
||||
deleted_by_user_id: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
deleted_at: null,
|
||||
};
|
||||
|
||||
await insertAttachment(env.DB, record);
|
||||
await recordAudit(env, user, id, "attachment.created", { filename, content_type });
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function uploadAttachmentContent(
|
||||
env: Env,
|
||||
user: CurrentUser,
|
||||
id: string,
|
||||
request: Request,
|
||||
): Promise<AttachmentRecord> {
|
||||
const record = await findAttachmentForUser(env.DB, user.id, id);
|
||||
if (!record) {
|
||||
throw attachmentNotFound();
|
||||
}
|
||||
if (record.status !== "upload_pending") {
|
||||
throw new HttpProblem(409, "attachment-state-conflict", "Attachment is not awaiting an upload", {
|
||||
status: record.status,
|
||||
});
|
||||
}
|
||||
|
||||
const maxBytes = parseMaxUploadBytes(env);
|
||||
const declaredLength = Number.parseInt(request.headers.get("content-length") ?? "", 10);
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
||||
throw new HttpProblem(413, "payload-too-large", "Upload exceeds the maximum allowed size", {
|
||||
max_bytes: maxBytes,
|
||||
});
|
||||
}
|
||||
|
||||
const objectKey = objectKeyFor(user.id, id);
|
||||
const r2Object = await putObject(env.ATTACHMENTS, objectKey, request, record.content_type);
|
||||
const etag = (r2Object.etag ?? "").replace(/^"|"$/g, "");
|
||||
await markUploaded(env.DB, user.id, id, objectKey, etag, r2Object.size);
|
||||
await recordAudit(env, user, id, "attachment.uploaded", { object_key: objectKey, byte_size: r2Object.size });
|
||||
|
||||
const refreshed = await findAttachmentForUser(env.DB, user.id, id);
|
||||
return refreshed ?? record;
|
||||
}
|
||||
|
||||
export async function finalizeAttachment(
|
||||
env: Env,
|
||||
user: CurrentUser,
|
||||
id: string,
|
||||
): Promise<AttachmentRecord> {
|
||||
const record = await findAttachmentForUser(env.DB, user.id, id);
|
||||
if (!record) {
|
||||
throw attachmentNotFound();
|
||||
}
|
||||
if (record.status !== "uploaded") {
|
||||
throw new HttpProblem(409, "attachment-state-conflict", "Attachment is not ready to finalize", {
|
||||
status: record.status,
|
||||
});
|
||||
}
|
||||
|
||||
await markAvailable(env.DB, user.id, id);
|
||||
await recordAudit(env, user, id, "attachment.finalized", {});
|
||||
|
||||
const refreshed = await findAttachmentForUser(env.DB, user.id, id);
|
||||
return refreshed ?? record;
|
||||
}
|
||||
|
||||
export async function getAttachment(
|
||||
env: Env,
|
||||
user: CurrentUser,
|
||||
id: string,
|
||||
): Promise<AttachmentRecord> {
|
||||
const record = await findAttachmentForUser(env.DB, user.id, id);
|
||||
if (!record) {
|
||||
throw attachmentNotFound();
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function deleteAttachment(env: Env, user: CurrentUser, id: string): Promise<void> {
|
||||
const record = await findAttachmentForUser(env.DB, user.id, id);
|
||||
if (!record) {
|
||||
throw attachmentNotFound();
|
||||
}
|
||||
|
||||
await softDeleteAttachment(env.DB, user.id, id, user.id);
|
||||
if (record.object_key) {
|
||||
await env.ATTACHMENTS.delete(record.object_key).catch(() => undefined);
|
||||
}
|
||||
await recordAudit(env, user, id, "attachment.deleted", {});
|
||||
}
|
||||
|
||||
export async function downloadAttachment(env: Env, user: CurrentUser, id: string): Promise<Response> {
|
||||
const record = await findAttachmentForUser(env.DB, user.id, id);
|
||||
if (!record) {
|
||||
throw attachmentNotFound();
|
||||
}
|
||||
if (record.status !== "available") {
|
||||
throw new HttpProblem(409, "object-not-available", "Attachment is not available for download", {
|
||||
status: record.status,
|
||||
});
|
||||
}
|
||||
if (!record.object_key) {
|
||||
throw new HttpProblem(410, "object-gone", "Attachment object is missing");
|
||||
}
|
||||
|
||||
const object = await getObjectOrNull(env.ATTACHMENTS, record.object_key);
|
||||
if (!object) {
|
||||
throw new HttpProblem(410, "object-gone", "Attachment object is missing");
|
||||
}
|
||||
|
||||
const headers = new Headers({
|
||||
"content-type": record.content_type,
|
||||
"content-length": String(object.size),
|
||||
"content-disposition": `attachment; filename="${sanitizeFilename(record.filename)}"`,
|
||||
});
|
||||
return new Response(object.body, { headers });
|
||||
}
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/["\\]/g, "_");
|
||||
}
|
||||
127
src/attachment-store.ts
Normal file
127
src/attachment-store.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
export type AttachmentStatus = "upload_pending" | "uploaded" | "available" | "deleted";
|
||||
|
||||
export interface AttachmentRecord {
|
||||
id: string;
|
||||
user_id: string;
|
||||
status: AttachmentStatus;
|
||||
filename: string;
|
||||
content_type: string;
|
||||
byte_size: number | null;
|
||||
checksum_sha256: string | null;
|
||||
visibility: string;
|
||||
category_id: string | null;
|
||||
description: string | null;
|
||||
object_key: string | null;
|
||||
etag: string | null;
|
||||
created_by_user_id: string;
|
||||
updated_by_user_id: string;
|
||||
deleted_by_user_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
const ATTACHMENT_COLUMNS =
|
||||
"id, user_id, status, filename, content_type, byte_size, checksum_sha256, visibility, category_id, description, object_key, etag, created_by_user_id, updated_by_user_id, deleted_by_user_id, created_at, updated_at, deleted_at";
|
||||
|
||||
export async function insertAttachment(db: D1Database, record: AttachmentRecord): Promise<void> {
|
||||
await db
|
||||
.prepare(`INSERT INTO attachments (${ATTACHMENT_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.bind(
|
||||
record.id,
|
||||
record.user_id,
|
||||
record.status,
|
||||
record.filename,
|
||||
record.content_type,
|
||||
record.byte_size,
|
||||
record.checksum_sha256,
|
||||
record.visibility,
|
||||
record.category_id,
|
||||
record.description,
|
||||
record.object_key,
|
||||
record.etag,
|
||||
record.created_by_user_id,
|
||||
record.updated_by_user_id,
|
||||
record.deleted_by_user_id,
|
||||
record.created_at,
|
||||
record.updated_at,
|
||||
record.deleted_at,
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function findAttachmentForUser(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
id: string,
|
||||
): Promise<AttachmentRecord | null> {
|
||||
return db
|
||||
.prepare(`SELECT ${ATTACHMENT_COLUMNS} FROM attachments WHERE id = ? AND user_id = ? AND deleted_at IS NULL`)
|
||||
.bind(id, userId)
|
||||
.first<AttachmentRecord>();
|
||||
}
|
||||
|
||||
export async function markUploaded(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
id: string,
|
||||
objectKey: string,
|
||||
etag: string | null,
|
||||
byteSize: number | null,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE attachments
|
||||
SET status = 'uploaded', object_key = ?, etag = ?, byte_size = ?, updated_at = ?
|
||||
WHERE id = ? AND user_id = ? AND deleted_at IS NULL`,
|
||||
)
|
||||
.bind(objectKey, etag, byteSize, new Date().toISOString(), id, userId)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function markAvailable(db: D1Database, userId: string, id: string): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE attachments SET status = 'available', updated_at = ?
|
||||
WHERE id = ? AND user_id = ? AND deleted_at IS NULL`,
|
||||
)
|
||||
.bind(new Date().toISOString(), id, userId)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function softDeleteAttachment(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
id: string,
|
||||
deletedByUserId: string,
|
||||
): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE attachments
|
||||
SET status = 'deleted', deleted_at = ?, deleted_by_user_id = ?, updated_at = ?
|
||||
WHERE id = ? AND user_id = ? AND deleted_at IS NULL`,
|
||||
)
|
||||
.bind(now, deletedByUserId, now, id, userId)
|
||||
.run();
|
||||
}
|
||||
|
||||
export interface AuditEvent {
|
||||
id: string;
|
||||
user_id: string;
|
||||
attachment_id: string | null;
|
||||
actor_user_id: string;
|
||||
action: string;
|
||||
detail_json: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function insertAuditEvent(db: D1Database, event: AuditEvent): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO attachment_audit_events (id, user_id, attachment_id, actor_user_id, action, detail_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(event.id, event.user_id, event.attachment_id, event.actor_user_id, event.action, event.detail_json, event.created_at)
|
||||
.run();
|
||||
}
|
||||
5
src/ids.ts
Normal file
5
src/ids.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function newId(prefix: string): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
return `${prefix}_${hex}`;
|
||||
}
|
||||
3
src/index.ts
Normal file
3
src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createRoutes } from "./routes";
|
||||
|
||||
export default createRoutes();
|
||||
26
src/json.ts
Normal file
26
src/json.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { HttpProblem } from "./problem";
|
||||
|
||||
export async function readJsonObject(request: Request): Promise<Record<string, unknown>> {
|
||||
const body = await request.json().catch(() => undefined);
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
||||
throw new HttpProblem(422, "validation-failed", "Request body must be a JSON object");
|
||||
}
|
||||
return body as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function requiredString(body: Record<string, unknown>, key: string): string {
|
||||
const value = body[key];
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new HttpProblem(422, "validation-failed", `${key} is required`, { field: key });
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function optionalString(body: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = body[key];
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
if (typeof value !== "string") {
|
||||
throw new HttpProblem(422, "validation-failed", `${key} must be a string`, { field: key });
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
24
src/object-store.ts
Normal file
24
src/object-store.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export function objectKeyFor(userId: string, attachmentId: string): string {
|
||||
const date = new Date();
|
||||
const yyyy = String(date.getUTCFullYear());
|
||||
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
return `users/${userId}/attachments/${yyyy}/${mm}/${attachmentId}/original`;
|
||||
}
|
||||
|
||||
export async function putObject(
|
||||
bucket: R2Bucket,
|
||||
key: string,
|
||||
request: Request,
|
||||
contentType: string,
|
||||
): Promise<R2Object> {
|
||||
const uploaded = await bucket.put(key, request.body, {
|
||||
httpMetadata: {
|
||||
contentType,
|
||||
},
|
||||
});
|
||||
return uploaded as R2Object;
|
||||
}
|
||||
|
||||
export async function getObjectOrNull(bucket: R2Bucket, key: string): Promise<R2ObjectBody | null> {
|
||||
return bucket.get(key);
|
||||
}
|
||||
58
src/routes.ts
Normal file
58
src/routes.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Hono } from "hono";
|
||||
import { resolveCurrentUser } from "./auth-session";
|
||||
import type { Env } from "./env";
|
||||
import { readJsonObject } from "./json";
|
||||
import { problemResponse } from "./problem";
|
||||
import {
|
||||
createAttachment,
|
||||
deleteAttachment,
|
||||
downloadAttachment,
|
||||
finalizeAttachment,
|
||||
getAttachment,
|
||||
uploadAttachmentContent,
|
||||
} from "./attachment-service";
|
||||
|
||||
export function createRoutes(): Hono<{ Bindings: Env }> {
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
app.onError((error) => problemResponse(error));
|
||||
|
||||
app.get("/healthz", (c) => c.json({ ok: true, service: "cfw-attachment" }));
|
||||
|
||||
app.post("/api/attachments", async (c) => {
|
||||
const user = await resolveCurrentUser(c.req.raw, c.env);
|
||||
const body = await readJsonObject(c.req.raw);
|
||||
const attachment = await createAttachment(c.env, user, body);
|
||||
return c.json({ attachment }, 201);
|
||||
});
|
||||
|
||||
app.get("/api/attachments/:id", async (c) => {
|
||||
const user = await resolveCurrentUser(c.req.raw, c.env);
|
||||
return c.json({ attachment: await getAttachment(c.env, user, c.req.param("id")) });
|
||||
});
|
||||
|
||||
app.put("/api/attachments/:id/content", async (c) => {
|
||||
const user = await resolveCurrentUser(c.req.raw, c.env);
|
||||
return c.json({
|
||||
attachment: await uploadAttachmentContent(c.env, user, c.req.param("id"), c.req.raw),
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/api/attachments/:id/finalize", async (c) => {
|
||||
const user = await resolveCurrentUser(c.req.raw, c.env);
|
||||
return c.json({ attachment: await finalizeAttachment(c.env, user, c.req.param("id")) });
|
||||
});
|
||||
|
||||
app.get("/api/attachments/:id/download", async (c) => {
|
||||
const user = await resolveCurrentUser(c.req.raw, c.env);
|
||||
return downloadAttachment(c.env, user, c.req.param("id"));
|
||||
});
|
||||
|
||||
app.delete("/api/attachments/:id", async (c) => {
|
||||
const user = await resolveCurrentUser(c.req.raw, c.env);
|
||||
await deleteAttachment(c.env, user, c.req.param("id"));
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
137
tests/attachment-worker.test.ts
Normal file
137
tests/attachment-worker.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import app from "../src/index";
|
||||
import { makeEnv, sharedStores } from "./fakes";
|
||||
|
||||
const BASE = "https://attachment.local";
|
||||
|
||||
async function call(
|
||||
method: string,
|
||||
path: string,
|
||||
env: unknown,
|
||||
init: { body?: BodyInit; headers?: Record<string, string> } = {},
|
||||
): Promise<Response> {
|
||||
const request = new Request(`${BASE}${path}`, {
|
||||
method,
|
||||
body: init.body,
|
||||
headers: init.headers,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return app.fetch(request, env as any);
|
||||
}
|
||||
|
||||
async function createAttachment(
|
||||
env: unknown,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<{ attachment: { id: string; status: string; user_id: string } }> {
|
||||
const response = await call("POST", "/api/attachments", env, { body: JSON.stringify(body) });
|
||||
expect(response.status).toBe(201);
|
||||
return (await response.json()) as { attachment: { id: string; status: string; user_id: string } };
|
||||
}
|
||||
|
||||
describe("cfw-attachment worker (core flow)", () => {
|
||||
it("creates an attachment for the current user and hides it from another user", async () => {
|
||||
const stores = sharedStores();
|
||||
const userA = makeEnv(stores, "user_a");
|
||||
const userB = makeEnv(stores, "user_b");
|
||||
|
||||
const created = await createAttachment(userA, {
|
||||
filename: "invoice.pdf",
|
||||
content_type: "application/pdf",
|
||||
});
|
||||
expect(created.attachment.status).toBe("upload_pending");
|
||||
expect(created.attachment.user_id).toBe("user_a");
|
||||
|
||||
const asOwner = await call("GET", `/api/attachments/${created.attachment.id}`, userA);
|
||||
expect(asOwner.status).toBe(200);
|
||||
|
||||
const asOther = await call("GET", `/api/attachments/${created.attachment.id}`, userB);
|
||||
expect(asOther.status).toBe(404);
|
||||
expect(asOther.headers.get("content-type")).toBe("application/problem+json");
|
||||
});
|
||||
|
||||
it("uploads content, finalizes the attachment, and downloads the same bytes", async () => {
|
||||
const stores = sharedStores();
|
||||
const env = makeEnv(stores, "user_a");
|
||||
const payload = "hello attachment bytes";
|
||||
|
||||
const created = await createAttachment(env, {
|
||||
filename: "note.txt",
|
||||
content_type: "text/plain",
|
||||
});
|
||||
|
||||
const uploaded = await call("PUT", `/api/attachments/${created.attachment.id}/content`, env, {
|
||||
body: payload,
|
||||
headers: { "content-length": String(new TextEncoder().encode(payload).byteLength) },
|
||||
});
|
||||
expect(uploaded.status).toBe(200);
|
||||
const uploadedBody = (await uploaded.json()) as { attachment: { status: string; byte_size: number } };
|
||||
expect(uploadedBody.attachment.status).toBe("uploaded");
|
||||
expect(uploadedBody.attachment.byte_size).toBe(new TextEncoder().encode(payload).byteLength);
|
||||
|
||||
const finalized = await call("POST", `/api/attachments/${created.attachment.id}/finalize`, env);
|
||||
expect(finalized.status).toBe(200);
|
||||
expect(((await finalized.json()) as { attachment: { status: string } }).attachment.status).toBe("available");
|
||||
|
||||
const download = await call("GET", `/api/attachments/${created.attachment.id}/download`, env);
|
||||
expect(download.status).toBe(200);
|
||||
expect(download.headers.get("content-type")).toBe("text/plain");
|
||||
expect(await download.text()).toBe(payload);
|
||||
});
|
||||
|
||||
it("does not download before finalize", async () => {
|
||||
const stores = sharedStores();
|
||||
const env = makeEnv(stores, "user_a");
|
||||
const payload = "not yet ready";
|
||||
|
||||
const created = await createAttachment(env, { filename: "draft.txt", content_type: "text/plain" });
|
||||
await call("PUT", `/api/attachments/${created.attachment.id}/content`, env, { body: payload });
|
||||
|
||||
const download = await call("GET", `/api/attachments/${created.attachment.id}/download`, env);
|
||||
expect(download.status).toBe(409);
|
||||
const problem = (await download.json()) as { code: string };
|
||||
expect(problem.code).toBe("object-not-available");
|
||||
});
|
||||
|
||||
it("soft deletes an attachment and removes it from detail reads", async () => {
|
||||
const stores = sharedStores();
|
||||
const env = makeEnv(stores, "user_a");
|
||||
|
||||
const created = await createAttachment(env, { filename: "temp.txt", content_type: "text/plain" });
|
||||
|
||||
const deleted = await call("DELETE", `/api/attachments/${created.attachment.id}`, env);
|
||||
expect(deleted.status).toBe(204);
|
||||
|
||||
const detail = await call("GET", `/api/attachments/${created.attachment.id}`, env);
|
||||
expect(detail.status).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects uploads that exceed the configured size limit", async () => {
|
||||
const stores = sharedStores();
|
||||
const env = makeEnv(stores, "user_a", "3");
|
||||
const created = await createAttachment(env, { filename: "big.bin", content_type: "application/octet-stream" });
|
||||
|
||||
const tooLarge = await call("PUT", `/api/attachments/${created.attachment.id}/content`, env, {
|
||||
body: "more than three bytes",
|
||||
headers: { "content-length": "21" },
|
||||
});
|
||||
expect(tooLarge.status).toBe(413);
|
||||
});
|
||||
|
||||
it("returns 401 when there is no session", async () => {
|
||||
const stores = sharedStores();
|
||||
const env = makeEnv(stores, null);
|
||||
|
||||
const response = await call("POST", "/api/attachments", env, {
|
||||
body: JSON.stringify({ filename: "x.txt", content_type: "text/plain" }),
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 422 when required fields are missing", async () => {
|
||||
const stores = sharedStores();
|
||||
const env = makeEnv(stores, "user_a");
|
||||
|
||||
const response = await call("POST", "/api/attachments", env, { body: JSON.stringify({ content_type: "text/plain" }) });
|
||||
expect(response.status).toBe(422);
|
||||
});
|
||||
});
|
||||
155
tests/fakes.ts
Normal file
155
tests/fakes.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import migrationSql from "../migrations/0001_initial.sql?raw";
|
||||
import { vi } from "vitest";
|
||||
import type { Env, FetcherLike } from "../src/env";
|
||||
|
||||
/**
|
||||
* 内存版 D1,基于 node:sqlite 执行真实 SQL。
|
||||
* 直接复用 migrations/0001_initial.sql 建表,保证 store 层 SQL 被真实跑过。
|
||||
*/
|
||||
export class FakeD1 {
|
||||
private db: DatabaseSync;
|
||||
|
||||
constructor() {
|
||||
this.db = new DatabaseSync(":memory:");
|
||||
this.db.exec(migrationSql);
|
||||
}
|
||||
|
||||
prepare(sql: string) {
|
||||
const exec = (values: unknown[]) => {
|
||||
const statement = this.db.prepare(sql);
|
||||
return {
|
||||
first: async <T = unknown>(): Promise<T | null> => (statement.get(...values) as T | null) ?? null,
|
||||
all: async <T = unknown>() => ({ results: statement.all(...values) as T[], success: true as const }),
|
||||
run: async <T = unknown>() => {
|
||||
statement.run(...values);
|
||||
return { results: [] as T[], success: true as const };
|
||||
},
|
||||
bind: (...vals: unknown[]) => exec(vals),
|
||||
};
|
||||
};
|
||||
return exec([]);
|
||||
}
|
||||
}
|
||||
|
||||
function fakeEtag(bytes: Uint8Array): string {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
hash = ((hash << 5) + hash + bytes[i]) >>> 0;
|
||||
}
|
||||
return hash.toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
interface StoredObject {
|
||||
body: Uint8Array;
|
||||
httpMetadata: R2HTTPMetadata;
|
||||
uploaded: number;
|
||||
}
|
||||
|
||||
function toR2Object(key: string, stored: StoredObject): R2Object {
|
||||
return {
|
||||
key,
|
||||
size: stored.body.byteLength,
|
||||
etag: fakeEtag(stored.body),
|
||||
version: "fake-version",
|
||||
httpEtag: `"${fakeEtag(stored.body)}"`,
|
||||
uploaded: new Date(stored.uploaded),
|
||||
httpMetadata: stored.httpMetadata,
|
||||
customMetadata: {},
|
||||
storageClass: "Standard",
|
||||
writeHttpMetadata: () => new Headers(),
|
||||
storageUsage: 0,
|
||||
} as unknown as R2Object;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内存版 R2 bucket,用 Map 保存对象字节,覆盖 put/get/head/delete。
|
||||
*/
|
||||
export class FakeR2 {
|
||||
private store = new Map<string, StoredObject>();
|
||||
|
||||
async put(
|
||||
key: string,
|
||||
body: ReadableStream<Uint8Array> | ArrayBuffer | ArrayBufferView | string | null,
|
||||
options: { httpMetadata?: R2HTTPMetadata } = {},
|
||||
): Promise<R2Object> {
|
||||
let bytes: Uint8Array;
|
||||
if (body === null) {
|
||||
bytes = new Uint8Array();
|
||||
} else if (typeof body === "string") {
|
||||
bytes = new TextEncoder().encode(body);
|
||||
} else if (body instanceof ArrayBuffer) {
|
||||
bytes = new Uint8Array(body);
|
||||
} else if (ArrayBuffer.isView(body)) {
|
||||
bytes = new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
|
||||
} else {
|
||||
const buffer = await new Response(body as ReadableStream<Uint8Array>).arrayBuffer();
|
||||
bytes = new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
const stored: StoredObject = { body: bytes, httpMetadata: options.httpMetadata ?? {}, uploaded: Date.now() };
|
||||
this.store.set(key, stored);
|
||||
return toR2Object(key, stored);
|
||||
}
|
||||
|
||||
async get(key: string): Promise<R2ObjectBody | null> {
|
||||
const stored = this.store.get(key);
|
||||
if (!stored) return null;
|
||||
const head = toR2Object(key, stored);
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(stored.body);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return {
|
||||
...head,
|
||||
body,
|
||||
bodyUsed: false,
|
||||
arrayBuffer: async () =>
|
||||
stored.body.buffer.slice(stored.body.byteOffset, stored.body.byteOffset + stored.body.byteLength),
|
||||
text: async () => new TextDecoder().decode(stored.body),
|
||||
json: async <T>() => JSON.parse(new TextDecoder().decode(stored.body)) as T,
|
||||
blob: async () => new Blob([stored.body as BlobPart]),
|
||||
} as unknown as R2ObjectBody;
|
||||
}
|
||||
|
||||
async head(key: string): Promise<R2Object | null> {
|
||||
const stored = this.store.get(key);
|
||||
return stored ? toR2Object(key, stored) : null;
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
this.store.delete(key);
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return this.store.has(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function fakeAuth(userId: string | null): FetcherLike {
|
||||
return {
|
||||
fetch: vi.fn(async () =>
|
||||
Response.json(userId ? { user: { id: userId } } : null, { status: userId ? 200 : 401 }),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export interface SharedStores {
|
||||
db: FakeD1;
|
||||
r2: FakeR2;
|
||||
}
|
||||
|
||||
export function sharedStores(): SharedStores {
|
||||
return { db: new FakeD1(), r2: new FakeR2() };
|
||||
}
|
||||
|
||||
export function makeEnv(stores: SharedStores, userId: string | null, maxUploadBytes = "52428800"): Env {
|
||||
return {
|
||||
AUTH: fakeAuth(userId),
|
||||
DB: stores.db as unknown as D1Database,
|
||||
ATTACHMENTS: stores.r2 as unknown as R2Bucket,
|
||||
MAX_UPLOAD_BYTES: maxUploadBytes,
|
||||
};
|
||||
}
|
||||
20
tests/globals.d.ts
vendored
Normal file
20
tests/globals.d.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
// 为测试 fake 提供的最小类型声明,避免引入 @types/node 与 workers-types 全局冲突。
|
||||
|
||||
declare module "node:sqlite" {
|
||||
export interface StatementSync {
|
||||
run(...params: unknown[]): { changes: number; lastInsertRowid: number | bigint };
|
||||
get(...params: unknown[]): unknown;
|
||||
all(...params: unknown[]): unknown[];
|
||||
}
|
||||
export class DatabaseSync {
|
||||
constructor(location: string);
|
||||
exec(sql: string): void;
|
||||
prepare(sql: string): StatementSync;
|
||||
close(): void;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "*.sql?raw" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
Reference in New Issue
Block a user