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
126 changes: 126 additions & 0 deletions e2e/sessions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { expect, test, type Browser, type Page } from '@playwright/test'

import { resetUserData } from './helpers/db'
import { navigateToSettings } from './helpers/navigation'
import { login, registerUser, uniqueUsername } from './helpers/users'

/**
* Session management E2E — real RPCs and realtime broadcasts against the
* production preview build.
*
* Tests exercise the full cross-device revocation chain: RPC deletion →
* Supabase Realtime broadcast → useSessionUpdateListener → isSessionValid →
* logoutUser → redirect + toast. Two browser contexts simulate two devices
* logged in as the same user.
*
* `beforeEach` truncates `auth.users` + `private.rate_limits` so pre-auth
* RPCs don't trip the shared-IP rate limiter. Per-test browser contexts
* isolate Supabase sessions.
*/

const PASSWORD = 'TestPass123!'

/** Auto-retry budget for cross-session assertions: realtime delivery + refetch. */
const CROSS_SESSION_TIMEOUT = 20_000

/** Safety margin so B's realtime channel is SUBSCRIBED before A mutates. */
const SUBSCRIBE_SETTLE_MS = 1500

/**
* Opens a second browser context (= a second device) and logs in as the same
* user via the real /login flow. Returns B's page and a `close` that tears
* the context down. Each test creates and closes its own second context.
*/
async function openSecondSession(
browser: Browser,
username: string,
password: string,
): Promise<{ pageB: Page; close: () => Promise<void> }> {
const contextB = await browser.newContext()
const pageB = await contextB.newPage()
await login(pageB, username, password)
return { pageB, close: () => contextB.close() }
}

test.describe('sessions', () => {
test.beforeEach(async () => {
await resetUserData()
})

test('session list shows the current session in Settings', async ({ page }, testInfo) => {
const username = uniqueUsername('sesslist')
await registerUser(page, username, PASSWORD)

// Navigate to Settings — SessionSection is lazy-loaded, so wait for it.
await navigateToSettings(page, testInfo)
await page.waitForURL('**/settings')

// The session card title should be visible.
await expect(page.getByText('Sessions', { exact: true })).toBeVisible()

// The current session row should show "Current device" — this means the
// session list RPC returned data and getCurrentSessionId matched a row.
await expect(page.getByText('Current device')).toHaveCount(2)

// The browser/OS should be parsed from the User-Agent — not "Unknown".
// Playwright's Chromium UA contains "Chrome" and either "Windows", "macOS",
// or "Linux". The session row renders "{browser} · {ip} · Last active: {time}".
// Assert that at least one session row contains a real browser name.
const sessionBrowser = page.getByTestId('session-browser')
await expect(sessionBrowser.first()).toContainText(/Chrome|HeadlessChrome|Edge|Firefox/)
})

test('revoking another session force-logs out the other device', async ({ page: pageA, browser }, testInfo) => {
const username = uniqueUsername('sessrevoke')
await registerUser(pageA, username, PASSWORD)

// Open a second browser context and log in as the same user.
const sessionB = await openSecondSession(browser, username, PASSWORD)
try {
const { pageB } = sessionB

// Let B's realtime channel finish subscribing before A acts.
await pageB.waitForTimeout(SUBSCRIBE_SETTLE_MS)

// A navigates to Settings and sees 2 sessions.
await navigateToSettings(pageA, testInfo)
await pageA.waitForURL('**/settings')
await expect(pageA.getByText('Sessions', { exact: true })).toBeVisible()

// Wait for sessions to load — at least one revoke button should appear.
// (The current session row doesn't have one, so this asserts ≥2 sessions.)
await expect(pageA.getByTestId('session-revoke-all')).toBeVisible()

// A clicks "Revoke" on the first other session.
// Find a revoke button that is NOT the current session's label.
// SessionRow renders <Button data-testid="session-revoke-{id}"> for
// other sessions and a <span> for the current one. The first other
// session's revoke button is the first data-testid matching the pattern.
const otherSessionRevokeButton = pageA.locator('button[data-testid^="session-revoke-"]').first()
await otherSessionRevokeButton.click()

// Confirmation dialog appears.
const dialog = pageA.getByRole('alertdialog')
await expect(dialog).toBeVisible()
await expect(dialog.getByText('Revoke this session?')).toBeVisible()
// Click the confirm button inside the dialog — scoped to avoid matching
// the row-level revoke button.
await dialog.getByRole('button', { name: 'Revoke', exact: true }).click()

// A sees the success toast.
await expect(pageA.getByText('Session revoked', { exact: true })).toBeVisible()

// A's session list now shows only 1 session — "Revoke all others" button
// should no longer be present (no other sessions to revoke).
await expect(pageA.getByTestId('session-revoke-all')).not.toBeVisible()

// B receives the realtime broadcast → isSessionValid() returns false →
// force-logout with toast + redirect to /login.
await expect(pageB).toHaveURL(/\/login$/, { timeout: CROSS_SESSION_TIMEOUT })
// Sonner may truncate long toast messages; assert a unique leading substring.
await expect(pageB.getByText(/session was revoked from another device/)).toBeVisible()
} finally {
await sessionB.close()
}
})
})
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "cipher-note",
"private": true,
"version": "1.0.0",
"version": "1.1.0",
"packageManager": "pnpm@9.6.0",
"type": "module",
"scripts": {
Expand Down
6 changes: 4 additions & 2 deletions src/app/Providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@ function InnerApp() {

useEffect(() => {
restoreSession()
const unsubscribe = subscribeToAuthChanges()
const unsubscribe = subscribeToAuthChanges(() => {
router.navigate({ to: '/login' })
})
return () => {
unsubscribe()
}
}, [])
}, [router])

if (auth.isRestoringSession) {
return <PageSkeleton />
Expand Down
15 changes: 15 additions & 0 deletions src/app/layouts/ProtectedLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ vi.mock('@/shared/realtime/supabase-realtime', () => ({
realtimeAdapter: { subscribe: vi.fn(() => Promise.resolve()), unsubscribe: vi.fn() },
}))

// Session update broadcast is a network side-effect; stub the channel so
// rendering the layout in tests never opens a real channel.
vi.mock('@/shared/realtime/session-update', () => ({
sessionUpdateChannel: { subscribe: vi.fn(), unsubscribe: vi.fn(), broadcastUpdate: vi.fn() },
}))

// The session revocation listener checks session validity on mount; stub it
// so tests don't make a real RPC call.
vi.mock('@/shared/api/supabase-session', () => ({
getActiveSessions: vi.fn(() => Promise.resolve([])),
revokeSession: vi.fn(() => Promise.resolve(true)),
revokeOtherSessions: vi.fn(() => Promise.resolve(0)),
isSessionValid: vi.fn(() => Promise.resolve(true)),
}))

import { useNavigationBlocker } from '@/features/fields/model/use-navigation-blocker'
import { useBlocker } from '@tanstack/react-router'
import { ProtectedLayout } from './ProtectedLayout'
Expand Down
8 changes: 5 additions & 3 deletions src/app/layouts/ProtectedLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { useResizable } from '@/shared/lib/use-resizable'
import { useVaultTimeout } from '@/features/vault/model/use-vault-timeout'
import { useVaultVisibilityLock } from '@/features/vault/model/use-vault-visibility-lock'
import { logoutUser } from '@/features/auth/model/auth-service'
import { useSessionUpdateListener } from '@/features/auth/model/use-session-update-listener'
import { useRealtimeSync } from '@/features/fields/model/use-realtime-sync'
import { useNavigationBlocker } from '@/features/fields/model/use-navigation-blocker'
import { useAuth } from '@/shared/auth/auth-context'
Expand Down Expand Up @@ -54,6 +55,7 @@ function AuthenticatedLayout() {
useVaultTimeout()
useVaultVisibilityLock()
useRealtimeSync()
useSessionUpdateListener()
useBlocker({
shouldBlockFn: () => !navigator.onLine,
enableBeforeUnload: false,
Expand All @@ -62,7 +64,7 @@ function AuthenticatedLayout() {
useNavigationBlocker()

return (
<div className="text-foreground bg-background flex h-dvh">
<div className="text-foreground bg-background flex h-dvh overflow-hidden">
{/* Desktop sidebar */}
<aside
className="bg-sidebar text-sidebar-foreground border-sidebar-border hidden shrink-0 flex-col border-r md:flex"
Expand Down Expand Up @@ -97,9 +99,9 @@ function AuthenticatedLayout() {
<OfflineBanner />

{/* Main content area with resize handle */}
<div className="relative flex flex-1 flex-col">
<div className="relative flex flex-1 flex-col overflow-y-auto">
<ResizeHandle isDragging={isDragging} handleProps={handleProps} />
<main className="mb-10 flex flex-1 flex-col overflow-y-auto p-6 pb-20 md:pb-6">
<main className="mb-10 flex flex-1 flex-col p-6 pb-20 md:pb-6">
<Outlet />
</main>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/app/layouts/PublicLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { PublicHeader } from '@/shared/ui/nav/PublicHeader'

function PublicLayout() {
return (
<div className="bg-background text-foreground relative flex min-h-dvh flex-col overflow-hidden">
<div className="bg-background text-foreground relative flex min-h-dvh [scrollbar-gutter:stable] flex-col overflow-hidden">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,var(--color-primary)_0,transparent_70%)] opacity-[0.08]" />
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_bottom,var(--color-primary)_0,transparent_50%)] opacity-[0.03]" />
<PublicHeader />
Expand Down
55 changes: 41 additions & 14 deletions src/app/router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@ vi.mock('@/shared/realtime/supabase-realtime', () => ({
realtimeAdapter: { subscribe: vi.fn(() => Promise.resolve()), unsubscribe: vi.fn() },
}))

// The authenticated layout subscribes to session update broadcast; stub it
// so rendering the route tree in tests never opens a real channel.
vi.mock('@/shared/realtime/session-update', () => ({
sessionUpdateChannel: { subscribe: vi.fn(), unsubscribe: vi.fn(), broadcastUpdate: vi.fn() },
}))

// The session revocation listener checks session validity on mount; stub it
// so the router tests don't make a real RPC call.
vi.mock('@/shared/api/supabase-session', () => ({
getActiveSessions: vi.fn(() => Promise.resolve([])),
revokeSession: vi.fn(() => Promise.resolve(true)),
revokeOtherSessions: vi.fn(() => Promise.resolve(0)),
isSessionValid: vi.fn(() => Promise.resolve(true)),
}))

// The entry detail route uses useEntryStatus which depends on useEntries;
// stub it so the dashboard route renders field cards in tests.
vi.mock('@/features/fields/model/use-entry-status', () => ({
Expand Down Expand Up @@ -97,33 +112,45 @@ describe('Router page rendering', () => {

it('renders register page at /register', async () => {
renderWithRouter({}, '/register')
await waitFor(() => {
expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument()
})
await waitFor(
() => {
expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument()
},
{ timeout: 5000 },
)
})

it('renders recover page at /recover', async () => {
renderWithRouter({}, '/recover')
await waitFor(() => {
expect(screen.getByRole('button', { name: /recover/i })).toBeInTheDocument()
})
await waitFor(
() => {
expect(screen.getByRole('button', { name: /recover/i })).toBeInTheDocument()
},
{ timeout: 5000 },
)
})

it('renders dashboard with field cards when authenticated', async () => {
useAuthStore.setState({ user: { id: '1', username: 'test', createdAt: '2024-01-01T00:00:00Z' } })
renderWithRouter({ isAuthenticated: true, user: { id: '1', username: 'test' } }, '/dashboard/test-entry')
await waitFor(() => {
expect(screen.getByText('Note')).toBeInTheDocument()
expect(screen.getByText('Website')).toBeInTheDocument()
expect(screen.getByText('Email')).toBeInTheDocument()
})
await waitFor(
() => {
expect(screen.getByText('Note')).toBeInTheDocument()
expect(screen.getByText('Website')).toBeInTheDocument()
expect(screen.getByText('Email')).toBeInTheDocument()
},
{ timeout: 5000 },
)
})

it('renders settings page when authenticated', async () => {
useAuthStore.setState({ user: { id: '1', username: 'test', createdAt: '2024-01-01T00:00:00Z' } })
renderWithRouter({ isAuthenticated: true, user: { id: '1', username: 'test' } }, '/settings')
await waitFor(() => {
expect(screen.getAllByText(/account/i).length).toBeGreaterThan(0)
})
await waitFor(
() => {
expect(screen.getAllByText(/account/i).length).toBeGreaterThan(0)
},
{ timeout: 5000 },
)
})
})
1 change: 0 additions & 1 deletion src/app/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,6 @@
}
html {
@apply font-sans;
scrollbar-gutter: stable;
}
button,
a,
Expand Down
Loading
Loading