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
125 changes: 125 additions & 0 deletions web/__tests__/api/seed-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";

const mockSql = vi.hoisted(() => vi.fn());

vi.mock("@/lib/db", () => ({
sql: () => mockSql,
initializeDatabase: vi.fn().mockResolvedValue(undefined),
}));

vi.mock("@/lib/chains", () => ({
getConfiguredSolanaChainContext: () => "solana:devnet",
}));

import { POST } from "@/app/api/seed/route";

function request(
headers: Record<string, string> = {},
method: "GET" | "POST" = "POST"
): NextRequest {
return new NextRequest("https://example.com/api/seed", {
method,
headers,
});
}

describe("POST /api/seed", () => {
beforeEach(() => {
vi.clearAllMocks();
});

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

it("seeds into an empty database in local development without a secret", async () => {
vi.stubEnv("CRON_SECRET", "");
vi.stubEnv("VERCEL_ENV", "");
// First query: SELECT COUNT(*) -> 0 rows exist. Second: INSERT skills.
// Third: INSERT skill_versions.
mockSql.mockResolvedValueOnce([{ count: "0" }]);
mockSql.mockResolvedValueOnce([
{ id: "11111111-1111-1111-1111-111111111111" },
]);
mockSql.mockResolvedValueOnce([]);

const res = await POST(request());
const body = await res.json();

expect(res.status).toBe(200);
expect(body.success).toBe(true);
expect(mockSql).toHaveBeenCalledTimes(3);
});

it("skips seeding when data already exists", async () => {
vi.stubEnv("CRON_SECRET", "");
vi.stubEnv("VERCEL_ENV", "");
mockSql.mockResolvedValueOnce([{ count: "5" }]);

const res = await POST(request());
const body = await res.json();

expect(res.status).toBe(200);
expect(body.skipped).toBe(true);
expect(mockSql).toHaveBeenCalledTimes(1);
});

it("fails closed in production when CRON_SECRET is unset", async () => {
vi.stubEnv("CRON_SECRET", "");
vi.stubEnv("VERCEL_ENV", "production");

const res = await POST(request());
const body = await res.json();

expect(res.status).toBe(401);
expect(body.error).toBe("Unauthorized");
expect(mockSql).not.toHaveBeenCalled();
});

it("fails closed in preview when CRON_SECRET is unset", async () => {
vi.stubEnv("CRON_SECRET", "");
vi.stubEnv("VERCEL_ENV", "preview");

const res = await POST(request());

expect(res.status).toBe(401);
expect(mockSql).not.toHaveBeenCalled();
});

it("rejects requests without the bearer token when a secret is set", async () => {
vi.stubEnv("CRON_SECRET", "topsecret");
vi.stubEnv("VERCEL_ENV", "production");

const res = await POST(request());

expect(res.status).toBe(401);
expect(mockSql).not.toHaveBeenCalled();
});

it("rejects a wrong bearer token", async () => {
vi.stubEnv("CRON_SECRET", "topsecret");
vi.stubEnv("VERCEL_ENV", "production");

const res = await POST(request({ authorization: "Bearer wrong" }));

expect(res.status).toBe(401);
expect(mockSql).not.toHaveBeenCalled();
});

it("accepts a valid bearer token", async () => {
vi.stubEnv("CRON_SECRET", "topsecret");
vi.stubEnv("VERCEL_ENV", "production");
mockSql.mockResolvedValueOnce([{ count: "0" }]);
mockSql.mockResolvedValueOnce([
{ id: "11111111-1111-1111-1111-111111111111" },
]);
mockSql.mockResolvedValueOnce([]);

const res = await POST(request({ authorization: "Bearer topsecret" }));
const body = await res.json();

expect(res.status).toBe(200);
expect(body.success).toBe(true);
});
});
75 changes: 72 additions & 3 deletions web/__tests__/api/setup.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";

const mockBootstrapDatabase = vi.fn();

Expand All @@ -8,14 +9,30 @@ vi.mock("@/lib/databaseBootstrap", () => ({

import { POST } from "@/app/api/setup/route";

function request(
headers: Record<string, string> = {},
method: "GET" | "POST" = "POST"
): NextRequest {
return new NextRequest("https://example.com/api/setup", {
method,
headers,
});
}

describe("POST /api/setup", () => {
beforeEach(() => {
vi.clearAllMocks();
mockBootstrapDatabase.mockResolvedValue(undefined);
});

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

it("runs the full database bootstrap", async () => {
const res = await POST();
vi.stubEnv("CRON_SECRET", "");
vi.stubEnv("VERCEL_ENV", "");
const res = await POST(request());
const body = await res.json();

expect(res.status).toBe(200);
Expand All @@ -24,13 +41,65 @@ describe("POST /api/setup", () => {
});

it("returns a 500 when bootstrap fails", async () => {
vi.stubEnv("CRON_SECRET", "");
vi.stubEnv("VERCEL_ENV", "");
mockBootstrapDatabase.mockRejectedValue(new Error("boom"));

const res = await POST();
const res = await POST(request());
const body = await res.json();

expect(res.status).toBe(500);
expect(body.success).toBe(false);
expect(body.error).toContain("boom");
});

it("fails closed in production when CRON_SECRET is unset", async () => {
vi.stubEnv("CRON_SECRET", "");
vi.stubEnv("VERCEL_ENV", "production");

const res = await POST(request());

expect(res.status).toBe(401);
expect(mockBootstrapDatabase).not.toHaveBeenCalled();
});

it("fails closed in preview when CRON_SECRET is unset", async () => {
vi.stubEnv("CRON_SECRET", "");
vi.stubEnv("VERCEL_ENV", "preview");

const res = await POST(request());

expect(res.status).toBe(401);
expect(mockBootstrapDatabase).not.toHaveBeenCalled();
});

it("rejects requests without the bearer token when a secret is set", async () => {
vi.stubEnv("CRON_SECRET", "topsecret");
vi.stubEnv("VERCEL_ENV", "production");

const res = await POST(request());

expect(res.status).toBe(401);
expect(mockBootstrapDatabase).not.toHaveBeenCalled();
});

it("rejects a wrong bearer token", async () => {
vi.stubEnv("CRON_SECRET", "topsecret");
vi.stubEnv("VERCEL_ENV", "production");

const res = await POST(request({ authorization: "Bearer wrong" }));

expect(res.status).toBe(401);
expect(mockBootstrapDatabase).not.toHaveBeenCalled();
});

it("accepts a valid bearer token", async () => {
vi.stubEnv("CRON_SECRET", "topsecret");
vi.stubEnv("VERCEL_ENV", "production");

const res = await POST(request({ authorization: "Bearer topsecret" }));

expect(res.status).toBe(200);
expect(mockBootstrapDatabase).toHaveBeenCalledOnce();
});
});
49 changes: 47 additions & 2 deletions web/app/api/seed/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,57 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { timingSafeEqual } from "crypto";
import { sql } from "@/lib/db";
import { getConfiguredSolanaChainContext } from "@/lib/chains";
import { getErrorMessage } from "@/lib/errors";

type CountRow = { count: string };
type SkillIdRow = { id: string };

export async function POST() {
// Operator-only endpoint: it writes demo seed rows into the live database.
// Auth matches /api/github/skills/discover: a Bearer CRON_SECRET, compared in
// constant time, that fails closed on any deployed Vercel environment
// (production and preview are both internet-reachable). The endpoint stays
// open in local development so the local dev flow is unchanged.
function timingSafeStringEqual(a: string, b: string): boolean {
const aBuf = Buffer.from(a);
const bBuf = Buffer.from(b);
if (aBuf.length !== bBuf.length) {
return false;
}
return timingSafeEqual(aBuf, bBuf);
}

function isAuthorized(request: NextRequest): boolean {
const secret = process.env.CRON_SECRET?.trim();
if (secret) {
return timingSafeStringEqual(
request.headers.get("authorization") ?? "",
`Bearer ${secret}`
);
}
// No secret configured: fail closed on any deployed Vercel environment.
// Preview deployments are internet-reachable, so "not production" is not a
// safe reason to skip auth. Only allow the open path in local development.
const deployed =
process.env.VERCEL_ENV === "production" ||
process.env.VERCEL_ENV === "preview";
if (deployed) {
console.error(
"[api/seed] CRON_SECRET is not set in a deployed environment; refusing request."
);
return false;
}
console.warn(
"[api/seed] CRON_SECRET is not set; running without auth (local development only)."
);
return true;
}

export async function POST(request: NextRequest) {
if (!isAuthorized(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

try {
const chainContext = getConfiguredSolanaChainContext();
const existingRows = await sql()<CountRow>`
Expand Down
49 changes: 47 additions & 2 deletions web/app/api/setup/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,53 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { timingSafeEqual } from "crypto";
import { bootstrapDatabase } from "@/lib/databaseBootstrap";
import { getErrorMessage } from "@/lib/errors";

export async function POST() {
// Operator-only endpoint: it runs schema DDL against the live database.
// Auth matches /api/github/skills/discover: a Bearer CRON_SECRET, compared in
// constant time, that fails closed on any deployed Vercel environment
// (production and preview are both internet-reachable). The endpoint stays
// open in local development so the local dev flow is unchanged.
function timingSafeStringEqual(a: string, b: string): boolean {
const aBuf = Buffer.from(a);
const bBuf = Buffer.from(b);
if (aBuf.length !== bBuf.length) {
return false;
}
return timingSafeEqual(aBuf, bBuf);
}

function isAuthorized(request: NextRequest): boolean {
const secret = process.env.CRON_SECRET?.trim();
if (secret) {
return timingSafeStringEqual(
request.headers.get("authorization") ?? "",
`Bearer ${secret}`
);
}
// No secret configured: fail closed on any deployed Vercel environment.
// Preview deployments are internet-reachable, so "not production" is not a
// safe reason to skip auth. Only allow the open path in local development.
const deployed =
process.env.VERCEL_ENV === "production" ||
process.env.VERCEL_ENV === "preview";
if (deployed) {
console.error(
"[api/setup] CRON_SECRET is not set in a deployed environment; refusing request."
);
return false;
}
console.warn(
"[api/setup] CRON_SECRET is not set; running without auth (local development only)."
);
return true;
}

export async function POST(request: NextRequest) {
if (!isAuthorized(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

try {
await bootstrapDatabase();
return NextResponse.json({
Expand Down
Loading