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
81 changes: 81 additions & 0 deletions src/app/[locale]/account/admin/actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'

const {
getSessionCustomer,
findAdminCustomerByEmail,
startImpersonation,
redirect,
consume,
} =
vi.hoisted(() => ({
getSessionCustomer: vi.fn(),
findAdminCustomerByEmail: vi.fn(),
startImpersonation: vi.fn(),
redirect: vi.fn(() => {
throw new Error('REDIRECT')
}),
consume: vi.fn(async () => ({ success: true, remaining: 9 })),
}))

vi.mock('@/lib/medusa-auth', () => ({ getSessionCustomer }))
vi.mock('@/lib/admin', () => ({
isAdmin: (e?: string | null) => e?.trim().toLowerCase() === 'paul@kilbot.com',
}))
vi.mock('@/lib/discord/medusa-admin', () => ({ findAdminCustomerByEmail }))
vi.mock('@/lib/impersonation', () => ({ startImpersonation }))
vi.mock('@/lib/rate-limit', () => ({
createRateLimiter: () => ({ consume }),
clientIp: () => 'ip',
}))
vi.mock('@/lib/logger', () => ({ authLogger: { info: vi.fn(), warn: vi.fn() } }))
vi.mock('@/i18n/navigation', () => ({ redirect }))
vi.mock('next/headers', () => ({ headers: async () => ({ get: () => 'ip' }) }))

import { startImpersonationAction } from './actions'

beforeEach(() => {
getSessionCustomer.mockReset()
findAdminCustomerByEmail.mockReset()
startImpersonation.mockReset()
consume.mockReset()
consume.mockResolvedValue({ success: true, remaining: 9 })
})

describe('startImpersonationAction', () => {
it('rejects a non-admin session', async () => {
getSessionCustomer.mockResolvedValue({ email: 'nope@x.com' })
const result = await startImpersonationAction({ email: 't@x.com', locale: 'en' })
expect(result).toEqual({ error: 'forbidden' })
expect(startImpersonation).not.toHaveBeenCalled()
})

it('rate limits trusted admin identity before looking up the target', async () => {
getSessionCustomer.mockResolvedValue({ email: 'Paul@Kilbot.com ' })
consume.mockResolvedValueOnce({ success: false, remaining: 0 })

const result = await startImpersonationAction({ email: 't@x.com', locale: 'en' })

expect(result).toEqual({ error: 'rate_limited' })
expect(consume).toHaveBeenCalledWith('paul@kilbot.com')
expect(findAdminCustomerByEmail).not.toHaveBeenCalled()
expect(startImpersonation).not.toHaveBeenCalled()
})

it('reports when the target email is not found', async () => {
getSessionCustomer.mockResolvedValue({ email: 'paul@kilbot.com' })
findAdminCustomerByEmail.mockResolvedValue(null)
const result = await startImpersonationAction({ email: 'ghost@x.com', locale: 'en' })
expect(result).toEqual({ error: 'not_found' })
expect(startImpersonation).not.toHaveBeenCalled()
})

it('starts impersonation + redirects when admin targets a real customer', async () => {
getSessionCustomer.mockResolvedValue({ email: 'paul@kilbot.com' })
findAdminCustomerByEmail.mockResolvedValue({ id: 'cus_t', email: 't@x.com' })
await expect(
startImpersonationAction({ email: 't@x.com', locale: 'fr' })
).rejects.toThrow('REDIRECT')
expect(startImpersonation).toHaveBeenCalledWith('cus_t')
expect(redirect).toHaveBeenCalledWith({ href: '/account', locale: 'fr' })
})
})
63 changes: 63 additions & 0 deletions src/app/[locale]/account/admin/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
'use server'

import { createHash } from 'node:crypto'
import { redirect } from '@/i18n/navigation'
import { getSessionCustomer } from '@/lib/medusa-auth'
import { isAdmin } from '@/lib/admin'
import { findAdminCustomerByEmail } from '@/lib/discord/medusa-admin'
import { startImpersonation } from '@/lib/impersonation'
import { createRateLimiter } from '@/lib/rate-limit'
import { authLogger } from '@/lib/logger'

const limiter = createRateLimiter({
prefix: 'impersonate-lookup',
limit: 10,
window: '10 m',
})

function redirectToAccount(locale: string): never {
redirect({ href: '/account', locale })
throw new Error('Redirect failed')
}

function auditHash(value: string): string {
return createHash('sha256').update(value).digest('hex').slice(0, 12)
}

export type StartImpersonationResult =
| { error: 'forbidden' }
| { error: 'not_found' }
| { error: 'rate_limited' }

/**
* Owner-only. Verifies the REAL session is an admin, rate-limits by IP (email
* enumeration guard), looks up the target by email, then starts impersonation
* and redirects to /account. Returns an error shape on failure (redirect throws
* on success, so the happy path never returns).
*/
export async function startImpersonationAction(input: {
email: string
locale: string
}): Promise<StartImpersonationResult> {
const session = await getSessionCustomer()
if (!session?.email || !isAdmin(session.email)) {
authLogger.warn`Non-admin impersonation attempt by ${session?.email ?? 'anon'}`
return { error: 'forbidden' }
}
const adminEmail = session.email

const rateLimitKey = adminEmail.trim().toLowerCase()
const { success } = await limiter.consume(rateLimitKey)
if (!success) return { error: 'rate_limited' }
Comment thread
wcpos-bot[bot] marked this conversation as resolved.

const email = input.email.trim().toLowerCase()
const target = await findAdminCustomerByEmail(email)
if (!target) {
authLogger.info`Impersonation lookup miss: admin=${adminEmail} target_hash=${auditHash(email)}`
return { error: 'not_found' }
}

authLogger.info`Impersonation START: admin=${adminEmail} target_id=${target.id}`
await startImpersonation(target.id)
redirectToAccount(input.locale)
}
50 changes: 50 additions & 0 deletions src/app/[locale]/account/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { notFound } from 'next/navigation'
import { getSessionCustomer } from '@/lib/medusa-auth'
import { isAdmin } from '@/lib/admin'
import { startImpersonationAction } from './actions'

export const metadata = { robots: { index: false, follow: false } }

export default async function AdminInspectPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
// Gate on the REAL session — never getCustomer (which could be a target).
const session = await getSessionCustomer()
if (!isAdmin(session?.email)) notFound()

async function submit(formData: FormData) {
'use server'
const email = String(formData.get('email') ?? '')
await startImpersonationAction({ email, locale })
// On success the action redirects; a returned error re-renders this page.
// (For inline error messaging, upgrade to useActionState in a follow-up.)
}

return (
<div className="mx-auto max-w-md space-y-4">
<h1 className="text-xl font-semibold">Inspect a customer (read-only)</h1>
<p className="text-sm text-muted-foreground">
Enter a customer email to view their account exactly as they see it. You
cannot change their data while inspecting.
</p>
<form action={submit} className="flex gap-2">
<input
name="email"
type="email"
required
placeholder="customer@example.com"
className="flex-1 rounded-md border px-3 py-2"
/>
<button
type="submit"
className="rounded-md bg-primary px-4 py-2 text-primary-foreground"
>
View as
</button>
</form>
</div>
)
}
14 changes: 14 additions & 0 deletions src/app/[locale]/account/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { setRequestLocale } from 'next-intl/server'
import { redirect } from 'next/navigation'
import { Suspense } from 'react'
import { getCustomer } from '@/lib/medusa-auth'
import { getImpersonation } from '@/lib/impersonation'
import { redirectToLoginClearingSession } from '@/lib/login-redirect'
import { AccountSidebar } from '@/components/account/account-sidebar'
import { ImpersonationBanner } from '@/components/account/impersonation-banner'
import { SiteHeader } from '@/components/main/site-header'
import { SiteFooter } from '@/components/main/site-footer'
import { Skeleton } from '@/components/ui/skeleton'
Expand All @@ -19,6 +22,14 @@ export const metadata: Metadata = {
async function AccountGate({ locale }: { locale: string }) {
const customer = await getCustomer()
if (!customer) {
// A null customer while impersonating means the target no longer resolves
// (stale/deleted). Don't nuke the admin's own session — bounce to the exit
// route (a route handler CAN delete the cookie) so the owner lands back on
// their own /account. Only a genuinely signed-out visitor gets the
// session-clearing login redirect.
if (await getImpersonation()) {
redirect(`/api/account/impersonate/exit?locale=${locale}`)
}
return redirectToLoginClearingSession(locale)
}
return null
Expand Down Expand Up @@ -75,6 +86,9 @@ export default async function AccountLayout({
<Suspense fallback={<HeaderSkeleton />}>
<SiteHeader />
</Suspense>
<Suspense fallback={null}>
<ImpersonationBanner />
</Suspense>
<Suspense fallback={null}>
<AccountGate locale={locale} />
</Suspense>
Expand Down
42 changes: 42 additions & 0 deletions src/app/api/account/impersonate/exit/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { NextResponse } from 'next/server'
import { stopImpersonation } from '@/lib/impersonation'
import { getSessionCustomer } from '@/lib/medusa-auth'
import { authLogger } from '@/lib/logger'
import { defaultLocale, locales } from '@/i18n/config'
import { getPathname } from '@/i18n/navigation'

function accountUrl(request: Request): URL {
const requestUrl = new URL(request.url)
const explicitLocale = requestUrl.searchParams.get('locale')
const referer = request.headers.get('referer')
let refererPathname = ''
try {
refererPathname = referer ? new URL(referer).pathname : ''
} catch {
refererPathname = ''
}
const refererLocale = refererPathname.split('/')[1]
const locale = locales.find(
(candidate) => candidate === explicitLocale || candidate === refererLocale
)
return new URL(getPathname({ href: '/account', locale: locale ?? defaultLocale }), request.url)
}

async function exit(request: Request) {
const session = await getSessionCustomer()
await stopImpersonation()
authLogger.info`Impersonation STOP: admin_id=${session?.id ?? 'unknown'}`
return NextResponse.redirect(accountUrl(request), { status: 303 })
}

export async function POST(request: Request) {
return exit(request)
}

// GET variant so a server-component render (which cannot delete cookies) can
// bounce a stale/deleted impersonation target here to clear the cookie and
// land the owner back on their own account. See AccountGate in
// src/app/[locale]/account/layout.tsx.
export async function GET(request: Request) {
return exit(request)
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ const mockGetLicense = vi.fn()
const mockUpdateLicenseMetadata = vi.fn()
const mockSyncDiscordProRoleForMember = vi.fn()
const mockCreateDiscordRoleSyncDependencies = vi.fn()
const mockAssertViewOnly = vi.fn(async () => {})

vi.mock('@/lib/impersonation', () => ({
assertViewOnly: () => mockAssertViewOnly(),
ViewOnlyError: class ViewOnlyError extends Error {},
}))

vi.mock('@/lib/customer-licenses', () => ({
getResolvedCustomerLicenses: (...args: unknown[]) => mockGetResolvedCustomerLicenses(...args),
Expand Down Expand Up @@ -51,7 +57,21 @@ function callDelete(licenseId = 'lic_1', memberId = 'member_1') {
}

describe('DELETE /api/account/licenses/[licenseId]/discord/members/[memberId]', () => {
beforeEach(() => vi.clearAllMocks())
beforeEach(() => {
vi.clearAllMocks()
mockAssertViewOnly.mockResolvedValue(undefined)
})

it('returns 403 read_only_inspection while impersonating', async () => {
const { ViewOnlyError } = await import('@/lib/impersonation')
mockAssertViewOnly.mockRejectedValueOnce(new ViewOnlyError())

const response = await callDelete()

expect(response.status).toBe(403)
expect(await response.json()).toEqual({ error: 'read_only_inspection' })
expect(mockGetResolvedCustomerLicenses).not.toHaveBeenCalled()
})

it('returns 401 when the holder is not authenticated', async () => {
mockGetResolvedCustomerLicenses.mockResolvedValueOnce({ authenticated: false, licenses: [] })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { syncDiscordProRoleForMember } from '@/lib/discord/sync'
import type { LicenseLifecycle } from '@/lib/license'
import { licenseClient } from '@/services/core/external/license-client'
import { infraLogger } from '@/lib/logger'
import { assertViewOnly, ViewOnlyError } from '@/lib/impersonation'
Comment thread
wcpos-bot[bot] marked this conversation as resolved.

const UNVERIFIABLE_DISCORD_ENTITLEMENT: LicenseLifecycle[] = [
{ status: 'unknown', expiry: null },
Expand All @@ -19,6 +20,18 @@ export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ licenseId: string; memberId: string }> }
) {
try {
await assertViewOnly()
} catch (error) {
if (error instanceof ViewOnlyError) {
return NextResponse.json(
{ error: 'read_only_inspection' },
{ status: 403 }
)
}
throw error
}

const { licenseId, memberId } = await params
const { authenticated, licenses } = await getResolvedCustomerLicenses()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ const mockExtractLicenseIdsFromOrders = vi.fn()
const mockGetLicenseMachines = vi.fn()
const mockDeactivateMachine = vi.fn()

vi.mock('@/lib/impersonation', () => ({
assertViewOnly: async () => {},
ViewOnlyError: class ViewOnlyError extends Error {},
}))

vi.mock('@/lib/medusa-auth', () => ({
getCustomer: (...args: unknown[]) => mockGetCustomer(...args),
}))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getCustomer } from '@/lib/medusa-auth'
import { extractLicenseIdsFromOrders } from '@/lib/licenses'
import { licenseClient } from '@/services/core/external/license-client'
import { licenseLogger } from '@/lib/logger'
import { assertViewOnly, ViewOnlyError } from '@/lib/impersonation'

/**
* Machine Deactivation API
Expand All @@ -21,6 +22,18 @@ export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ licenseId: string; machineId: string }> }
) {
try {
await assertViewOnly()
} catch (error) {
if (error instanceof ViewOnlyError) {
return NextResponse.json(
{ error: 'read_only_inspection' },
{ status: 403 }
)
}
throw error
}

try {
const { licenseId, machineId } = await params

Expand Down
Loading
Loading