Skip to content
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
80 changes: 78 additions & 2 deletions apps/web/public/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ info:
version: 0.2.0
description: |
Public, read-only endpoints for ThreatCrush — module marketplace,
blog feed, and health. Authenticated endpoints are not documented
here and require a signed-in session via the web app.
blog feed, and health. Authenticated module-author endpoints require
a signed-in session via the web app or a Supabase Bearer token.
contact:
name: ThreatCrush
email: hello@threatcrush.com
Expand Down Expand Up @@ -79,6 +79,70 @@ paths:
'404':
description: Not found

/api/modules/{slug}/versions:
get:
summary: List module releases
operationId: listModuleVersions
parameters:
- in: path
name: slug
required: true
schema: { type: string }
responses:
'200':
description: Published releases newest first
content:
application/json:
schema:
type: object
properties:
versions:
type: array
items: { $ref: '#/components/schemas/ModuleVersion' }
'404':
description: Not found
post:
summary: Publish a module release
operationId: publishModuleVersion
parameters:
- in: path
name: slug
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [version]
properties:
version: { type: string, example: "1.2.3" }
changelog: { type: string, nullable: true }
package_url: { type: string, nullable: true }
git_tag: { type: string, nullable: true, example: "v1.2.3" }
min_threatcrush_version: { type: string, nullable: true, example: ">=0.2.0" }
responses:
'201':
description: Release published and module metadata synchronized
content:
application/json:
schema:
type: object
properties:
version: { $ref: '#/components/schemas/ModuleVersion' }
module: { $ref: '#/components/schemas/Module' }
'400':
description: Invalid JSON or version
'401':
description: Login required
'403':
description: Verified module author required
'404':
description: Not found
'409':
description: Duplicate version

/blog/rss.xml:
get:
summary: Blog RSS feed
Expand Down Expand Up @@ -126,3 +190,15 @@ components:
featured: { type: boolean }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }

ModuleVersion:
type: object
properties:
id: { type: string, format: uuid }
module_id: { type: string, format: uuid }
version: { type: string }
changelog: { type: string, nullable: true }
package_url: { type: string, nullable: true }
git_tag: { type: string, nullable: true }
min_threatcrush_version: { type: string, nullable: true }
created_at: { type: string, format: date-time }
220 changes: 220 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,220 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TEST_MODULE } from "@/__tests__/helpers/supabase-mock";

type QueryResult = { data: unknown; error: { message: string; code?: string } | null };

const mockAuthGetUser = vi.fn();
const mockInsertVersion = vi.fn();
const mockUpdateModule = vi.fn();

let tableResults: Record<string, {
awaited?: QueryResult[];
single?: QueryResult[];
maybeSingle?: QueryResult[];
}> = {};

function createChainable(table: string) {
const handler: ProxyHandler<object> = {
get(_target, prop: string) {
if (prop === "then") {
return (resolve: (value: QueryResult) => void) => {
const result = tableResults[table]?.awaited?.shift() || { data: null, error: null };
resolve(result);
};
}
if (prop === "single") {
return vi.fn().mockImplementation(() => {
const result = tableResults[table]?.single?.shift() || { data: null, error: null };
return Promise.resolve(result);
});
}
if (prop === "maybeSingle") {
return vi.fn().mockImplementation(() => {
const result = tableResults[table]?.maybeSingle?.shift() || { data: null, error: null };
return Promise.resolve(result);
});
}
if (prop === "insert") {
return mockInsertVersion.mockReturnValue(new Proxy({}, handler));
}
if (prop === "update") {
return mockUpdateModule.mockReturnValue(new Proxy({}, handler));
}
return vi.fn().mockReturnValue(new Proxy({}, handler));
},
};

return new Proxy({}, handler);
}

vi.mock("@/lib/supabase", () => ({
getSupabaseAdmin: () => ({
auth: {
getUser: mockAuthGetUser,
},
from: (table: string) => createChainable(table),
}),
}));

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

function makeRequest(url: string, init?: RequestInit) {
return new Request(url, init) as unknown as import("next/server").NextRequest;
}

function makeContext(slug: string) {
return { params: Promise.resolve({ slug }) };
}

const TEST_VERSION = {
id: "v-001",
module_id: "mod-001",
version: "1.0.0",
changelog: "Initial",
created_at: "2025-01-01T00:00:00Z",
};

describe("GET /api/modules/:slug/versions", () => {
beforeEach(() => {
vi.clearAllMocks();
tableResults = {
modules: { single: [{ data: { id: "mod-001" }, error: null }] },
module_versions: { awaited: [{ data: [TEST_VERSION], error: null }] },
};
});

it("returns versions newest first", async () => {
const req = makeRequest("http://localhost/api/modules/test-scanner/versions");
const res = await GET(req, makeContext("test-scanner"));
const body = await res.json();

expect(res.status).toBe(200);
expect(body.versions).toEqual([TEST_VERSION]);
});

it("returns 404 for unknown modules", async () => {
tableResults.modules = { single: [{ data: null, error: null }] };

const req = makeRequest("http://localhost/api/modules/nope/versions");
const res = await GET(req, makeContext("nope"));
const body = await res.json();

expect(res.status).toBe(404);
expect(body.error).toBe("Module not found");
});
});

describe("POST /api/modules/:slug/versions", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAuthGetUser.mockResolvedValue({ data: { user: { id: "user-1", email: "test@example.com" } } });
tableResults = {
user_profiles: {
single: [{ data: { id: "user-1", email: "test@example.com", email_verified: true }, error: null }],
},
modules: {
single: [
{ data: { id: "mod-001", author_email: "test@example.com" }, error: null },
{ data: { ...TEST_MODULE, version: "1.1.0" }, error: null },
],
},
module_versions: {
maybeSingle: [{ data: null, error: null }],
single: [{ data: { ...TEST_VERSION, version: "1.1.0" }, error: null }],
},
};
});

it("rejects unauthenticated release publishing", async () => {
mockAuthGetUser.mockResolvedValue({ data: { user: null } });

const req = makeRequest("http://localhost/api/modules/test-scanner/versions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ version: "1.1.0" }),
});
const res = await POST(req, makeContext("test-scanner"));
const body = await res.json();

expect(res.status).toBe(401);
expect(body.error).toContain("logged in");
});

it("rejects invalid semver", async () => {
const req = makeRequest("http://localhost/api/modules/test-scanner/versions", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer token-123" },
body: JSON.stringify({ version: "latest" }),
});
const res = await POST(req, makeContext("test-scanner"));
const body = await res.json();

expect(res.status).toBe(400);
expect(body.error).toContain("semantic version");
});

it("rejects duplicate versions", async () => {
tableResults.module_versions = {
maybeSingle: [{ data: { id: "existing-version" }, error: null }],
};

const req = makeRequest("http://localhost/api/modules/test-scanner/versions", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer token-123" },
body: JSON.stringify({ version: "1.1.0" }),
});
const res = await POST(req, makeContext("test-scanner"));
const body = await res.json();

expect(res.status).toBe(409);
expect(body.error).toContain("already exists");
});

it("returns conflict when the database rejects a duplicate release", async () => {
tableResults.module_versions = {
maybeSingle: [{ data: null, error: null }],
single: [{ data: null, error: { message: "duplicate key value violates unique constraint", code: "23505" } }],
};

const req = makeRequest("http://localhost/api/modules/test-scanner/versions", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer token-123" },
body: JSON.stringify({ version: "1.1.0" }),
});
const res = await POST(req, makeContext("test-scanner"));
const body = await res.json();

expect(res.status).toBe(409);
expect(body.error).toContain("already exists");
});

it("creates a release and synchronizes module metadata", async () => {
const req = makeRequest("http://localhost/api/modules/test-scanner/versions", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer token-123" },
body: JSON.stringify({
version: "1.1.0",
changelog: "Adds indicator modules",
git_tag: "v1.1.0",
package_url: "https://example.com/module.tgz",
min_threatcrush_version: ">=0.2.0",
}),
});
const res = await POST(req, makeContext("test-scanner"));
const body = await res.json();

expect(res.status).toBe(201);
expect(body.version.version).toBe("1.1.0");
expect(body.module.version).toBe("1.1.0");
expect(mockInsertVersion).toHaveBeenCalledWith(expect.objectContaining({
version: "1.1.0",
changelog: "Adds indicator modules",
git_tag: "v1.1.0",
package_url: "https://example.com/module.tgz",
}));
expect(mockUpdateModule).toHaveBeenCalledWith(expect.objectContaining({
version: "1.1.0",
min_threatcrush_version: ">=0.2.0",
}));
});
});
Loading
Loading