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
63 changes: 63 additions & 0 deletions app/auth/pollinations/callback/actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { savePollinationsApiKey } from "./actions";
import { fetchMutation } from "convex/nextjs";
import { getConvexClerkToken } from "@/app/_server/convex/client";
import { api } from "@/convex/_generated/api";

vi.mock("convex/nextjs", () => ({
fetchMutation: vi.fn(),
}));

vi.mock("@/app/_server/convex/client", () => ({
getConvexClerkToken: vi.fn(),
}));

vi.mock("@/convex/_generated/api", () => ({
api: {
users: {
setPollinationsApiKey: "users:setPollinationsApiKey",
},
},
}));

describe("savePollinationsApiKey", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getConvexClerkToken).mockResolvedValue("convex-token");
vi.mocked(fetchMutation).mockResolvedValue({ success: true });
});

it("stores a valid API key with the Clerk Convex token", async () => {
const result = await savePollinationsApiKey("sk_test1234");

expect(result).toEqual({ status: "success" });
expect(fetchMutation).toHaveBeenCalledWith(
api.users.setPollinationsApiKey,
{ apiKey: "sk_test1234" },
{ token: "convex-token" },
);
});

it("rejects a missing API key before calling Convex", async () => {
const result = await savePollinationsApiKey(null);

expect(result).toEqual({ status: "error_missing_key" });
expect(fetchMutation).not.toHaveBeenCalled();
});

it("rejects an invalid API key before calling Convex", async () => {
const result = await savePollinationsApiKey("invalid");

expect(result).toEqual({ status: "error_invalid_key" });
expect(fetchMutation).not.toHaveBeenCalled();
});

it("returns save failure when no auth token is available", async () => {
vi.mocked(getConvexClerkToken).mockResolvedValue(undefined);

const result = await savePollinationsApiKey("sk_test1234");

expect(result).toEqual({ status: "error_save_failed" });
expect(fetchMutation).not.toHaveBeenCalled();
});
});
51 changes: 51 additions & 0 deletions app/auth/pollinations/callback/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"use server";

import { fetchMutation } from "convex/nextjs";
import { api } from "@/convex/_generated/api";
import { getConvexClerkToken } from "@/app/_server/convex/client";
import { isValidApiKeyFormat } from "@/lib/pollen-auth/storage";

export type SavePollinationsApiKeyResult = {
status:
| "idle"
| "success"
| "error_missing_key"
| "error_invalid_key"
| "error_save_failed";
};

/**
* Stores the Pollinations API key from the OAuth callback via an authenticated
* server boundary. The browser still has to read the URL hash because fragments
* are not sent to the server.
*/
export async function savePollinationsApiKey(
apiKey: string | null,
): Promise<SavePollinationsApiKeyResult> {
if (!apiKey) {
return { status: "error_missing_key" };
}

if (!isValidApiKeyFormat(apiKey)) {
return { status: "error_invalid_key" };
}

try {
const token = await getConvexClerkToken();

if (!token) {
return { status: "error_save_failed" };
}

await fetchMutation(
api.users.setPollinationsApiKey,
{ apiKey },
{ token },
);

return { status: "success" };
} catch (error) {
console.error("[PollinationsCallback] Error saving API key:", error);
return { status: "error_save_failed" };
Comment on lines +47 to +49
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid logging the raw exception in the API-key save path.

This handler processes a secret, and dumping the full thrown object to server logs can expose the key or request metadata from downstream failures. Log a sanitized identifier instead.

Suggested fix
   } catch (error) {
-    console.error("[PollinationsCallback] Error saving API key:", error);
+    console.error("[PollinationsCallback] Error saving API key", {
+      errorName: error instanceof Error ? error.name : "unknown",
+    });
     return { status: "error_save_failed" };
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (error) {
console.error("[PollinationsCallback] Error saving API key:", error);
return { status: "error_save_failed" };
} catch (error) {
console.error("[PollinationsCallback] Error saving API key", {
errorName: error instanceof Error ? error.name : "unknown",
});
return { status: "error_save_failed" };
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/auth/pollinations/callback/actions.ts` around lines 47 - 49, The catch
block in app/auth/pollinations/callback/actions.ts currently logs the raw thrown
object (console.error("[PollinationsCallback] Error saving API key:", error)),
which may expose secrets; change this to log a sanitized identifier
instead—replace the raw error log with a minimal safe message and non-sensitive
metadata (e.g., console.error("[PollinationsCallback] Error saving API key:", {
errorName: error?.name ?? "UnknownError" }) or log a redacted message like "{
message: 'save failed', errorName: error?.name }"), and ensure the thrown error
object or full stack is not included; keep the return { status:
"error_save_failed" } behavior intact.

}
}
75 changes: 60 additions & 15 deletions app/auth/pollinations/callback/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*/

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import { render, screen, act, fireEvent } from "@testing-library/react";
import PollinationsCallbackPage from "./page";

// Mock next/navigation
Expand All @@ -33,19 +33,14 @@ vi.mock("@/lib/pollen-auth", () => ({
getCallbackUrl: vi.fn(() => "https://example.com/auth/pollinations/callback"),
}));

// Mock Convex
const mockSetApiKey = vi.fn().mockResolvedValue({ success: true });
// Mock server action
const mockSavePollinationsApiKey = vi.hoisted(() =>
vi.fn().mockResolvedValue({ status: "success" }),
);

vi.mock("convex/react", () => ({
useMutation: () => mockSetApiKey,
}));

vi.mock("@/convex/_generated/api", () => ({
api: {
users: {
setPollinationsApiKey: "setPollinationsApiKey",
},
},
vi.mock("./actions", () => ({
savePollinationsApiKey: (apiKey: string | null) =>
mockSavePollinationsApiKey(apiKey),
}));

// Mock sonner
Expand Down Expand Up @@ -114,16 +109,35 @@ describe("PollinationsCallbackPage", () => {
}

/**
* Helper to advance past processing and wait for success state.
* Helper to advance past processing and wait for the user-confirmation state.
*/
async function advanceToSuccessState() {
async function advanceToReadyState() {
await advancePastProcessingDelay();
// Allow React to process the state update
await act(async () => {
vi.advanceTimersByTime(0);
});
}

/**
* Helper to submit the server-action form and wait for success state.
*/
async function submitConnection() {
await act(async () => {
fireEvent.click(
screen.getByRole("button", { name: /Finish Connection/i }),
);
});
}

/**
* Helper to advance past processing and submit the connection.
*/
async function advanceToSuccessState() {
await advanceToReadyState();
await submitConnection();
}

describe("returnTo validation (isSafeReturnTo)", () => {
it("should accept valid local paths", async () => {
window.location.hash = "#api_key=sk_test123";
Expand Down Expand Up @@ -278,6 +292,7 @@ describe("PollinationsCallbackPage", () => {
"",
"/auth/pollinations/callback?returnTo=/dashboard",
);
expect(mockSavePollinationsApiKey).toHaveBeenCalledWith("sk_test123");
});

it("should work correctly when there is no query string", async () => {
Expand All @@ -294,6 +309,36 @@ describe("PollinationsCallbackPage", () => {
"/auth/pollinations/callback",
);
});

it("should clear hash immediately during callback processing delay", () => {
window.location.hash = "#api_key=sk_test123";
window.location.search = "?returnTo=/studio";

render(<PollinationsCallbackPage />);

expect(window.history.replaceState).toHaveBeenCalledWith(
null,
"",
"/auth/pollinations/callback?returnTo=/studio",
);
expect(mockSavePollinationsApiKey).not.toHaveBeenCalled();
});

it("should clear hash before invalid-key callback bailout", async () => {
window.location.hash = "#api_key=invalid_key";

render(<PollinationsCallbackPage />);

await advancePastProcessingDelay();

expect(window.history.replaceState).toHaveBeenCalledWith(
null,
"",
"/auth/pollinations/callback",
);
expect(mockSavePollinationsApiKey).not.toHaveBeenCalled();
expect(screen.getByText(/Invalid API Key/i)).toBeInTheDocument();
});
});

describe("error states", () => {
Expand Down
Loading
Loading