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
61 changes: 55 additions & 6 deletions apps/web/__tests__/api/ai-caption.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ vi.mock("@/lib/supabase/server", () => ({
createClient: vi.fn(),
}));

const mockServiceClient = {
rpc: vi.fn(),
};

vi.mock("@/lib/supabase/service", () => ({
getServiceClient: vi.fn(() => mockServiceClient),
}));

const mockCreate = vi.fn();

vi.mock("@anthropic-ai/sdk", () => {
Expand Down Expand Up @@ -48,6 +56,10 @@ beforeEach(() => {
vi.clearAllMocks();
process.env.ANTHROPIC_API_KEY = "test-key";
process.env.NEXT_PUBLIC_SUPABASE_URL = "https://test.supabase.co";
mockServiceClient.rpc.mockResolvedValue({
data: [{ allowed: true, retry_after_seconds: 0 }],
error: null,
});
});

describe("POST /api/ai/generate-caption", () => {
Expand All @@ -62,7 +74,7 @@ describe("POST /api/ai/generate-caption", () => {

const res = await POST(
makeRequest({
images: ["https://test.supabase.co/storage/v1/object/public/screenshots/1.png"],
images: ["https://test.supabase.co/storage/v1/object/public/post-images/user-1/1.png"],
usage: { costUSD: 2.5, totalTokens: 10000 },
})
);
Expand All @@ -81,7 +93,7 @@ describe("POST /api/ai/generate-caption", () => {

const res = await POST(
makeRequest({
images: ["https://test.supabase.co/storage/v1/object/public/screenshots/1.png"],
images: ["https://test.supabase.co/storage/v1/object/public/post-images/user-1/1.png"],
usage: { costUSD: 1 },
})
);
Expand All @@ -97,7 +109,7 @@ describe("POST /api/ai/generate-caption", () => {

const res = await POST(
makeRequest({
images: ["https://test.supabase.co/storage/v1/object/public/screenshots/1.png"],
images: ["https://test.supabase.co/storage/v1/object/public/post-images/user-1/1.png"],
usage: {},
})
);
Expand Down Expand Up @@ -135,7 +147,7 @@ describe("POST /api/ai/generate-caption", () => {

const res = await POST(
makeRequest({
images: ["https://test.supabase.co/storage/v1/object/public/screenshots/1.png"],
images: ["https://test.supabase.co/storage/v1/object/public/post-images/user-1/1.png"],
usage: {},
})
);
Expand All @@ -151,7 +163,7 @@ describe("POST /api/ai/generate-caption", () => {

const res = await POST(
makeRequest({
images: ["https://test.supabase.co/storage/v1/object/public/screenshots/1.png"],
images: ["https://test.supabase.co/storage/v1/object/public/post-images/user-1/1.png"],
usage: {},
})
);
Expand All @@ -172,7 +184,7 @@ describe("POST /api/ai/generate-caption", () => {

const res = await POST(
makeRequest({
images: ["https://test.supabase.co/storage/v1/object/public/screenshots/1.png"],
images: ["https://test.supabase.co/storage/v1/object/public/post-images/user-1/1.png"],
usage: {},
})
);
Expand All @@ -182,4 +194,41 @@ describe("POST /api/ai/generate-caption", () => {
expect(json.title.length).toBe(100);
expect(json.description.length).toBe(5000);
});

it("rejects first-party storage URLs outside post-images", async () => {
mockSupabaseUser({ id: "user-1" });

const res = await POST(
makeRequest({
images: ["https://test.supabase.co/storage/v1/object/public/avatars/user-1/avatar.png"],
usage: {},
})
);
const json = await res.json();

expect(res.status).toBe(400);
expect(json.error).toBe("Images must use Straude post image uploads");
expect(mockCreate).not.toHaveBeenCalled();
});

it("applies durable AI quota before calling Anthropic", async () => {
mockSupabaseUser({ id: "user-1" });
mockServiceClient.rpc.mockResolvedValueOnce({
data: [{ allowed: false, retry_after_seconds: 42 }],
error: null,
});

const res = await POST(
makeRequest({
images: ["https://test.supabase.co/storage/v1/object/public/post-images/user-1/1.png"],
usage: {},
})
);
const json = await res.json();

expect(res.status).toBe(429);
expect(res.headers.get("Retry-After")).toBe("42");
expect(json.error).toContain("Too many requests");
expect(mockCreate).not.toHaveBeenCalled();
});
});
104 changes: 90 additions & 14 deletions apps/web/__tests__/api/auth-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";

const mockServiceClient: Record<string, any> = {
from: vi.fn(),
rpc: vi.fn(),
};

vi.mock("@/lib/supabase/service", () => ({
Expand All @@ -12,9 +13,13 @@ vi.mock("@/lib/supabase/server", () => ({
createClient: vi.fn(),
}));

vi.mock("@/lib/api/cli-auth", () => ({
createCliToken: vi.fn(),
}));
vi.mock("@/lib/api/cli-auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api/cli-auth")>();
return {
...actual,
createCliToken: vi.fn(),
};
});

import { POST as initPOST } from "@/app/api/auth/cli/init/route";
import { POST as pollPOST } from "@/app/api/auth/cli/poll/route";
Expand All @@ -30,6 +35,7 @@ function mockChain(overrides = {}) {
update: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
gt: vi.fn().mockReturnThis(),
is: vi.fn().mockReturnThis(),
neq: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({ data: null, error: null }),
...overrides,
Expand All @@ -50,6 +56,10 @@ beforeEach(() => {
vi.stubEnv("NEXT_PUBLIC_APP_URL", "https://straude.com");
vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", "https://test.supabase.co");
vi.stubEnv("SUPABASE_SECRET_KEY", "test-secret");
mockServiceClient.rpc.mockResolvedValue({
data: [{ allowed: true, retry_after_seconds: 0 }],
error: null,
});
});

function mockAuthenticatedUser(user: { id: string } | null = { id: "user-abc" }) {
Expand All @@ -76,9 +86,20 @@ describe("POST /api/auth/cli/init", () => {

expect(res.status).toBe(200);
expect(json.code).toMatch(/^[A-Z0-9]{4}-[A-Z0-9]{4}$/);
expect(json.verify_url).toBe(
`https://straude.com/cli/verify?code=${json.code}`
);
expect(json.poll_secret).toMatch(/^[A-Za-z0-9_-]+$/);

const url = new URL(json.verify_url);
expect(`${url.origin}${url.pathname}`).toBe("https://straude.com/cli/verify");
expect(url.searchParams.get("code")).toBe(json.code);
expect(url.searchParams.get("verify_secret")).toMatch(/^[A-Za-z0-9_-]+$/);

const insertArg = chain.insert.mock.calls[0][0];
expect(insertArg.code).toBe(json.code);
expect(insertArg.status).toBe("pending");
expect(insertArg.poll_secret_hash).toMatch(/^[a-f0-9]{64}$/);
expect(insertArg.verify_secret_hash).toMatch(/^[a-f0-9]{64}$/);
expect(insertArg.poll_secret_hash).not.toBe(json.poll_secret);
expect(insertArg.verify_secret_hash).not.toBe(url.searchParams.get("verify_secret"));
});

it("returns 500 when insert fails", async () => {
Expand Down Expand Up @@ -120,13 +141,21 @@ describe("POST /api/auth/cli/poll", () => {
expect(json.error).toBe("Missing code");
});

it("returns error when poll_secret is missing", async () => {
const res = await pollPOST(mockRequest({ code: "AAAA-BBBB" }));
const json = await res.json();

expect(res.status).toBe(400);
expect(json.error).toBe("Missing poll_secret");
});

it("returns expired when code not found", async () => {
const chain = mockChain({
single: vi.fn().mockResolvedValue({ data: null, error: { code: "PGRST116" } }),
});
mockServiceClient.from.mockReturnValue(chain);

const res = await pollPOST(mockRequest({ code: "XXXX-YYYY" }));
const res = await pollPOST(mockRequest({ code: "XXXX-YYYY", poll_secret: "secret" }));
const json = await res.json();

expect(json.status).toBe("expired");
Expand All @@ -142,7 +171,7 @@ describe("POST /api/auth/cli/poll", () => {
});
mockServiceClient.from.mockReturnValue(chain);

const res = await pollPOST(mockRequest({ code: "AAAA-BBBB" }));
const res = await pollPOST(mockRequest({ code: "AAAA-BBBB", poll_secret: "secret" }));
const json = await res.json();

expect(json.status).toBe("pending");
Expand All @@ -158,7 +187,7 @@ describe("POST /api/auth/cli/poll", () => {
});
mockServiceClient.from.mockReturnValue(chain);

const res = await pollPOST(mockRequest({ code: "AAAA-BBBB" }));
const res = await pollPOST(mockRequest({ code: "AAAA-BBBB", poll_secret: "secret" }));
const json = await res.json();

expect(json.status).toBe("expired");
Expand All @@ -174,7 +203,7 @@ describe("POST /api/auth/cli/poll", () => {
});
mockServiceClient.from.mockReturnValue(chain);

const res = await pollPOST(mockRequest({ code: "AAAA-BBBB" }));
const res = await pollPOST(mockRequest({ code: "AAAA-BBBB", poll_secret: "secret" }));
const json = await res.json();

expect(json.status).toBe("expired");
Expand All @@ -194,6 +223,10 @@ describe("POST /api/auth/cli/poll", () => {
},
error: null,
})
.mockResolvedValueOnce({
data: { user_id: "user-abc" },
error: null,
})
.mockResolvedValueOnce({
data: { username: "testuser" },
error: null,
Expand All @@ -202,21 +235,53 @@ describe("POST /api/auth/cli/poll", () => {

(createCliToken as any).mockReturnValue("jwt-token-123");

const res = await pollPOST(mockRequest({ code: "AAAA-BBBB" }));
const res = await pollPOST(mockRequest({ code: "AAAA-BBBB", poll_secret: "secret" }));
const json = await res.json();

expect(json.status).toBe("completed");
expect(json.token).toBe("jwt-token-123");
expect(json.username).toBe("testuser");
expect(createCliToken).toHaveBeenCalledWith("user-abc", "testuser");
expect(chain.update).toHaveBeenCalledWith({
status: "used",
redeemed_at: expect.any(String),
});
expect(chain.is).toHaveBeenCalledWith("redeemed_at", null);
});

it("does not mint a token when a completed code was already redeemed", async () => {
const futureDate = new Date(Date.now() + 600_000).toISOString();
const chain = mockChain();
chain.single = vi.fn()
.mockResolvedValueOnce({
data: {
id: "1",
code: "AAAA-BBBB",
status: "completed",
expires_at: futureDate,
user_id: "user-abc",
},
error: null,
})
.mockResolvedValueOnce({
data: null,
error: { code: "PGRST116", message: "No rows" },
});
mockServiceClient.from.mockReturnValue(chain);

const res = await pollPOST(mockRequest({ code: "AAAA-BBBB", poll_secret: "secret" }));
const json = await res.json();

expect(json.status).toBe("expired");
expect(createCliToken).not.toHaveBeenCalled();
});
});

describe("POST /api/auth/cli/verify", () => {
it("rejects unauthenticated requests", async () => {
mockAuthenticatedUser(null);

const res = await verifyPOST(mockRequest({ code: "AAAA-BBBB" }));
const res = await verifyPOST(mockRequest({ code: "AAAA-BBBB", verify_secret: "secret" }));
const json = await res.json();

expect(res.status).toBe(401);
Expand All @@ -233,6 +298,16 @@ describe("POST /api/auth/cli/verify", () => {
expect(json.error).toBe("Missing code");
});

it("returns error when verify_secret is missing", async () => {
mockAuthenticatedUser();

const res = await verifyPOST(mockRequest({ code: "AAAA-BBBB" }));
const json = await res.json();

expect(res.status).toBe(400);
expect(json.error).toBe("Missing verify_secret");
});

it("returns error for invalid JSON", async () => {
mockAuthenticatedUser();

Expand All @@ -257,7 +332,7 @@ describe("POST /api/auth/cli/verify", () => {
});
mockServiceClient.from.mockReturnValue(chain);

const res = await verifyPOST(mockRequest({ code: "AAAA-BBBB" }));
const res = await verifyPOST(mockRequest({ code: "AAAA-BBBB", verify_secret: "secret" }));
const json = await res.json();

expect(res.status).toBe(400);
Expand All @@ -274,7 +349,7 @@ describe("POST /api/auth/cli/verify", () => {
});
mockServiceClient.from.mockReturnValue(chain);

const res = await verifyPOST(mockRequest({ code: " AAAA-BBBB " }));
const res = await verifyPOST(mockRequest({ code: " AAAA-BBBB ", verify_secret: "secret" }));
const json = await res.json();

expect(res.status).toBe(200);
Expand All @@ -284,6 +359,7 @@ describe("POST /api/auth/cli/verify", () => {
status: "completed",
});
expect(chain.eq).toHaveBeenCalledWith("code", "AAAA-BBBB");
expect(chain.eq).toHaveBeenCalledWith("verify_secret_hash", expect.stringMatching(/^[a-f0-9]{64}$/));
expect(chain.eq).toHaveBeenCalledWith("status", "pending");
expect(chain.gt).toHaveBeenCalledWith("expires_at", expect.any(String));
expect(chain.select).toHaveBeenCalledWith("id");
Expand Down
5 changes: 5 additions & 0 deletions apps/web/__tests__/api/comment-email-notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ function makeServiceClient({ emailNotifications }: { emailNotifications: boolean
};

return {
rpc: vi.fn((fn: string) => Promise.resolve(
fn === "check_rate_limit"
? { data: [{ allowed: true, retry_after_seconds: 0 }], error: null }
: { data: null, error: null },
)),
from: vi.fn().mockImplementation((table: string) => {
if (table === "users") return usersChain;
if (table === "posts") return postsChain;
Expand Down
12 changes: 12 additions & 0 deletions apps/web/__tests__/api/social.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ vi.mock("@/lib/supabase/server", () => ({
createClient: vi.fn(),
}));

const mockServiceClient = {
rpc: vi.fn(),
};

vi.mock("@/lib/supabase/service", () => ({
getServiceClient: vi.fn(() => mockServiceClient),
}));

import { POST as followPOST, DELETE as followDELETE } from "@/app/api/follow/[username]/route";
import {
POST as kudosPOST,
Expand Down Expand Up @@ -45,6 +53,10 @@ function makeRequest(

beforeEach(() => {
vi.clearAllMocks();
mockServiceClient.rpc.mockResolvedValue({
data: [{ allowed: true, retry_after_seconds: 0 }],
error: null,
});
});

// ---------- Follow ----------
Expand Down
Loading
Loading