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
32 changes: 32 additions & 0 deletions app/api/files/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { getFilesHandler } from "@/lib/files/getFilesHandler";

/**
* OPTIONS handler for CORS preflight requests.
*
* @returns A NextResponse with CORS headers.
*/
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: getCorsHeaders(),
});
}

/**
* GET /api/files
*
* Lists files for an accessible artist account. Returns the matching file rows
* along with the owner's primary email address when one exists.
*
* @param request - The request object containing query parameters.
* @returns A NextResponse with file rows or an error response.
*/
export async function GET(request: NextRequest) {
return getFilesHandler(request);
}

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;
110 changes: 110 additions & 0 deletions lib/files/__tests__/getFilesHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextResponse, type NextRequest } from "next/server";
import { getFilesHandler } from "../getFilesHandler";
import { validateGetFilesQuery } from "../validateGetFilesQuery";
import { listFilesByArtist } from "../listFilesByArtist";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
}));

vi.mock("../validateGetFilesQuery", () => ({
validateGetFilesQuery: vi.fn(),
}));

vi.mock("../listFilesByArtist", () => ({
listFilesByArtist: vi.fn(),
}));

/**
* Creates a mock request for GET /api/files tests.
*
* @returns A mocked NextRequest instance.
*/
function createRequest(): NextRequest {
return {
url: "http://localhost/api/files",
headers: new Headers({ authorization: "Bearer token" }),
} as unknown as NextRequest;
}

const baseFile = {
id: "550e8400-e29b-41d4-a716-446655440010",
owner_account_id: "550e8400-e29b-41d4-a716-446655440100",
artist_account_id: "550e8400-e29b-41d4-a716-446655440000",
file_name: "report.md",
storage_key:
"files/550e8400-e29b-41d4-a716-446655440100/550e8400-e29b-41d4-a716-446655440000/report.md",
mime_type: "text/markdown",
is_directory: false,
size_bytes: 123,
description: null,
tags: null,
created_at: "2026-04-09T00:00:00.000Z",
updated_at: "2026-04-09T00:00:00.000Z",
};

describe("getFilesHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("returns validation response errors directly", async () => {
vi.mocked(validateGetFilesQuery).mockResolvedValue(
NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }),
);

const result = await getFilesHandler(createRequest());

expect(result.status).toBe(401);
});

it("returns enriched files", async () => {
vi.mocked(validateGetFilesQuery).mockResolvedValue({
artist_account_id: "550e8400-e29b-41d4-a716-446655440000",
recursive: false,
});
vi.mocked(listFilesByArtist).mockResolvedValue([baseFile]);

const result = await getFilesHandler(createRequest());
const body = await result.json();

expect(listFilesByArtist).toHaveBeenCalledWith(
"550e8400-e29b-41d4-a716-446655440000",
undefined,
false,
);
expect(result.status).toBe(200);
expect(body).toEqual({
files: [baseFile],
});
});

it("returns an empty files array when no files match", async () => {
vi.mocked(validateGetFilesQuery).mockResolvedValue({
artist_account_id: "550e8400-e29b-41d4-a716-446655440000",
recursive: false,
});
vi.mocked(listFilesByArtist).mockResolvedValue([]);

const result = await getFilesHandler(createRequest());
const body = await result.json();

expect(body).toEqual({ files: [] });
});

it("returns 500 when file lookup throws", async () => {
vi.mocked(validateGetFilesQuery).mockResolvedValue({
artist_account_id: "550e8400-e29b-41d4-a716-446655440000",
recursive: false,
});
vi.mocked(listFilesByArtist).mockRejectedValue(new Error("db blew up"));

const result = await getFilesHandler(createRequest());

expect(result.status).toBe(500);
await expect(result.json()).resolves.toMatchObject({
error: "db blew up",
});
});
});
161 changes: 161 additions & 0 deletions lib/files/__tests__/validateGetFilesQuery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextResponse, type NextRequest } from "next/server";
import { validateGetFilesQuery } from "../validateGetFilesQuery";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
}));

vi.mock("@/lib/auth/validateAuthContext", () => ({
validateAuthContext: vi.fn(),
}));

vi.mock("@/lib/artists/checkAccountArtistAccess", () => ({
checkAccountArtistAccess: vi.fn(),
}));

/**
* Creates a mock NextRequest for GET /api/files validation tests.
*
* @param url - Request URL.
* @returns A mocked NextRequest instance.
*/
function createRequest(url: string): NextRequest {
return {
url,
headers: new Headers({ authorization: "Bearer token" }),
} as unknown as NextRequest;
}

describe("validateGetFilesQuery", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("returns auth errors directly", async () => {
vi.mocked(validateAuthContext).mockResolvedValue(
NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }),
);

const result = await validateGetFilesQuery(createRequest("http://localhost/api/files"));

expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(401);
});

it("returns 400 when artist_account_id is missing", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "account-123",
authToken: "token",
orgId: null,
});

const result = await validateGetFilesQuery(createRequest("http://localhost/api/files"));

expect((result as NextResponse).status).toBe(400);
await expect((result as NextResponse).json()).resolves.toMatchObject({
error: "Invalid input: expected string, received undefined",
});
});

it("returns 400 when artist_account_id is invalid", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "account-123",
authToken: "token",
orgId: null,
});

const result = await validateGetFilesQuery(
createRequest("http://localhost/api/files?artist_account_id=not-a-uuid"),
);

expect((result as NextResponse).status).toBe(400);
await expect((result as NextResponse).json()).resolves.toMatchObject({
error: "artist_account_id must be a valid UUID",
});
});

it("returns 403 when requester cannot access the artist", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "account-123",
authToken: "token",
orgId: null,
});
vi.mocked(checkAccountArtistAccess).mockResolvedValue(false);

const result = await validateGetFilesQuery(
createRequest(
"http://localhost/api/files?artist_account_id=550e8400-e29b-41d4-a716-446655440000",
),
);

expect(checkAccountArtistAccess).toHaveBeenCalledWith(
"account-123",
"550e8400-e29b-41d4-a716-446655440000",
);
expect((result as NextResponse).status).toBe(403);
});

it("returns validated params for direct artist access", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "account-123",
authToken: "token",
orgId: null,
});
vi.mocked(checkAccountArtistAccess).mockResolvedValue(true);

const result = await validateGetFilesQuery(
createRequest(
"http://localhost/api/files?artist_account_id=550e8400-e29b-41d4-a716-446655440000&path=reports&recursive=true",
),
);

expect(result).toEqual({
artist_account_id: "550e8400-e29b-41d4-a716-446655440000",
path: "reports",
recursive: true,
});
});

it("parses recursive=false as false", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "account-123",
authToken: "token",
orgId: null,
});
vi.mocked(checkAccountArtistAccess).mockResolvedValue(true);

const result = await validateGetFilesQuery(
createRequest(
"http://localhost/api/files?artist_account_id=550e8400-e29b-41d4-a716-446655440000&recursive=false",
),
);

expect(result).toEqual({
artist_account_id: "550e8400-e29b-41d4-a716-446655440000",
recursive: false,
});
});

it("returns validated params for shared-org artist access", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "account-456",
authToken: "token",
orgId: null,
});
vi.mocked(checkAccountArtistAccess).mockResolvedValue(true);

const result = await validateGetFilesQuery(
createRequest(
"http://localhost/api/files?artist_account_id=550e8400-e29b-41d4-a716-446655440001",
),
);

expect(result).toEqual({
artist_account_id: "550e8400-e29b-41d4-a716-446655440001",
recursive: false,
});
});
});
1 change: 1 addition & 0 deletions lib/files/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const STORAGE_KEY_RELATIVE_PATH_REGEX = /^files\/[^/]+\/[^/]+\/(.+)$/;
36 changes: 36 additions & 0 deletions lib/files/filterFilesByPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { Tables } from "@/types/database.types";
import { STORAGE_KEY_RELATIVE_PATH_REGEX } from "@/lib/files/constants";

type FileRecord = Tables<"files">;

/**
* Filters file rows to immediate children of the provided relative path.
*
* @param files - File rows to filter.
* @param path - Optional relative directory path.
* @returns The filtered file rows.
*/
export function filterFilesByPath(files: FileRecord[], path?: string): FileRecord[] {
return files.filter(file => {
const match = file.storage_key.match(STORAGE_KEY_RELATIVE_PATH_REGEX);
if (!match) return false;

const relativePath = match[1];

if (path) {
const pathPrefix = path.endsWith("/") ? path : `${path}/`;
if (!relativePath.startsWith(pathPrefix)) return false;

const relativeToFilter = relativePath.slice(pathPrefix.length);
const trimmed = relativeToFilter.endsWith("/")
? relativeToFilter.slice(0, -1)
: relativeToFilter;

return trimmed.length > 0 && !trimmed.includes("/");
}

const trimmed = relativePath.endsWith("/") ? relativePath.slice(0, -1) : relativePath;

return trimmed.length > 0 && !trimmed.includes("/");
});
}
45 changes: 45 additions & 0 deletions lib/files/getFilesHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { validateGetFilesQuery } from "@/lib/files/validateGetFilesQuery";
import { listFilesByArtist } from "@/lib/files/listFilesByArtist";

/**
* Handles GET /api/files requests.
*
* @param request - The incoming request.
* @returns A NextResponse with files or an error.
*/
export async function getFilesHandler(request: NextRequest): Promise<NextResponse> {
try {
const validatedQuery = await validateGetFilesQuery(request);
if (validatedQuery instanceof NextResponse) {
return validatedQuery;
}

const files = await listFilesByArtist(
validatedQuery.artist_account_id,
validatedQuery.path,
validatedQuery.recursive,
);

return NextResponse.json(
{ files },
{
status: 200,
headers: getCorsHeaders(),
},
);
} catch (error) {
return NextResponse.json(
{
status: "error",
error: error instanceof Error ? error.message : "Internal server error",
},
{
status: 500,
headers: getCorsHeaders(),
},
);
}
}
Loading
Loading