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
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -1305,7 +1305,7 @@ Plugin architecture refactoring to support true modular development, plugin isol
**Fix:**
1. Added `AuthPlugin` from `@objectstack/plugin-auth` to `objectstack.config.ts` for server mode (`pnpm dev:server`).
2. Created `authHandlers.ts` with in-memory mock implementations of better-auth endpoints for MSW mode (`pnpm dev`). Mock handlers are added to `customHandlers` in both `browser.ts` and `server.ts`.
3. Mock handlers support: sign-up/email, sign-in/email, get-session, sign-out, forgot-password, reset-password, update-user.
3. Mock handlers support: sign-up/email, sign-in/email, get-session, sign-out, forget-password (better-auth convention), reset-password, update-user.

**Tests:** 11 new auth handler tests, all existing MSW (7) and auth (24) tests pass.

Expand Down
4 changes: 2 additions & 2 deletions apps/console/src/__tests__/authHandlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,8 @@ describe('Mock Auth Handlers', () => {
expect(sessionRes.status).toBe(401);
});

it('should handle forgot-password', async () => {
const res = await fetch(`${BASE_URL}/forgot-password`, {
it('should handle forget-password', async () => {
const res = await fetch(`${BASE_URL}/forget-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'alice@example.com' }),
Expand Down
7 changes: 4 additions & 3 deletions apps/console/src/mocks/authHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* POST /sign-in/email — authenticate with email + password
* GET /get-session — retrieve the current session
* POST /sign-out — clear the session
* POST /forgot-password — no-op acknowledgement
* POST /forget-password — no-op acknowledgement (better-auth convention)
* POST /reset-password — no-op acknowledgement
* POST /update-user — update the current user's profile
*/
Expand Down Expand Up @@ -163,8 +163,9 @@ export function createAuthHandlers(baseUrl: string): HttpHandler[] {
}),

// ── Forgot Password (mock acknowledgement) ──────────────────────────
http.post(`${p}/forgot-password`, () => {
return HttpResponse.json({ success: true });
// better-auth uses "forget-password" (not "forgot-password")
http.post(`${p}/forget-password`, () => {
return HttpResponse.json({ status: true });
}),

// ── Reset Password (mock acknowledgement) ────────────────────────────
Expand Down
5 changes: 2 additions & 3 deletions packages/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Authentication system for Object UI — AuthProvider, guards, login/register for
- 🛡️ **AuthGuard** - Protect routes and components from unauthenticated access
- 📝 **Pre-built Forms** - LoginForm, RegisterForm, and ForgotPasswordForm ready to use
- 👤 **UserMenu** - Display authenticated user info with sign-out support
- 🔑 **Auth Client Factory** - `createAuthClient` for pluggable backend integration
- 🔑 **Auth Client Factory** - `createAuthClient` powered by official [better-auth](https://better-auth.com) client
- 🌐 **Authenticated Fetch** - `createAuthenticatedFetch` for automatic token injection
- 👀 **Preview Mode** - Auto-login with simulated identity for marketplace demos and app showcases
- 🎯 **Type-Safe** - Full TypeScript support with exported types
Expand All @@ -30,8 +30,7 @@ import { AuthProvider, useAuth, AuthGuard } from '@object-ui/auth';
import { createAuthClient } from '@object-ui/auth';

const authClient = createAuthClient({
provider: 'custom',
apiUrl: 'https://api.example.com/auth',
baseURL: 'https://api.example.com/auth',
});

function App() {
Expand Down
3 changes: 2 additions & 1 deletion packages/auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
"react": "^18.0.0 || ^19.0.0"
},
"dependencies": {
"@object-ui/types": "workspace:*"
"@object-ui/types": "workspace:*",
"better-auth": "^1.5.4"
},
"devDependencies": {
"@types/react": "19.2.14",
Expand Down
206 changes: 113 additions & 93 deletions packages/auth/src/__tests__/createAuthClient.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,47 @@
/**
* Tests for createAuthClient
* Tests for createAuthClient (backed by official better-auth client)
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createAuthClient } from '../createAuthClient';
import type { AuthClient } from '../types';
Comment on lines 5 to 7
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

beforeEach and the AuthClient type are imported but never used in this test file after the refactor. The beforeEach was previously used to share client and mockFetch setup, but now each test creates its own local variables. These unused imports should be removed to keep the file clean and avoid TypeScript/linter warnings.

Suggested change
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createAuthClient } from '../createAuthClient';
import type { AuthClient } from '../types';
import { describe, it, expect, vi } from 'vitest';
import { createAuthClient } from '../createAuthClient';

Copilot uses AI. Check for mistakes.

describe('createAuthClient', () => {
let client: AuthClient;
let mockFetch: ReturnType<typeof vi.fn>;

beforeEach(() => {
mockFetch = vi.fn();
client = createAuthClient({ baseURL: '/api/auth', fetchFn: mockFetch });
/**
* Helper: creates a mock fetch that routes requests based on URL
* and records every call for inspection.
*/
function createMockFetch(handlers: Record<string, { status?: number; body: unknown }>) {
const calls: Array<{ url: string; method: string; body: string | null }> = [];
const mockFn = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
let url: string;
if (typeof input === 'string') {
url = input;
} else if (input instanceof URL) {
url = input.toString();
} else {
url = input.url;
}
calls.push({ url, method: init?.method ?? 'GET', body: init?.body as string | null });
for (const [pattern, handler] of Object.entries(handlers)) {
if (url.includes(pattern)) {
return new Response(JSON.stringify(handler.body), {
status: handler.status ?? 200,
headers: { 'Content-Type': 'application/json' },
});
}
}
return new Response(JSON.stringify({ message: 'Not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
});
return { mockFn, calls };
}

describe('createAuthClient', () => {
it('creates a client with all expected methods', () => {
const { mockFn } = createMockFetch({});
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
Comment on lines 42 to +44
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

The resolveAuthURL function contains a non-trivial branch for handling relative URLs (falling back to window.location.origin in browsers and http://localhost in other environments), but this behavior is not covered by any test. The existing tests all use an absolute URL (http://localhost/api/auth), so the relative-URL path is never exercised. A test using a relative base URL (e.g. '/api/v1/auth') should be added to verify that the function correctly assembles the origin and basePath in a browser-like environment.

Copilot uses AI. Check for mistakes.
expect(client).toHaveProperty('signIn');
expect(client).toHaveProperty('signUp');
expect(client).toHaveProperty('signOut');
Expand All @@ -26,156 +52,150 @@ describe('createAuthClient', () => {
});

it('signIn sends POST to /sign-in/email', async () => {
const mockResponse = {
user: { id: '1', name: 'Test', email: 'test@test.com' },
session: { token: 'tok123' },
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockResponse),
const { mockFn, calls } = createMockFetch({
'/sign-in/email': {
body: {
user: { id: '1', name: 'Test', email: 'test@test.com' },
session: { token: 'tok123', id: 's1', userId: '1', expiresAt: '2025-01-01' },
},
},
});
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });

const result = await client.signIn({ email: 'test@test.com', password: 'pass123' });

expect(mockFetch).toHaveBeenCalledWith(
'/api/auth/sign-in/email',
expect.objectContaining({
method: 'POST',
credentials: 'include',
body: JSON.stringify({ email: 'test@test.com', password: 'pass123' }),
}),
);
expect(calls).toHaveLength(1);
expect(calls[0].url).toContain('/api/auth/sign-in/email');
expect(calls[0].method).toBe('POST');
expect(JSON.parse(calls[0].body!)).toMatchObject({ email: 'test@test.com', password: 'pass123' });
expect(result.user.email).toBe('test@test.com');
expect(result.session.token).toBe('tok123');
});

it('signUp sends POST to /sign-up/email', async () => {
const mockResponse = {
user: { id: '2', name: 'New User', email: 'new@test.com' },
session: { token: 'tok456' },
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockResponse),
const { mockFn, calls } = createMockFetch({
'/sign-up/email': {
body: {
user: { id: '2', name: 'New User', email: 'new@test.com' },
session: { token: 'tok456', id: 's2', userId: '2', expiresAt: '2025-01-01' },
},
},
});
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });

const result = await client.signUp({ name: 'New User', email: 'new@test.com', password: 'pass123' });

expect(mockFetch).toHaveBeenCalledWith(
'/api/auth/sign-up/email',
expect.objectContaining({ method: 'POST' }),
);
expect(calls).toHaveLength(1);
expect(calls[0].url).toContain('/api/auth/sign-up/email');
expect(calls[0].method).toBe('POST');
expect(JSON.parse(calls[0].body!)).toMatchObject({ email: 'new@test.com', name: 'New User' });
expect(result.user.name).toBe('New User');
});

it('signOut sends POST to /sign-out', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({}),
const { mockFn, calls } = createMockFetch({
'/sign-out': { body: { success: true } },
});
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });

await client.signOut();

expect(mockFetch).toHaveBeenCalledWith(
'/api/auth/sign-out',
expect.objectContaining({ method: 'POST' }),
);
expect(calls).toHaveLength(1);
expect(calls[0].url).toContain('/api/auth/sign-out');
expect(calls[0].method).toBe('POST');
});

it('getSession sends GET to /get-session', async () => {
const mockSession = {
user: { id: '1', name: 'Test', email: 'test@test.com' },
session: { token: 'tok789' },
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockSession),
const { mockFn, calls } = createMockFetch({
'/get-session': {
body: {
user: { id: '1', name: 'Test', email: 'test@test.com' },
session: { token: 'tok789', id: 's1', userId: '1', expiresAt: '2025-01-01' },
},
},
});
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });

const result = await client.getSession();

expect(mockFetch).toHaveBeenCalledWith(
'/api/auth/get-session',
expect.objectContaining({ method: 'GET' }),
);
expect(calls).toHaveLength(1);
expect(calls[0].url).toContain('/api/auth/get-session');
expect(calls[0].method).toBe('GET');
expect(result?.user.id).toBe('1');
});

it('getSession returns null on failure', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
const { mockFn } = createMockFetch({
'/get-session': { status: 401, body: { message: 'Unauthorized' } },
});
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });

const result = await client.getSession();
expect(result).toBeNull();
});

it('forgotPassword sends POST to /forgot-password', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({}),
it('forgotPassword sends POST to /forget-password', async () => {
const { mockFn, calls } = createMockFetch({
'/forget-password': { body: { status: true } },
});
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });

await client.forgotPassword('test@test.com');

expect(mockFetch).toHaveBeenCalledWith(
'/api/auth/forgot-password',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ email: 'test@test.com' }),
}),
);
expect(calls).toHaveLength(1);
expect(calls[0].url).toContain('/api/auth/forget-password');
expect(calls[0].method).toBe('POST');
expect(JSON.parse(calls[0].body!)).toMatchObject({ email: 'test@test.com' });
});

it('resetPassword sends POST to /reset-password', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({}),
const { mockFn, calls } = createMockFetch({
'/reset-password': { body: { status: true } },
});
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });

await client.resetPassword('token123', 'newpass');

expect(mockFetch).toHaveBeenCalledWith(
'/api/auth/reset-password',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ token: 'token123', newPassword: 'newpass' }),
}),
);
expect(calls).toHaveLength(1);
expect(calls[0].url).toContain('/api/auth/reset-password');
expect(calls[0].method).toBe('POST');
expect(JSON.parse(calls[0].body!)).toMatchObject({ token: 'token123', newPassword: 'newpass' });
});

it('throws error with server message on non-OK response', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 401,
json: () => Promise.resolve({ message: 'Invalid credentials' }),
const { mockFn } = createMockFetch({
'/sign-in/email': {
status: 401,
body: { message: 'Invalid credentials', code: 'INVALID_CREDENTIALS' },
},
});
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });

await expect(client.signIn({ email: 'x', password: 'y' })).rejects.toThrow('Invalid credentials');
});

it('throws generic error when response has no message', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
json: () => Promise.reject(new Error('parse error')),
it('throws error on non-OK response without message', async () => {
const { mockFn } = createMockFetch({
'/sign-in/email': { status: 500, body: {} },
});
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });

await expect(client.signIn({ email: 'x', password: 'y' })).rejects.toThrow(
'Auth request failed with status 500',
);
await expect(client.signIn({ email: 'x', password: 'y' })).rejects.toThrow();
});

it('updateUser sends POST to /update-user and returns user', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ user: { id: '1', name: 'Updated', email: 'test@test.com' } }),
const { mockFn, calls } = createMockFetch({
'/update-user': {
body: { user: { id: '1', name: 'Updated', email: 'test@test.com' } },
},
});
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });

const result = await client.updateUser({ name: 'Updated' });

expect(calls).toHaveLength(1);
expect(calls[0].url).toContain('/api/auth/update-user');
expect(calls[0].method).toBe('POST');
expect(result.name).toBe('Updated');
expect(mockFetch).toHaveBeenCalledWith(
'/api/auth/update-user',
expect.objectContaining({ method: 'POST' }),
);
});
});
Loading