Skip to content
Closed
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
206 changes: 206 additions & 0 deletions apps/web/src/app/api/modules/[slug]/versions/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const mockAuth = vi.fn();
const mockModuleSingle = vi.fn();
const mockVersionsOrder = vi.fn();
const mockVersionSingle = vi.fn();
const mockVersionInsert = vi.fn();
const mockModuleUpdate = vi.fn();
const mockModuleUpdateEq = vi.fn();

function moduleQuery() {
const query = {
eq: vi.fn(() => query),
single: mockModuleSingle,
};
return query;
}

function versionsQuery() {
const query = {
eq: vi.fn(() => query),
order: mockVersionsOrder,
};
return query;
}

vi.mock("@/lib/api-auth", () => ({
getAuthenticatedRequestUser: (...args: unknown[]) => mockAuth(...args),
}));

vi.mock("@/lib/supabase", () => ({
getSupabaseAdmin: () => ({
from: (table: string) => {
if (table === "modules") {
return {
select: vi.fn(moduleQuery),
update: mockModuleUpdate,
};
}
if (table === "module_versions") {
return {
select: vi.fn(versionsQuery),
insert: mockVersionInsert,
};
}
return {};
},
}),
}));

import { GET, POST } from "@/app/api/modules/[slug]/versions/route";

function makeRequest(body?: unknown, token = "token-123") {
return new Request("http://localhost/api/modules/test-scanner/versions", {
method: body === undefined ? "GET" : "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
}) as unknown as import("next/server").NextRequest;
}

function makeContext(slug = "test-scanner") {
return { params: Promise.resolve({ slug }) };
}

describe("GET /api/modules/:slug/versions", () => {
beforeEach(() => {
vi.clearAllMocks();
mockModuleSingle.mockResolvedValue({
data: { id: "mod-001" },
error: null,
});
mockVersionsOrder.mockResolvedValue({
data: [{ id: "ver-1", version: "1.0.0" }],
error: null,
});
});

it("lists versions newest first", async () => {
const response = await GET(makeRequest(), makeContext());
const body = await response.json();

expect(response.status).toBe(200);
expect(body.versions).toEqual([{ id: "ver-1", version: "1.0.0" }]);
expect(mockVersionsOrder).toHaveBeenCalledWith("created_at", {
ascending: false,
});
});

it("returns 404 for an unknown module", async () => {
mockModuleSingle.mockResolvedValue({ data: null, error: { message: "missing" } });

const response = await GET(makeRequest(), makeContext("missing"));

expect(response.status).toBe(404);
});
});

describe("POST /api/modules/:slug/versions", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAuth.mockResolvedValue({
userId: "user-1",
email: "author@example.com",
});
mockModuleSingle.mockResolvedValue({
data: { id: "mod-001", author_email: "author@example.com" },
error: null,
});
mockVersionSingle.mockResolvedValue({
data: { id: "ver-2", module_id: "mod-001", version: "1.1.0" },
error: null,
});
mockVersionInsert.mockReturnValue({
select: vi.fn(() => ({ single: mockVersionSingle })),
});
mockModuleUpdateEq.mockResolvedValue({ error: null });
mockModuleUpdate.mockReturnValue({ eq: mockModuleUpdateEq });
});

it("publishes a version and updates the module release", async () => {
const response = await POST(
makeRequest({
version: "1.1.0",
changelog: "Adds scheduled scans",
package_url: "https://example.com/releases/1.1.0.tgz",
git_tag: "v1.1.0",
min_threatcrush_version: ">=0.2.0",
}),
makeContext(),
);
const body = await response.json();

expect(response.status).toBe(201);
expect(body.version.version).toBe("1.1.0");
expect(mockVersionInsert).toHaveBeenCalledWith(
expect.objectContaining({
module_id: "mod-001",
version: "1.1.0",
git_tag: "v1.1.0",
}),
);
expect(mockModuleUpdate).toHaveBeenCalledWith(
expect.objectContaining({
version: "1.1.0",
min_threatcrush_version: ">=0.2.0",
}),
);
});

it("requires authentication", async () => {
mockAuth.mockResolvedValue(null);

const response = await POST(makeRequest({ version: "1.1.0" }, ""), makeContext());

expect(response.status).toBe(401);
expect(mockVersionInsert).not.toHaveBeenCalled();
});

it("rejects releases from a different author", async () => {
mockAuth.mockResolvedValue({
userId: "user-2",
email: "other@example.com",
});

const response = await POST(makeRequest({ version: "1.1.0" }), makeContext());

expect(response.status).toBe(403);
expect(mockVersionInsert).not.toHaveBeenCalled();
});

it.each(["1", "v1.2.3", "1.2", "latest"])(
"rejects invalid semantic version %s",
async (version) => {
const response = await POST(makeRequest({ version }), makeContext());

expect(response.status).toBe(400);
expect(mockVersionInsert).not.toHaveBeenCalled();
},
);

it("rejects a non-HTTP package URL", async () => {
const response = await POST(
makeRequest({ version: "1.1.0", package_url: "file:///tmp/module.tgz" }),
makeContext(),
);

expect(response.status).toBe(400);
expect(mockVersionInsert).not.toHaveBeenCalled();
});

it("returns 409 for an existing version", async () => {
mockVersionSingle.mockResolvedValue({
data: null,
error: { code: "23505", message: "duplicate key" },
});

const response = await POST(makeRequest({ version: "1.1.0" }), makeContext());
const body = await response.json();

expect(response.status).toBe(409);
expect(body.error).toContain("already exists");
});
});
161 changes: 161 additions & 0 deletions apps/web/src/app/api/modules/[slug]/versions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { NextRequest, NextResponse } from "next/server";
import { getAuthenticatedRequestUser } from "@/lib/api-auth";
import { getSupabaseAdmin } from "@/lib/supabase";

type RouteContext = { params: Promise<{ slug: string }> };

const SEMVER_PATTERN =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;

function isHttpUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}

/**
* GET /api/modules/[slug]/versions
* List published versions for a marketplace module.
*/
export async function GET(
_request: NextRequest,
context: RouteContext,
) {
const { slug } = await context.params;
const sb = getSupabaseAdmin();

const { data: mod, error: moduleError } = await sb
.from("modules")
.select("id")
.eq("slug", slug)
.eq("published", true)
.single();

if (moduleError || !mod) {
return NextResponse.json({ error: "Module not found" }, { status: 404 });
}

const { data: versions, error } = await sb
.from("module_versions")
.select("*")
.eq("module_id", mod.id)
.order("created_at", { ascending: false });

if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}

return NextResponse.json({ versions: versions || [] });
}

/**
* POST /api/modules/[slug]/versions
* Publish a version for a module owned by the authenticated user.
*/
export async function POST(
request: NextRequest,
context: RouteContext,
) {
const user = await getAuthenticatedRequestUser(request);
if (!user) {
return NextResponse.json(
{ error: "You must be logged in to publish module versions." },
{ status: 401 },
);
}

let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}

const version = typeof body.version === "string" ? body.version.trim() : "";
if (!SEMVER_PATTERN.test(version)) {
return NextResponse.json(
{ error: "version must be a valid semantic version" },
{ status: 400 },
);
}

const packageUrl =
typeof body.package_url === "string" && body.package_url.trim()
? body.package_url.trim()
: null;
if (packageUrl && !isHttpUrl(packageUrl)) {
return NextResponse.json(
{ error: "package_url must be a valid HTTP(S) URL" },
{ status: 400 },
);
}

const { slug } = await context.params;
const sb = getSupabaseAdmin();
const { data: mod, error: moduleError } = await sb
.from("modules")
.select("id, author_email")
.eq("slug", slug)
.single();

if (moduleError || !mod) {
return NextResponse.json({ error: "Module not found" }, { status: 404 });
}
if (!user.email || user.email.toLowerCase() !== mod.author_email?.toLowerCase()) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}

const versionData = {
module_id: mod.id,
version,
changelog:
typeof body.changelog === "string" && body.changelog.trim()
? body.changelog.trim()
: null,
package_url: packageUrl,
git_tag:
typeof body.git_tag === "string" && body.git_tag.trim()
? body.git_tag.trim()
: null,
min_threatcrush_version:
typeof body.min_threatcrush_version === "string" &&
body.min_threatcrush_version.trim()
? body.min_threatcrush_version.trim()
: null,
};

const { data: created, error } = await sb
.from("module_versions")
.insert(versionData)
.select()
.single();

if (error) {
const status = error.code === "23505" ? 409 : 500;
const message =
status === 409 ? `Version ${version} already exists` : error.message;
return NextResponse.json({ error: message }, { status });
}

const moduleUpdates: Record<string, unknown> = {
version,
updated_at: new Date().toISOString(),
};
if (versionData.min_threatcrush_version) {
moduleUpdates.min_threatcrush_version = versionData.min_threatcrush_version;
}

const { error: updateError } = await sb
.from("modules")
.update(moduleUpdates)
.eq("id", mod.id);

if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 500 });
}

return NextResponse.json({ version: created }, { status: 201 });
}
Loading
Loading