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
39 changes: 36 additions & 3 deletions __tests__/page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,46 @@
import { render, screen } from "@testing-library/react";
import { expect, test } from "vitest";
import { beforeEach, expect, test, vi } from "vitest";
import Home from "../src/app/page";

test("Home page renders welcome heading", () => {
render(<Home />);
// Mock Next.js modules
vi.mock("next/headers", () => ({
headers: vi.fn(() => Promise.resolve(new Headers())),
}));

vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));

// Mock auth module
vi.mock("@/lib/auth", () => ({
auth: {
api: {
getSession: vi.fn(() =>
Promise.resolve({
user: {
email: "test@example.com",
name: "Test User",
},
}),
),
},
},
}));

beforeEach(() => {
vi.clearAllMocks();
});

test("Home page renders welcome heading when user is logged in", async () => {
render(await Home());

expect(
screen.getByRole("heading", {
level: 1,
name: /Welcome to ToolHive Cloud UI/i,
}),
).toBeDefined();

expect(screen.getByText(/You are logged in as/i)).toBeDefined();
expect(screen.getByText(/test@example.com/i)).toBeDefined();
});
147 changes: 147 additions & 0 deletions __tests__/signin.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import SignInPage from "@/app/signin/page";

// Mock Next.js Image component
vi.mock("next/image", () => ({
default: () => null,
}));

// Mock sonner toast
vi.mock("sonner", () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
},
}));

// Mock auth client
vi.mock("@/lib/auth-client", () => ({
authClient: {
signIn: {
oauth2: vi.fn(),
},
},
}));

describe("SignInPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});

afterEach(() => {
cleanup();
});

test("renders signin page with all elements", () => {
render(<SignInPage />);

// Check main heading
expect(
screen.getByRole("heading", {
level: 2,
name: /Sign in/i,
}),
).toBeDefined();

// Check description text
expect(
screen.getByText(/Sign in using your company credentials/i),
).toBeDefined();

// Check Toolhive branding
expect(
screen.getByRole("heading", {
level: 1,
name: /Toolhive/i,
}),
).toBeDefined();

// Check Okta button
expect(screen.getByRole("button", { name: /Okta/i })).toBeDefined();
});

test("calls authClient.signIn.oauth2 when button is clicked", async () => {
const user = userEvent.setup();
const { authClient } = await import("@/lib/auth-client");
vi.mocked(authClient.signIn.oauth2).mockResolvedValue({ error: null });

render(<SignInPage />);

const oktaButton = screen.getByRole("button", { name: /Okta/i });
await user.click(oktaButton);

await waitFor(() => {
expect(authClient.signIn.oauth2).toHaveBeenCalledWith({
providerId: "oidc",
callbackURL: "/catalog",
});
});
});

test("shows error toast when signin fails with error", async () => {
const user = userEvent.setup();
const { toast } = await import("sonner");
const { authClient } = await import("@/lib/auth-client");

vi.mocked(authClient.signIn.oauth2).mockResolvedValue({
error: {
message: "Invalid credentials",
},
});

render(<SignInPage />);

const oktaButton = screen.getByRole("button", { name: /Okta/i });
await user.click(oktaButton);

await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Signin failed", {
description: "Invalid credentials",
});
});
});

test("shows error toast when signin throws exception", async () => {
const user = userEvent.setup();
const { toast } = await import("sonner");
const { authClient } = await import("@/lib/auth-client");

vi.mocked(authClient.signIn.oauth2).mockRejectedValue(
new Error("Network error"),
);

render(<SignInPage />);

const oktaButton = screen.getByRole("button", { name: /Okta/i });
await user.click(oktaButton);

await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Signin error", {
description: "Network error",
});
});
});

test("shows generic error message for unknown errors", async () => {
const user = userEvent.setup();
const { toast } = await import("sonner");
const { authClient } = await import("@/lib/auth-client");

vi.mocked(authClient.signIn.oauth2).mockRejectedValue(
"Something went wrong",
);

render(<SignInPage />);

const oktaButton = screen.getByRole("button", { name: /Okta/i });
await user.click(oktaButton);

await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Signin error", {
description: "An unexpected error occurred",
});
});
});
});
7 changes: 6 additions & 1 deletion dev-auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,21 @@ This directory contains a simple OIDC provider for local development and testing
## What is it?

A minimal OIDC-compliant identity provider built with `oidc-provider` that:

- Automatically logs in a test user (`test@example.com`)
- Auto-approves all consent requests
- Supports standard OAuth 2.0 / OIDC flows

## How to use

Start the provider:

```bash
pnpm oidc
```

Or run it alongside the Next.js app:

```bash
pnpm dev
```
Expand All @@ -26,6 +29,7 @@ The provider runs on `http://localhost:4000` and is already configured in `.env.
## Configuration

The provider is pre-configured with:

- **Client ID**: `better-auth-dev`
- **Client Secret**: `dev-secret-change-in-production`
- **Test User**: `test@example.com` (Test User)
Expand All @@ -35,6 +39,7 @@ The provider is pre-configured with:
## For Production

Replace this with a real OIDC provider (Okta, Keycloak, Auth0, etc.) by updating the environment variables in `.env.local`:
- `OIDC_ISSUER_URL`

- `OIDC_ISSUER`
- `OIDC_CLIENT_ID`
- `OIDC_CLIENT_SECRET`
2 changes: 1 addition & 1 deletion dev-auth/oidc-provider.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,6 @@ oidc.listen(PORT, () => {
console.log(`🔑 Client Secret: dev-secret-change-in-production`);
console.log(`👤 Test user: test@example.com`);
console.log(
`\n⚙️ Update your .env.local with:\nOIDC_CLIENT_ID=better-auth-dev\nOIDC_CLIENT_SECRET=dev-secret-change-in-production\nOIDC_ISSUER_URL=${ISSUER}`,
`\n⚙️ Update your .env.local with:\nOIDC_CLIENT_ID=better-auth-dev\nOIDC_CLIENT_SECRET=dev-secret-change-in-production\nOIDC_ISSUER=${ISSUER}`,
);
});
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@
"next": "16.0.3",
"react": "19.2.0",
"react-dom": "19.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "3.4.0"
},
"devDependencies": {
"@biomejs/biome": "2.3.5",
"@tailwindcss/postcss": "^4",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.0.0",
"@types/react": "^19",
"@types/react-dom": "^19",
Expand Down
27 changes: 27 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions public/okta-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions public/toolhive-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
44 changes: 44 additions & 0 deletions src/app/catalog/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { SignOut } from "@/components/sign-out-button";
import { auth } from "@/lib/auth";

export default async function CatalogPage() {
const session = await auth.api.getSession({
headers: await headers(),
});

if (!session) {
redirect("/signin");
}

return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-black">
<main className="flex flex-col items-center gap-8 rounded-lg bg-white p-12 shadow-lg dark:bg-zinc-900">
<h1 className="text-3xl font-bold text-black dark:text-white">
Hello World! 🎉
</h1>

<div className="flex flex-col gap-4 text-center">
<p className="text-zinc-600 dark:text-zinc-400">
You are successfully authenticated!
</p>

<div className="rounded-lg bg-zinc-100 p-4 dark:bg-zinc-800">
<p className="text-sm font-semibold text-zinc-800 dark:text-zinc-200">
User Info:
</p>
<p className="text-zinc-600 dark:text-zinc-400">
Email: <strong>{session.user.email || "Not provided"}</strong>
</p>
<p className="text-zinc-600 dark:text-zinc-400">
User ID: <strong>{session.user.id}</strong>
</p>
</div>
</div>

<SignOut />
</main>
</div>
);
}
Loading