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
134 changes: 134 additions & 0 deletions web/__tests__/auth-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { signIn, signUp, refreshSession, signOut } from '../src/lib/auth/client'

// ---------------------------------------------------------------------------
// fetch mock
// ---------------------------------------------------------------------------
function jsonResponse(body: unknown, ok = true, status = ok ? 200 : 400) {
return {
ok,
status,
json: async () => body,
} as Response
}

beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})

afterEach(() => {
vi.unstubAllGlobals()
})

// signIn/signUp/refreshSession/signOut call ChatIslam's own same-origin
// /api/auth/* proxy routes — the routes hold the real tokens as httpOnly
// cookies server-side and only ever return { user, accessTokenExpiresAt }
// to the client (no-localstorage-token fix, ported from praycalc/web).

describe('signIn', () => {
it('resolves with user + accessTokenExpiresAt (no raw tokens)', async () => {
;(fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
jsonResponse({
user: { id: 'u1', email: 'a@b.com', displayName: 'A B' },
accessTokenExpiresAt: Date.now() + 900_000,
}),
)
const result = await signIn('a@b.com', 'secret')
expect(result.user.email).toBe('a@b.com')
expect(result.user.displayName).toBe('A B')
expect(result.accessTokenExpiresAt).toBeGreaterThan(Date.now())
expect((result as unknown as { tokens?: unknown }).tokens).toBeUndefined()
})

it('posts to the same-origin proxy route with credentials', async () => {
const mockFetch = fetch as unknown as ReturnType<typeof vi.fn>
mockFetch.mockResolvedValue(
jsonResponse({ user: { email: 'e@e.com', displayName: 'e' }, accessTokenExpiresAt: Date.now() }),
)
await signIn('e@e.com', 'pw')
expect(mockFetch).toHaveBeenCalledWith(
'/api/auth/signin',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({ email: 'e@e.com', password: 'pw' }),
}),
)
})

it('throws a user-presentable message on failure', async () => {
;(fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
jsonResponse({ error: 'Invalid email or password.' }, false, 401),
)
await expect(signIn('a@b.com', 'wrong')).rejects.toThrow('Invalid email or password.')
})

it('throws a fallback message when the error body is unparseable', async () => {
;(fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 500,
json: async () => {
throw new Error('not json')
},
} as unknown as Response)
await expect(signIn('a@b.com', 'wrong')).rejects.toThrow('Request failed.')
})
})

describe('signUp', () => {
it('posts to the signup proxy route with displayName', async () => {
const mockFetch = fetch as unknown as ReturnType<typeof vi.fn>
mockFetch.mockResolvedValue(
jsonResponse({ user: { email: 'n@n.com', displayName: 'New' }, accessTokenExpiresAt: Date.now() }),
)
const result = await signUp('n@n.com', 'pw123456', 'New')
expect(mockFetch).toHaveBeenCalledWith(
'/api/auth/signup',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ email: 'n@n.com', password: 'pw123456', displayName: 'New' }),
}),
)
expect(result.user.displayName).toBe('New')
})

it('throws a user-presentable message on failure', async () => {
;(fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
jsonResponse({ error: 'Email already registered.' }, false, 409),
)
await expect(signUp('dupe@e.com', 'pw')).rejects.toThrow('Email already registered.')
})
})

describe('refreshSession', () => {
it('resolves with a new accessTokenExpiresAt on success (cookie-based, no args)', async () => {
;(fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
jsonResponse({ user: { email: 'r@r.com' }, accessTokenExpiresAt: Date.now() + 900_000 }),
)
const result = await refreshSession()
expect(result.accessTokenExpiresAt).toBeGreaterThan(Date.now())
expect(fetch).toHaveBeenCalledWith(
'/api/auth/refresh',
expect.objectContaining({ body: JSON.stringify({}) }),
)
})

it('throws on failure', async () => {
;(fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
jsonResponse({ error: 'Session refresh failed.' }, false, 401),
)
await expect(refreshSession()).rejects.toThrow('Session refresh failed.')
})
})

describe('signOut', () => {
it('resolves even when the network call fails (best-effort)', async () => {
;(fetch as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('network down'))
await expect(signOut()).resolves.toBeUndefined()
})

it('resolves on success', async () => {
;(fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(jsonResponse({ ok: true }))
await expect(signOut()).resolves.toBeUndefined()
})
})
130 changes: 130 additions & 0 deletions web/__tests__/session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
buildSession,
computeInitials,
getSession,
saveSession,
clearSession,
hasValidToken,
type ChatIslamSession,
} from '../src/lib/session'

// ---------------------------------------------------------------------------
// localStorage mock (matches praycalc/web's session.test.ts house style).
// chatislam/web's vitest.config.ts uses environment: 'node' (not jsdom), so
// `window` is not declared at all here — session.ts guards every function
// with `typeof window === 'undefined'` to stay SSR-safe, which means the
// guard short-circuits in a bare Node environment too. Stub a minimal
// `window` global (any truthy value satisfies `typeof window !== 'undefined'`)
// so the module under test exercises its localStorage branch.
// ---------------------------------------------------------------------------
let _store: Record<string, string> = {}

Object.defineProperty(globalThis, 'window', {
value: globalThis,
writable: true,
configurable: true,
})

Object.defineProperty(globalThis, 'localStorage', {
value: {
getItem: (key: string) => _store[key] ?? null,
setItem: (key: string, value: string) => {
_store[key] = value
},
removeItem: (key: string) => {
delete _store[key]
},
clear: () => {
_store = {}
},
},
writable: true,
configurable: true,
})

const SESSION_KEY = 'chatislam-profile'

beforeEach(() => {
_store = {}
})

describe('computeInitials', () => {
it('derives initials from first+last name', () => {
expect(computeInitials('John Doe')).toBe('JD')
})

it('derives initials from a single name (first two letters)', () => {
expect(computeInitials('Madonna')).toBe('MA')
})
})

describe('buildSession', () => {
it('builds a session without a token expiry', () => {
const s = buildSession('john.doe@example.com')
expect(s.email).toBe('john.doe@example.com')
expect(s.displayName).toBe('john doe')
expect(s.accessTokenExpiresAt).toBeUndefined()
})

it('accepts an explicit display name', () => {
const s = buildSession('a@b.com', 'A B')
expect(s.displayName).toBe('A B')
expect(s.initials).toBe('AB')
})
})

describe('getSession / saveSession / clearSession', () => {
it('returns null when nothing is stored', () => {
expect(getSession()).toBeNull()
})

it('round-trips a profile session (with expiry) through localStorage', () => {
const s: ChatIslamSession = {
email: 'a@b.com',
displayName: 'A B',
initials: 'AB',
accessTokenExpiresAt: Date.now() + 900_000,
}
saveSession(s)
expect(getSession()).toEqual(s)
})

it('clearSession removes the stored session', () => {
saveSession(buildSession('x@y.com'))
clearSession()
expect(getSession()).toBeNull()
})

it('getSession returns null on malformed JSON', () => {
_store[SESSION_KEY] = 'not-json{{'
expect(getSession()).toBeNull()
})
})

describe('hasValidToken', () => {
it('returns false for null session', () => {
expect(hasValidToken(null)).toBe(false)
})

it('returns false for a session with no expiry field', () => {
const s = buildSession('a@b.com')
expect(hasValidToken(s)).toBe(false)
})

it('returns false when accessTokenExpiresAt is in the past', () => {
const s: ChatIslamSession = {
...buildSession('a@b.com'),
accessTokenExpiresAt: Date.now() - 1000,
}
expect(hasValidToken(s)).toBe(false)
})

it('returns true when accessTokenExpiresAt is in the future', () => {
const s: ChatIslamSession = {
...buildSession('a@b.com'),
accessTokenExpiresAt: Date.now() + 900_000,
}
expect(hasValidToken(s)).toBe(true)
})
})
72 changes: 21 additions & 51 deletions web/components/chat/ChatSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,15 @@
* - "New chat" button
* - Active session highlighted
* - Sign in prompt for unauthenticated users
*
* Auth: no longer reads a raw bearer token from localStorage. GET
* /api/chat/sessions authenticates via the httpOnly ci_access_token cookie
* (sent automatically with credentials: 'same-origin'); sign-in gating uses
* getSession() from @/lib/session (no-localstorage-token fix).
*/

import { useCallback, useEffect, useState } from 'react'
import { getSession } from '@/lib/session'

interface ConversationSummary {
id: string
Expand All @@ -31,66 +37,30 @@ export function ChatSidebar({ isOpen = true, onClose }: ChatSidebarProps) {

const [sessions, setSessions] = useState<ConversationSummary[]>([])
const [isLoading, setIsLoading] = useState(false)
// Lazy initializer reads localStorage on first client render — avoids setState-in-effect.
const [authToken] = useState<string | null>(() =>
typeof window !== 'undefined' ? localStorage.getItem('chatislam_token') : null
)
// Lazy initializer reads the cached profile on first render (client-only).
// Presence of a profile means "was signed in last we checked" — the actual
// credential is the httpOnly cookie, sent automatically by fetch().
const [isSignedIn] = useState<boolean>(() => getSession() !== null)

const fetchSessions = useCallback(async (token: string) => {
const fetchSessions = useCallback(async () => {
setIsLoading(true)
try {
const HASURA_URL = import.meta.env.PUBLIC_HASURA_URL
if (!HASURA_URL) return

const res = await fetch(HASURA_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
query: `
query {
ci_sessions(
order_by: { last_message_at: desc_nulls_last }
limit: 50
) {
id title last_message_at audience_mode
messages_aggregate { aggregate { count } }
}
}`,
}),
const res = await fetch('/api/chat/sessions', {
credentials: 'same-origin',
})
if (!res.ok) return

const data = await res.json() as {
data?: {
ci_sessions: Array<{
id: string
title: string | null
last_message_at: string | null
audience_mode: string | null
messages_aggregate: { aggregate: { count: number } }
}>
}
}

const raw = data.data?.ci_sessions ?? []
setSessions(raw.map((s) => ({
id: s.id,
title: s.title,
last_message_at: s.last_message_at,
message_count: s.messages_aggregate.aggregate.count,
audience_mode: s.audience_mode,
})))
const data = await res.json() as { sessions: ConversationSummary[] }
setSessions(data.sessions ?? [])
} catch { /* silently ignore */ } finally {
setIsLoading(false)
}
}, [])

useEffect(() => {
// fetchSessions is async — setState runs in callbacks, not synchronously in the effect body.
if (authToken) void fetchSessions(authToken)
}, [authToken, fetchSessions])
if (isSignedIn) void fetchSessions()
}, [isSignedIn, fetchSessions])

function formatDate(iso: string | null): string {
if (!iso) return ''
Expand Down Expand Up @@ -142,7 +112,7 @@ export function ChatSidebar({ isOpen = true, onClose }: ChatSidebarProps) {

{/* Session list */}
<div className="flex-1 overflow-y-auto px-2 pb-4">
{!authToken && (
{!isSignedIn && (
<div className="px-2 py-4 text-center">
<p className="mb-2 text-xs text-gray-500 dark:text-gray-400">
Sign in to save conversations
Expand All @@ -156,15 +126,15 @@ export function ChatSidebar({ isOpen = true, onClose }: ChatSidebarProps) {
</div>
)}

{authToken && isLoading && (
{isSignedIn && isLoading && (
<div className="space-y-2 px-1 pt-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-10 motion-safe:animate-pulse rounded-lg bg-gray-100 dark:bg-gray-800" />
))}
</div>
)}

{authToken && !isLoading && sessions.length === 0 && (
{isSignedIn && !isLoading && sessions.length === 0 && (
<p className="px-2 py-4 text-center text-xs text-gray-400 dark:text-gray-600">
No conversations yet
</p>
Expand Down
Loading
Loading