diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md index 9e6769e..2375f31 100644 --- a/documentation/TABLEOFCONTENTS.md +++ b/documentation/TABLEOFCONTENTS.md @@ -5,6 +5,7 @@ ## Authentication & Users - **Plex OAuth, JWT sessions, RBAC** → [backend/services/auth.md](backend/services/auth.md) - **Local admin authentication, password change** → [backend/services/auth.md](backend/services/auth.md) +- **Admin-generated login token per user (URL-login)** → [backend/services/auth.md](backend/services/auth.md) - **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md) - **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md) diff --git a/documentation/backend/services/auth.md b/documentation/backend/services/auth.md index 62d8878..93c0aeb 100644 --- a/documentation/backend/services/auth.md +++ b/documentation/backend/services/auth.md @@ -249,6 +249,14 @@ oidc.admin_claim_value = 'readmeabook-admin' - **Admin Settings:** OIDC section in `/admin/settings` (auth tab) - **Library:** `openid-client` (OIDC discovery, token exchange, PKCE) +## Admin-Generated Login Token + +- Login token stored as SHA-256 hash in `User.loginTokenHash` +- Admin generates/revokes via user permissions modal +- User navigates to `/auth/token/login?token=rmab_...` → page POSTs token to API in request body +- API: `POST /api/auth/token/login` with `{ token }` in JSON body +- Invalid token redirects to `/login` + ## Security - Never log tokens diff --git a/prisma/migrations/20260312000000_add_login_token_hash/migration.sql b/prisma/migrations/20260312000000_add_login_token_hash/migration.sql new file mode 100644 index 0000000..8934e47 --- /dev/null +++ b/prisma/migrations/20260312000000_add_login_token_hash/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable - Add login_token_hash column for admin-generated login tokens +ALTER TABLE "users" ADD COLUMN "login_token_hash" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b841ec6..a613d62 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -57,6 +57,9 @@ model User { interactiveSearchAccess Boolean? @map("interactive_search_access") // null = use global setting, true = allow, false = deny downloadAccess Boolean? @map("download_access") // null = use global setting, true = allow, false = deny + // Login token (admin-generated, for direct URL login) + loginTokenHash String? @map("login_token_hash") // SHA-256 hash of the login token (never store plaintext) + // Soft delete support deletedAt DateTime? @map("deleted_at") deletedBy String? @map("deleted_by") // Admin user ID who deleted this user diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 1a95f7f..48666a5 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -29,6 +29,7 @@ interface User { autoApproveRequests: boolean | null; interactiveSearchAccess: boolean | null; downloadAccess: boolean | null; + hasLoginToken: boolean; _count: { requests: number; }; @@ -220,6 +221,7 @@ function AdminUsersPageContent() { const [globalDownloadAccess, setGlobalDownloadAccess] = useState(true); const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false); const [permissionsUserId, setPermissionsUserId] = useState(null); + const [generatedToken, setGeneratedToken] = useState(null); const toast = useToast(); const isLoading = !data && !error; @@ -363,6 +365,24 @@ function AdminUsersPageContent() { } }; + const handleToggleToken = async (user: { id: string; plexUsername: string }, newValue: boolean) => { + try { + if (newValue) { + const result = await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'POST' }); + setGeneratedToken(result.fullToken); + toast.success(`Login token generated for ${user.plexUsername}`); + } else { + await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'DELETE' }); + setGeneratedToken(null); + toast.success(`Login token revoked for ${user.plexUsername}`); + } + mutate(); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Failed to update login token'; + toast.error(errorMsg); + } + }; + const showEditDialog = (user: User) => { setEditRole(user.role); setEditDialog({ isOpen: true, user }); @@ -968,11 +988,15 @@ function AdminUsersPageContent() { {/* User Permissions Modal */} setPermissionsUserId(null)} + onClose={() => { + setPermissionsUserId(null); + setGeneratedToken(null); + }} user={permissionsUser} globalAutoApprove={globalAutoApprove} globalInteractiveSearch={globalInteractiveSearch} globalDownloadAccess={globalDownloadAccess} + generatedToken={generatedToken} onToggleAutoApprove={(user, newValue) => { handleUserAutoApproveToggle(user as User, newValue); }} @@ -982,6 +1006,9 @@ function AdminUsersPageContent() { onToggleDownloadAccess={(user, newValue) => { handleUserDownloadAccessToggle(user as User, newValue); }} + onToggleToken={(user, newValue) => { + handleToggleToken(user, newValue); + }} /> diff --git a/src/app/api/admin/users/[id]/login-token/route.ts b/src/app/api/admin/users/[id]/login-token/route.ts new file mode 100644 index 0000000..05fb69d --- /dev/null +++ b/src/app/api/admin/users/[id]/login-token/route.ts @@ -0,0 +1,99 @@ +/** + * Component: Admin User Login Token + * Documentation: documentation/backend/services/auth.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { RMABLogger } from '@/lib/utils/logger'; +import { generateApiToken } from '@/lib/utils/api-token'; + +const logger = RMABLogger.create('API.Admin.Users.LoginToken'); + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + return requireAdmin(req, async () => { + try { + const { id } = await params; + + const targetUser = await prisma.user.findUnique({ + where: { id }, + select: { plexUsername: true, deletedAt: true }, + }); + + if (!targetUser) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + if (targetUser.deletedAt) { + return NextResponse.json( + { error: 'Cannot generate token for deleted user' }, + { status: 403 } + ); + } + + const { fullToken, tokenHash } = generateApiToken(); + + await prisma.user.update({ + where: { id }, + data: { loginTokenHash: tokenHash }, + }); + + logger.info('Admin generated login token for user', { + targetUser: targetUser.plexUsername, + createdBy: req.user!.username, + }); + + return NextResponse.json({ fullToken }, { status: 201 }); + } catch (error) { + logger.error('Failed to generate login token', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json({ error: 'Failed to generate login token' }, { status: 500 }); + } + }); + }); +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + return requireAdmin(req, async () => { + try { + const { id } = await params; + + const targetUser = await prisma.user.findUnique({ + where: { id }, + select: { plexUsername: true }, + }); + + if (!targetUser) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + await prisma.user.update({ + where: { id }, + data: { loginTokenHash: null }, + }); + + logger.info('Admin revoked login token for user', { + targetUser: targetUser.plexUsername, + revokedBy: req.user!.username, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + logger.error('Failed to revoke login token', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json({ error: 'Failed to revoke login token' }, { status: 500 }); + } + }); + }); +} diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index d97373b..dd88ad0 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -33,6 +33,7 @@ export async function GET(request: NextRequest) { autoApproveRequests: true, interactiveSearchAccess: true, downloadAccess: true, + loginTokenHash: true, _count: { select: { requests: true, @@ -44,7 +45,12 @@ export async function GET(request: NextRequest) { }, }); - return NextResponse.json({ users }); + return NextResponse.json({ + users: users.map(({ loginTokenHash, ...u }) => ({ + ...u, + hasLoginToken: loginTokenHash !== null, + })), + }); } catch (error) { logger.error('Failed to fetch users', { error: error instanceof Error ? error.message : String(error) }); return NextResponse.json( diff --git a/src/app/api/auth/token/login/route.ts b/src/app/api/auth/token/login/route.ts new file mode 100644 index 0000000..f5f7c42 --- /dev/null +++ b/src/app/api/auth/token/login/route.ts @@ -0,0 +1,90 @@ +/** + * Component: Token Login Route + * Documentation: documentation/backend/services/auth.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; +import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt'; +import { RMABLogger } from '@/lib/utils/logger'; +import { checkTokenLoginRateLimit } from '@/lib/utils/authRateLimit'; +import crypto from 'crypto'; + +const logger = RMABLogger.create('API.Auth.TokenLogin'); + +export async function POST(request: NextRequest) { + try { + const ip = request.headers.get('x-forwarded-for') ?? 'unknown'; + const rateLimit = checkTokenLoginRateLimit(ip); + if (!rateLimit.allowed) { + return NextResponse.json( + { error: 'Too many login attempts. Please try again later.' }, + { + status: 429, + headers: { 'Retry-After': String(rateLimit.retryAfterSeconds) }, + } + ); + } + + const { token } = await request.json(); + + if (!token) { + return NextResponse.json({ error: 'Missing token parameter' }, { status: 400 }); + } + + const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); + + const user = await prisma.user.findFirst({ + where: { + loginTokenHash: tokenHash, + deletedAt: null, + }, + select: { + id: true, + plexId: true, + plexUsername: true, + plexEmail: true, + avatarUrl: true, + role: true, + }, + }); + + if (!user) { + logger.warn('Token login failed - not found or user deleted'); + return NextResponse.json({ error: 'Invalid token' }, { status: 401 }); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { lastLoginAt: new Date() }, + }); + + const accessToken = generateAccessToken({ + sub: user.id, + plexId: user.plexId, + username: user.plexUsername, + role: user.role, + }); + + const refreshToken = generateRefreshToken(user.id); + + logger.info('Token login successful', { username: user.plexUsername }); + + return NextResponse.json({ + accessToken, + refreshToken, + user: { + id: user.id, + username: user.plexUsername, + email: user.plexEmail, + avatarUrl: user.avatarUrl, + role: user.role, + }, + }); + } catch (error) { + logger.error('Token login error', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json({ error: 'Authentication failed' }, { status: 500 }); + } +} diff --git a/src/app/auth/token/login/page.tsx b/src/app/auth/token/login/page.tsx new file mode 100644 index 0000000..9b57aad --- /dev/null +++ b/src/app/auth/token/login/page.tsx @@ -0,0 +1,65 @@ +/** + * Component: Token Login Page + * Documentation: documentation/backend/services/auth.md + */ + +'use client'; + +import { Suspense, useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useAuth } from '@/contexts/AuthContext'; + +function TokenLoginContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { setAuthData } = useAuth(); + + useEffect(() => { + const token = searchParams.get('token'); + + if (!token) { + router.replace('/login'); + return; + } + + fetch('/api/auth/token/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }) + .then((res) => res.json()) + .then((data) => { + if (data.error) { + router.replace('/login'); + return; + } + + localStorage.setItem('accessToken', data.accessToken); + localStorage.setItem('refreshToken', data.refreshToken); + localStorage.setItem('user', JSON.stringify(data.user)); + + setAuthData(data.user, data.accessToken); + window.location.href = '/'; + }) + .catch(() => { + router.replace('/login'); + }); + }, [searchParams, router, setAuthData]); + + return ( +
+
+
+

Authenticating...

+
+
+ ); +} + +export default function TokenLoginPage() { + return ( + + + + ); +} diff --git a/src/components/admin/users/UserPermissionsModal.tsx b/src/components/admin/users/UserPermissionsModal.tsx index 060c483..a8a8e8b 100644 --- a/src/components/admin/users/UserPermissionsModal.tsx +++ b/src/components/admin/users/UserPermissionsModal.tsx @@ -6,6 +6,7 @@ 'use client'; import { Modal } from '@/components/ui/Modal'; +import { useToast } from '@/components/ui/Toast'; interface UserPermissionsUser { id: string; @@ -16,6 +17,7 @@ interface UserPermissionsUser { autoApproveRequests: boolean | null; interactiveSearchAccess: boolean | null; downloadAccess: boolean | null; + hasLoginToken: boolean; } interface UserPermissionsModalProps { @@ -25,9 +27,11 @@ interface UserPermissionsModalProps { globalAutoApprove: boolean; globalInteractiveSearch: boolean; globalDownloadAccess: boolean; + generatedToken: string | null; onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void; onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void; onToggleDownloadAccess: (user: UserPermissionsUser, newValue: boolean) => void; + onToggleToken: (user: UserPermissionsUser, newValue: boolean) => void; } interface PermissionToggleProps { @@ -83,6 +87,79 @@ function PermissionToggle({ label, ariaLabel, value, disabled, disabledMessage, ); } +interface LoginTokenRowProps { + value: boolean; + generatedToken: string | null; + onToggle: () => void; +} + +function LoginTokenRow({ value, generatedToken, onToggle }: LoginTokenRowProps) { + const toast = useToast(); + const loginUrl = generatedToken + ? `${typeof window !== 'undefined' ? window.location.origin : ''}/auth/token/login?token=${generatedToken}` + : null; + + const copyUrl = async () => { + if (!loginUrl) return; + try { + await navigator.clipboard.writeText(loginUrl); + } catch { + toast.error('Failed to copy to clipboard'); + } + }; + + return ( +
+
+ +
+
+ Login Token +
+

+ When enabled, this user can log in via a direct URL without credentials +

+
+
+ + {loginUrl && ( +
+

+ Copy the login URL - it won't be shown again +

+
+ + {loginUrl} + + +
+
+ )} +
+ ); +} + export function UserPermissionsModal({ isOpen, onClose, @@ -90,9 +167,11 @@ export function UserPermissionsModal({ globalAutoApprove, globalInteractiveSearch, globalDownloadAccess, + generatedToken, onToggleAutoApprove, onToggleInteractiveSearch, onToggleDownloadAccess, + onToggleToken, }: UserPermissionsModalProps) { if (!user) return null; @@ -201,6 +280,13 @@ export function UserPermissionsModal({ description="When enabled, this user can download audiobook files directly" onToggle={() => onToggleDownloadAccess(user, !downloadValue)} /> + + {/* Login Token */} + onToggleToken(user, !(user.hasLoginToken || generatedToken !== null))} + /> diff --git a/src/lib/utils/authRateLimit.ts b/src/lib/utils/authRateLimit.ts new file mode 100644 index 0000000..0d0a56c --- /dev/null +++ b/src/lib/utils/authRateLimit.ts @@ -0,0 +1,89 @@ +/** + * Component: Auth Rate Limiting + * Documentation: documentation/backend/services/auth.md + * + * In-memory fixed-window rate limiter with lazy eviction and periodic sweep + * to prevent unbounded memory growth. + */ + +type Bucket = { + count: number; + resetAt: number; +}; + +type RateLimitResult = { + allowed: boolean; + retryAfterSeconds: number; +}; + +const buckets = new Map(); + +/** Number of checkRateLimit calls since the last full sweep */ +let checkCount = 0; + +/** How often (in calls) to perform a full sweep of expired buckets */ +const SWEEP_INTERVAL = 100; + +/** + * Sweep the entire bucket map and delete all expired entries. + * Called automatically every SWEEP_INTERVAL checks. + */ +function sweepExpiredBuckets(): void { + const now = Date.now(); + for (const [key, bucket] of buckets) { + if (now >= bucket.resetAt) { + buckets.delete(key); + } + } +} + +function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult { + const now = Date.now(); + + // Periodic full sweep every SWEEP_INTERVAL calls + checkCount += 1; + if (checkCount >= SWEEP_INTERVAL) { + checkCount = 0; + sweepExpiredBuckets(); + } + + const current = buckets.get(key); + + // Lazy eviction: if the bucket is expired, delete it and start fresh + if (!current || now >= current.resetAt) { + if (current) { + buckets.delete(key); + } + buckets.set(key, { count: 1, resetAt: now + windowMs }); + return { allowed: true, retryAfterSeconds: Math.ceil(windowMs / 1000) }; + } + + if (current.count >= maxRequests) { + return { + allowed: false, + retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)), + }; + } + + current.count += 1; + return { + allowed: true, + retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)), + }; +} + +/** 10 attempts per 15 minutes per IP */ +export function checkTokenLoginRateLimit(ip: string): RateLimitResult { + return checkRateLimit(`token-login:${ip}`, 10, 15 * 60 * 1000); +} + +/** Reset all buckets and the sweep counter. For testing only. */ +export function _resetBuckets(): void { + buckets.clear(); + checkCount = 0; +} + +/** Get the current number of tracked buckets. For testing only. */ +export function _getBucketCount(): number { + return buckets.size; +} diff --git a/tests/api/admin-login-token.routes.test.ts b/tests/api/admin-login-token.routes.test.ts new file mode 100644 index 0000000..e6b2eb4 --- /dev/null +++ b/tests/api/admin-login-token.routes.test.ts @@ -0,0 +1,106 @@ +/** + * Component: Admin User Login Token Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +let authRequest: any; + +const prismaMock = createPrismaMock(); +const requireAuthMock = vi.hoisted(() => vi.fn()); +const requireAdminMock = vi.hoisted(() => vi.fn()); +const generateApiTokenMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, + requireAdmin: requireAdminMock, +})); + +vi.mock('@/lib/utils/api-token', () => ({ + generateApiToken: generateApiTokenMock, +})); + +describe('Admin login token routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { user: { id: 'admin-1', username: 'admin', role: 'admin' }, json: vi.fn() }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + requireAdminMock.mockImplementation((_req: any, handler: any) => handler()); + generateApiTokenMock.mockReturnValue({ fullToken: 'rmab_test_token', tokenHash: 'hash_abc123' }); + }); + + describe('POST /api/admin/users/[id]/login-token', () => { + it('generates a login token for an active user', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce({ + plexUsername: 'testuser', + deletedAt: null, + }); + prismaMock.user.update.mockResolvedValueOnce({}); + + const { POST } = await import('@/app/api/admin/users/[id]/login-token/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'u1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(201); + expect(payload.fullToken).toBe('rmab_test_token'); + }); + + it('returns 404 when user does not exist', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce(null); + + const { POST } = await import('@/app/api/admin/users/[id]/login-token/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'missing' }) }); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toMatch(/User not found/); + }); + + it('returns 403 when user is deleted', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce({ + plexUsername: 'deleteduser', + deletedAt: new Date(), + }); + + const { POST } = await import('@/app/api/admin/users/[id]/login-token/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'u2' }) }); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toMatch(/deleted user/); + }); + }); + + describe('DELETE /api/admin/users/[id]/login-token', () => { + it('revokes the login token for a user', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce({ + plexUsername: 'testuser', + }); + prismaMock.user.update.mockResolvedValueOnce({}); + + const { DELETE } = await import('@/app/api/admin/users/[id]/login-token/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'u1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + }); + + it('returns 404 when user does not exist', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce(null); + + const { DELETE } = await import('@/app/api/admin/users/[id]/login-token/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'missing' }) }); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toMatch(/User not found/); + }); + }); +}); diff --git a/tests/api/auth-login-token.routes.test.ts b/tests/api/auth-login-token.routes.test.ts new file mode 100644 index 0000000..12015a7 --- /dev/null +++ b/tests/api/auth-login-token.routes.test.ts @@ -0,0 +1,95 @@ +/** + * Component: Token Login Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); +const generateAccessTokenMock = vi.hoisted(() => vi.fn()); +const generateRefreshTokenMock = vi.hoisted(() => vi.fn()); +const checkTokenLoginRateLimitMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/utils/jwt', () => ({ + generateAccessToken: generateAccessTokenMock, + generateRefreshToken: generateRefreshTokenMock, +})); + +vi.mock('@/lib/utils/authRateLimit', () => ({ + checkTokenLoginRateLimit: checkTokenLoginRateLimitMock, +})); + +function makeRequest(body: Record, ip = '127.0.0.1') { + return { + headers: { get: vi.fn().mockReturnValue(ip) }, + json: vi.fn().mockResolvedValue(body), + }; +} + +describe('POST /api/auth/token/login', () => { + beforeEach(() => { + vi.clearAllMocks(); + generateAccessTokenMock.mockReturnValue('access-token'); + generateRefreshTokenMock.mockReturnValue('refresh-token'); + checkTokenLoginRateLimitMock.mockReturnValue({ allowed: true, retryAfterSeconds: 900 }); + }); + + it('authenticates user with a valid token', async () => { + prismaMock.user.findFirst.mockResolvedValueOnce({ + id: 'u1', + plexId: 'plex-1', + plexUsername: 'testuser', + plexEmail: 'test@example.com', + avatarUrl: null, + role: 'user', + }); + prismaMock.user.update.mockResolvedValueOnce({}); + + const { POST } = await import('@/app/api/auth/token/login/route'); + const response = await POST(makeRequest({ token: 'rmab_valid_token' }) as any); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.accessToken).toBe('access-token'); + expect(payload.refreshToken).toBe('refresh-token'); + expect(payload.user.username).toBe('testuser'); + expect(payload.user.email).toBe('test@example.com'); + }); + + it('returns 400 when token parameter is missing', async () => { + const { POST } = await import('@/app/api/auth/token/login/route'); + const response = await POST(makeRequest({}) as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Missing token/); + }); + + it('returns 401 when token is invalid or user not found', async () => { + prismaMock.user.findFirst.mockResolvedValueOnce(null); + + const { POST } = await import('@/app/api/auth/token/login/route'); + const response = await POST(makeRequest({ token: 'rmab_invalid' }) as any); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.error).toMatch(/Invalid token/); + }); + + it('returns 429 when rate limit is exceeded', async () => { + checkTokenLoginRateLimitMock.mockReturnValue({ allowed: false, retryAfterSeconds: 600 }); + + const { POST } = await import('@/app/api/auth/token/login/route'); + const response = await POST(makeRequest({ token: 'rmab_any' }) as any); + const payload = await response.json(); + + expect(response.status).toBe(429); + expect(payload.error).toMatch(/Too many login attempts/); + expect(response.headers.get('Retry-After')).toBe('600'); + }); +});