Skip to content

feat: implement read gist APIs with comprehensive error handling #118

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 7, 2025
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
259 changes: 259 additions & 0 deletions app/api/blobs/[id]/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
import { GET, OPTIONS } from "./route";
import { StorageOperations } from "@/lib/storage-operations";
import { ApiErrors } from "@/lib/api-errors";
import type { GistMetadata } from "@/types/models";
import type { ApiErrorResponse } from "@/types/api";

// Mock StorageOperations
vi.mock("@/lib/storage-operations", () => ({
StorageOperations: {
getGist: vi.fn(),
deleteIfNeeded: vi.fn(),
},
}));

// Mock logger
vi.mock("@/lib/logger", () => ({
createLogger: vi.fn(() => ({
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
})),
}));

describe("GET /api/blobs/[id]", () => {
const mockMetadata: GistMetadata = {
id: "test-gist-id",
created_at: "2025-06-07T10:00:00.000Z",
updated_at: "2025-06-07T10:00:00.000Z",
expires_at: undefined,
one_time_view: false,
edit_pin_hash: "hash123",
edit_pin_salt: "salt123",
total_size: 1024,
blob_count: 1,
version: 1,
current_version: "1733568000000",
indent_mode: "spaces",
indent_size: 2,
wrap_mode: "soft",
theme: "dark",
encrypted_metadata: { iv: "iv123", data: "encrypted" },
};

const mockBlob = new Uint8Array([1, 2, 3, 4, 5]);

const createRequest = () => {
return new NextRequest("https://test.com/api/blobs/test-gist-id");
};

const createContext = (id = "test-gist-id") => ({
params: Promise.resolve({ id }),
});

beforeEach(() => {
vi.clearAllMocks();
});

afterEach(() => {
vi.restoreAllMocks();
});

describe("successful retrieval", () => {
it("should return blob data for valid gist", async () => {
const mockGist = { metadata: mockMetadata, blob: mockBlob };
vi.mocked(StorageOperations.getGist).mockResolvedValue(mockGist);

const request = createRequest();
const context = createContext();
const response = await GET(request, context);

expect(response.status).toBe(200);

const data = await response.arrayBuffer();
const uint8Array = new Uint8Array(data);
expect(uint8Array).toEqual(mockBlob);
});

it("should set appropriate headers for blob response", async () => {
const mockGist = { metadata: mockMetadata, blob: mockBlob };
vi.mocked(StorageOperations.getGist).mockResolvedValue(mockGist);

const request = createRequest();
const context = createContext();
const response = await GET(request, context);

expect(response.headers.get("Content-Type")).toBe(
"application/octet-stream"
);
expect(response.headers.get("Content-Length")).toBe(
mockBlob.length.toString()
);
expect(response.headers.get("Cache-Control")).toBe(
"private, max-age=3600"
);
expect(response.headers.get("Content-Disposition")).toBe(
'attachment; filename="gist-test-gist-id.bin"'
);
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(response.headers.get("X-Frame-Options")).toBe("DENY");
});

it("should handle one-time view gists correctly", async () => {
const oneTimeMetadata = { ...mockMetadata, one_time_view: true };
const mockGist = { metadata: oneTimeMetadata, blob: mockBlob };
vi.mocked(StorageOperations.getGist).mockResolvedValue(mockGist);
vi.mocked(StorageOperations.deleteIfNeeded).mockResolvedValue(true);

const request = createRequest();
const context = createContext();
const response = await GET(request, context);

expect(response.status).toBe(200);
expect(response.headers.get("Cache-Control")).toBe(
"no-store, no-cache, must-revalidate"
);
expect(StorageOperations.deleteIfNeeded).toHaveBeenCalledWith(
oneTimeMetadata
);
});

it("should handle large blob data", async () => {
const largeMockBlob = new Uint8Array(1024 * 1024); // 1MB
largeMockBlob.fill(42); // Fill with some data
const mockGist = { metadata: mockMetadata, blob: largeMockBlob };
vi.mocked(StorageOperations.getGist).mockResolvedValue(mockGist);

const request = createRequest();
const context = createContext();
const response = await GET(request, context);

expect(response.status).toBe(200);
expect(response.headers.get("Content-Length")).toBe(
largeMockBlob.length.toString()
);

const data = await response.arrayBuffer();
expect(data.byteLength).toBe(1024 * 1024);
});
});

describe("error handling", () => {
it("should return 400 for invalid gist ID", async () => {
const request = createRequest();
const context = createContext("");
const response = await GET(request, context);

expect(response.status).toBe(400);
const data: ApiErrorResponse = await response.json();
expect(data.message).toBe("Invalid gist ID");
});

it("should return 404 for non-existent gist", async () => {
const notFoundError = ApiErrors.notFound("Gist");
vi.mocked(StorageOperations.getGist).mockRejectedValue(notFoundError);

const request = createRequest();
const context = createContext();
const response = await GET(request, context);

expect(response.status).toBe(404);
const data: ApiErrorResponse = await response.json();
expect(data.message).toBe("Gist not found");
});

it("should return 410 for expired gist based on expires_at", async () => {
const expiredMetadata = {
...mockMetadata,
expires_at: "2020-01-01T00:00:00.000Z", // Past date
};
const mockGist = { metadata: expiredMetadata, blob: mockBlob };
vi.mocked(StorageOperations.getGist).mockResolvedValue(mockGist);

const request = createRequest();
const context = createContext();
const response = await GET(request, context);

expect(response.status).toBe(410);
const data: ApiErrorResponse = await response.json();
expect(data.message).toBe("Gist has expired");
});

it("should handle storage errors gracefully", async () => {
const storageError = new Error("Storage connection failed");
vi.mocked(StorageOperations.getGist).mockRejectedValue(storageError);

const request = createRequest();
const context = createContext();
const response = await GET(request, context);

expect(response.status).toBe(500);
// Note: Logger mock calls can't be easily tested with current mock setup
});

it("should continue if one-time deletion fails", async () => {
const oneTimeMetadata = { ...mockMetadata, one_time_view: true };
const mockGist = { metadata: oneTimeMetadata, blob: mockBlob };
vi.mocked(StorageOperations.getGist).mockResolvedValue(mockGist);
vi.mocked(StorageOperations.deleteIfNeeded).mockRejectedValue(
new Error("Delete failed")
);

const request = createRequest();
const context = createContext();
const response = await GET(request, context);

expect(response.status).toBe(200); // Should still succeed
// Note: Logger mock calls can't be easily tested with current mock setup
});

it("should handle unexpected errors", async () => {
const unexpectedError = new TypeError("Something went wrong");
vi.mocked(StorageOperations.getGist).mockRejectedValue(unexpectedError);

const request = createRequest();
const context = createContext();
const response = await GET(request, context);

expect(response.status).toBe(500);
// Note: Logger mock calls can't be easily tested with current mock setup
});

it("should handle empty blob data", async () => {
const emptyBlob = new Uint8Array(0);
const mockGist = { metadata: mockMetadata, blob: emptyBlob };
vi.mocked(StorageOperations.getGist).mockResolvedValue(mockGist);

const request = createRequest();
const context = createContext();
const response = await GET(request, context);

expect(response.status).toBe(200);
expect(response.headers.get("Content-Length")).toBe("0");

const data = await response.arrayBuffer();
expect(data.byteLength).toBe(0);
});
});
});

describe("OPTIONS /api/blobs/[id]", () => {
it("should return correct CORS headers", async () => {
const response = await OPTIONS();

expect(response.status).toBe(200);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe(
"https://ghostpaste.dev"
);
expect(response.headers.get("Access-Control-Allow-Methods")).toBe(
"GET, OPTIONS"
);
expect(response.headers.get("Access-Control-Allow-Headers")).toBe(
"Content-Type"
);
expect(response.headers.get("Access-Control-Max-Age")).toBe("86400");
});
});
120 changes: 120 additions & 0 deletions app/api/blobs/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from "next/server";
import { StorageOperations } from "@/lib/storage-operations";
import { AppError, ErrorCode } from "@/types/errors";
import { errorResponse, ApiErrors } from "@/lib/api-errors";
import { createLogger } from "@/lib/logger";
import type { GistMetadata } from "@/types/models";

const logger = createLogger("api:blobs:get");

/**
* GET /api/blobs/[id]
* Retrieve encrypted blob data by gist ID
*/
export async function GET(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;

// Validate gist ID format
if (!id || typeof id !== "string" || id.length === 0) {
return errorResponse(ApiErrors.badRequest("Invalid gist ID"));
}

// Get gist data including blob
let gistData: { metadata: GistMetadata; blob: Uint8Array };
try {
const result = await StorageOperations.getGist(id);
if (!result) {
return errorResponse(ApiErrors.notFound("Gist"));
}
gistData = result;
} catch (error) {
if (error instanceof AppError) {
// Handle specific storage errors
if (error.code === ErrorCode.NOT_FOUND) {
return errorResponse(ApiErrors.notFound("Gist"));
}
if (error.code === ErrorCode.GIST_EXPIRED) {
return errorResponse(ApiErrors.gone("Gist has expired"));
}
}

// Log unexpected errors
logger.error(
"Error retrieving gist blob",
error instanceof Error ? error : new Error(String(error))
);
return errorResponse(
ApiErrors.storageError("Failed to retrieve gist blob")
);
}

const { metadata, blob } = gistData;

// Check if gist has expired
if (metadata.expires_at) {
const expiryDate = new Date(metadata.expires_at);
if (expiryDate <= new Date()) {
return errorResponse(ApiErrors.gone("Gist has expired"));
}
}

// For one-time view gists, delete after successful retrieval
if (metadata.one_time_view) {
try {
await StorageOperations.deleteIfNeeded(metadata);
} catch (error) {
// Log but don't fail the request if deletion fails
logger.warn(
"Failed to delete one-time view gist",
error instanceof Error ? error : new Error(String(error))
);
}
}

// Return blob data with appropriate headers
return new NextResponse(blob, {
status: 200,
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": blob.length.toString(),
"Cache-Control": metadata.one_time_view
? "no-store, no-cache, must-revalidate"
: "private, max-age=3600", // 1 hour for regular gist blobs
"Content-Disposition": `attachment; filename="gist-${id}.bin"`,
// Security headers
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
},
});
} catch (error) {
// Handle unexpected errors
logger.error(
"Unexpected error in GET /api/blobs/[id]",
error instanceof Error ? error : new Error(String(error))
);
return errorResponse(
error instanceof Error ? error : new Error("Unknown error")
);
}
}

/**
* OPTIONS /api/blobs/[id]
* Handle preflight requests
*/
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: {
"Access-Control-Allow-Origin":
process.env.NEXT_PUBLIC_APP_URL || "https://ghostpaste.dev",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Max-Age": "86400",
},
});
}
Loading