Spaces:
Running
Running
Commit ·
071f340
1
Parent(s): 1b10d43
support admin login page
Browse files- .env.example +9 -5
- src/app/admin/conversations/[conversationId]/page.tsx +12 -1
- src/app/admin/conversations/page.tsx +12 -1
- src/app/admin/layout.tsx +61 -2
- src/app/admin/page.tsx +12 -2
- src/app/admin/users/[userId]/page.tsx +12 -1
- src/app/admin/users/page.tsx +12 -1
- src/app/api/admin/conversations/[conversationId]/export/tsv/route.ts +11 -0
- src/app/api/admin/conversations/[conversationId]/route.ts +13 -2
- src/app/api/admin/conversations/route.ts +11 -0
- src/app/api/admin/health/route.ts +13 -2
- src/app/api/admin/indexes/route.ts +13 -2
- src/app/api/admin/stats/route.ts +13 -2
- src/app/api/admin/users/[userId]/route.ts +13 -2
- src/app/api/admin/users/route.ts +11 -0
- src/app/api/auth/me/route.ts +25 -0
- src/lib/auth/index.ts +73 -14
- src/lib/auth/session.ts +4 -3
- tests/e2e/admin-conversation-export-concurrent.api.spec.ts +15 -7
- tests/e2e/admin-conversation-export.api.spec.ts +35 -14
- tests/e2e/admin-conversations.api.spec.ts +50 -20
- tests/e2e/admin-stats-health.api.spec.ts +32 -6
- tests/e2e/admin-users.api.spec.ts +42 -14
.env.example
CHANGED
|
@@ -19,16 +19,20 @@ CZ_OPENAI_API_KEY=your-openai-api-key
|
|
| 19 |
MODEL_NAME=gpt-5
|
| 20 |
|
| 21 |
# ------------------------------------------------------------------------------
|
| 22 |
-
# Authentication (
|
| 23 |
# ------------------------------------------------------------------------------
|
| 24 |
# Shared password for all users (used for Basic Auth)
|
| 25 |
-
#
|
| 26 |
-
BASIC_AUTH_PASSWORD=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
# Default password shown in the UI registration form
|
| 29 |
# Should match BASIC_AUTH_PASSWORD for convenience
|
| 30 |
-
|
| 31 |
-
NEXT_PUBLIC_DEFAULT_PASSWORD=cz-2025
|
| 32 |
|
| 33 |
# ------------------------------------------------------------------------------
|
| 34 |
# Database Configuration
|
|
|
|
| 19 |
MODEL_NAME=gpt-5
|
| 20 |
|
| 21 |
# ------------------------------------------------------------------------------
|
| 22 |
+
# Authentication (REQUIRED)
|
| 23 |
# ------------------------------------------------------------------------------
|
| 24 |
# Shared password for all users (used for Basic Auth)
|
| 25 |
+
# REQUIRED - no default value
|
| 26 |
+
BASIC_AUTH_PASSWORD=
|
| 27 |
+
|
| 28 |
+
# Admin password for admin panel access
|
| 29 |
+
# Users who login with this password get admin-level access
|
| 30 |
+
# REQUIRED for admin access - no default value
|
| 31 |
+
ADMIN_PASSWORD=
|
| 32 |
|
| 33 |
# Default password shown in the UI registration form
|
| 34 |
# Should match BASIC_AUTH_PASSWORD for convenience
|
| 35 |
+
NEXT_PUBLIC_DEFAULT_PASSWORD=
|
|
|
|
| 36 |
|
| 37 |
# ------------------------------------------------------------------------------
|
| 38 |
# Database Configuration
|
src/app/admin/conversations/[conversationId]/page.tsx
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
import { useState, useEffect } from 'react';
|
| 4 |
import Link from 'next/link';
|
| 5 |
import { useParams } from 'next/navigation';
|
|
|
|
| 6 |
|
| 7 |
interface Conversation {
|
| 8 |
id: string;
|
|
@@ -47,7 +48,17 @@ export default function AdminConversationDetailPage() {
|
|
| 47 |
const fetchConversationDetail = async () => {
|
| 48 |
try {
|
| 49 |
setLoading(true);
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
if (!response.ok) {
|
| 53 |
if (response.status === 404) {
|
|
|
|
| 3 |
import { useState, useEffect } from 'react';
|
| 4 |
import Link from 'next/link';
|
| 5 |
import { useParams } from 'next/navigation';
|
| 6 |
+
import { getStoredCredentials, createBasicAuthHeader } from '@/lib/auth/session';
|
| 7 |
|
| 8 |
interface Conversation {
|
| 9 |
id: string;
|
|
|
|
| 48 |
const fetchConversationDetail = async () => {
|
| 49 |
try {
|
| 50 |
setLoading(true);
|
| 51 |
+
|
| 52 |
+
const credentials = getStoredCredentials();
|
| 53 |
+
if (!credentials) {
|
| 54 |
+
throw new Error('Not authenticated');
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
const headers = {
|
| 58 |
+
Authorization: createBasicAuthHeader(credentials.username, credentials.password),
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
const response = await fetch(`/api/admin/conversations/${conversationId}`, { headers });
|
| 62 |
|
| 63 |
if (!response.ok) {
|
| 64 |
if (response.status === 404) {
|
src/app/admin/conversations/page.tsx
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
import { useState, useEffect } from 'react';
|
| 4 |
import Link from 'next/link';
|
|
|
|
| 5 |
|
| 6 |
interface Conversation {
|
| 7 |
id: string;
|
|
@@ -43,6 +44,16 @@ export default function AdminConversationsPage() {
|
|
| 43 |
const fetchConversations = async () => {
|
| 44 |
try {
|
| 45 |
setLoading(true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
const params = new URLSearchParams({
|
| 47 |
page: page.toString(),
|
| 48 |
limit: '50',
|
|
@@ -53,7 +64,7 @@ export default function AdminConversationsPage() {
|
|
| 53 |
...(coachPromptFilter && { coachPromptId: coachPromptFilter }),
|
| 54 |
});
|
| 55 |
|
| 56 |
-
const response = await fetch(`/api/admin/conversations?${params}`);
|
| 57 |
if (!response.ok) {
|
| 58 |
throw new Error('Failed to fetch conversations');
|
| 59 |
}
|
|
|
|
| 2 |
|
| 3 |
import { useState, useEffect } from 'react';
|
| 4 |
import Link from 'next/link';
|
| 5 |
+
import { getStoredCredentials, createBasicAuthHeader } from '@/lib/auth/session';
|
| 6 |
|
| 7 |
interface Conversation {
|
| 8 |
id: string;
|
|
|
|
| 44 |
const fetchConversations = async () => {
|
| 45 |
try {
|
| 46 |
setLoading(true);
|
| 47 |
+
|
| 48 |
+
const credentials = getStoredCredentials();
|
| 49 |
+
if (!credentials) {
|
| 50 |
+
throw new Error('Not authenticated');
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const headers = {
|
| 54 |
+
Authorization: createBasicAuthHeader(credentials.username, credentials.password),
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
const params = new URLSearchParams({
|
| 58 |
page: page.toString(),
|
| 59 |
limit: '50',
|
|
|
|
| 64 |
...(coachPromptFilter && { coachPromptId: coachPromptFilter }),
|
| 65 |
});
|
| 66 |
|
| 67 |
+
const response = await fetch(`/api/admin/conversations?${params}`, { headers });
|
| 68 |
if (!response.ok) {
|
| 69 |
throw new Error('Failed to fetch conversations');
|
| 70 |
}
|
src/app/admin/layout.tsx
CHANGED
|
@@ -1,12 +1,54 @@
|
|
| 1 |
'use client';
|
| 2 |
|
| 3 |
-
import { useState } from 'react';
|
| 4 |
import Link from 'next/link';
|
| 5 |
-
import { usePathname } from 'next/navigation';
|
|
|
|
| 6 |
|
| 7 |
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
| 8 |
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
|
|
|
|
|
|
| 9 |
const pathname = usePathname();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
const navigation = [
|
| 12 |
{ name: '📊 Dashboard', href: '/admin', icon: '📊' },
|
|
@@ -22,6 +64,23 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|
| 22 |
return pathname?.startsWith(href);
|
| 23 |
};
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
return (
|
| 26 |
<div className="min-h-screen bg-gray-50">
|
| 27 |
{/* Mobile Header */}
|
|
|
|
| 1 |
'use client';
|
| 2 |
|
| 3 |
+
import { useState, useEffect } from 'react';
|
| 4 |
import Link from 'next/link';
|
| 5 |
+
import { usePathname, useRouter } from 'next/navigation';
|
| 6 |
+
import { getStoredCredentials, createBasicAuthHeader } from '@/lib/auth/session';
|
| 7 |
|
| 8 |
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
| 9 |
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
| 10 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 11 |
+
const [isAuthorized, setIsAuthorized] = useState(false);
|
| 12 |
const pathname = usePathname();
|
| 13 |
+
const router = useRouter();
|
| 14 |
+
|
| 15 |
+
useEffect(() => {
|
| 16 |
+
async function checkAdminAccess() {
|
| 17 |
+
const credentials = getStoredCredentials();
|
| 18 |
+
if (!credentials) {
|
| 19 |
+
router.replace('/');
|
| 20 |
+
return;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
try {
|
| 24 |
+
const response = await fetch('/api/auth/me', {
|
| 25 |
+
headers: {
|
| 26 |
+
Authorization: createBasicAuthHeader(credentials.username, credentials.password),
|
| 27 |
+
},
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
if (!response.ok) {
|
| 31 |
+
router.replace('/dashboard');
|
| 32 |
+
return;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const data = await response.json();
|
| 36 |
+
if (data.sessionRole !== 'admin') {
|
| 37 |
+
router.replace('/dashboard');
|
| 38 |
+
return;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
setIsAuthorized(true);
|
| 42 |
+
} catch (error) {
|
| 43 |
+
console.error('[Admin] Auth check failed:', error);
|
| 44 |
+
router.replace('/dashboard');
|
| 45 |
+
} finally {
|
| 46 |
+
setIsLoading(false);
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
checkAdminAccess();
|
| 51 |
+
}, [router]);
|
| 52 |
|
| 53 |
const navigation = [
|
| 54 |
{ name: '📊 Dashboard', href: '/admin', icon: '📊' },
|
|
|
|
| 64 |
return pathname?.startsWith(href);
|
| 65 |
};
|
| 66 |
|
| 67 |
+
// Show loading state while checking auth
|
| 68 |
+
if (isLoading) {
|
| 69 |
+
return (
|
| 70 |
+
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
| 71 |
+
<div className="text-center">
|
| 72 |
+
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
| 73 |
+
<p className="mt-4 text-gray-600">Checking access...</p>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
// Don't render content if not authorized (will redirect)
|
| 80 |
+
if (!isAuthorized) {
|
| 81 |
+
return null;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
return (
|
| 85 |
<div className="min-h-screen bg-gray-50">
|
| 86 |
{/* Mobile Header */}
|
src/app/admin/page.tsx
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
import { useState, useEffect } from 'react';
|
| 4 |
import Link from 'next/link';
|
|
|
|
| 5 |
|
| 6 |
interface Stats {
|
| 7 |
users: {
|
|
@@ -44,10 +45,19 @@ export default function AdminDashboardPage() {
|
|
| 44 |
try {
|
| 45 |
setLoading(true);
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
// Fetch stats and health in parallel
|
| 48 |
const [statsRes, healthRes] = await Promise.all([
|
| 49 |
-
fetch('/api/admin/stats'),
|
| 50 |
-
fetch('/api/admin/health'),
|
| 51 |
]);
|
| 52 |
|
| 53 |
if (!statsRes.ok || !healthRes.ok) {
|
|
|
|
| 2 |
|
| 3 |
import { useState, useEffect } from 'react';
|
| 4 |
import Link from 'next/link';
|
| 5 |
+
import { getStoredCredentials, createBasicAuthHeader } from '@/lib/auth/session';
|
| 6 |
|
| 7 |
interface Stats {
|
| 8 |
users: {
|
|
|
|
| 45 |
try {
|
| 46 |
setLoading(true);
|
| 47 |
|
| 48 |
+
const credentials = getStoredCredentials();
|
| 49 |
+
if (!credentials) {
|
| 50 |
+
throw new Error('Not authenticated');
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const headers = {
|
| 54 |
+
Authorization: createBasicAuthHeader(credentials.username, credentials.password),
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
// Fetch stats and health in parallel
|
| 58 |
const [statsRes, healthRes] = await Promise.all([
|
| 59 |
+
fetch('/api/admin/stats', { headers }),
|
| 60 |
+
fetch('/api/admin/health', { headers }),
|
| 61 |
]);
|
| 62 |
|
| 63 |
if (!statsRes.ok || !healthRes.ok) {
|
src/app/admin/users/[userId]/page.tsx
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
import { useState, useEffect } from 'react';
|
| 4 |
import Link from 'next/link';
|
| 5 |
import { useParams } from 'next/navigation';
|
|
|
|
| 6 |
|
| 7 |
interface User {
|
| 8 |
id: string;
|
|
@@ -45,7 +46,17 @@ export default function AdminUserDetailPage() {
|
|
| 45 |
const fetchUserDetail = async () => {
|
| 46 |
try {
|
| 47 |
setLoading(true);
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
if (!response.ok) {
|
| 51 |
if (response.status === 404) {
|
|
|
|
| 3 |
import { useState, useEffect } from 'react';
|
| 4 |
import Link from 'next/link';
|
| 5 |
import { useParams } from 'next/navigation';
|
| 6 |
+
import { getStoredCredentials, createBasicAuthHeader } from '@/lib/auth/session';
|
| 7 |
|
| 8 |
interface User {
|
| 9 |
id: string;
|
|
|
|
| 46 |
const fetchUserDetail = async () => {
|
| 47 |
try {
|
| 48 |
setLoading(true);
|
| 49 |
+
|
| 50 |
+
const credentials = getStoredCredentials();
|
| 51 |
+
if (!credentials) {
|
| 52 |
+
throw new Error('Not authenticated');
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
const headers = {
|
| 56 |
+
Authorization: createBasicAuthHeader(credentials.username, credentials.password),
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
const response = await fetch(`/api/admin/users/${userId}`, { headers });
|
| 60 |
|
| 61 |
if (!response.ok) {
|
| 62 |
if (response.status === 404) {
|
src/app/admin/users/page.tsx
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
import { useState, useEffect } from 'react';
|
| 4 |
import Link from 'next/link';
|
|
|
|
| 5 |
|
| 6 |
interface User {
|
| 7 |
id: string;
|
|
@@ -30,6 +31,16 @@ export default function AdminUsersPage() {
|
|
| 30 |
const fetchUsers = async () => {
|
| 31 |
try {
|
| 32 |
setLoading(true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
const params = new URLSearchParams({
|
| 34 |
page: page.toString(),
|
| 35 |
limit: '50',
|
|
@@ -38,7 +49,7 @@ export default function AdminUsersPage() {
|
|
| 38 |
...(search && { search }),
|
| 39 |
});
|
| 40 |
|
| 41 |
-
const response = await fetch(`/api/admin/users?${params}`);
|
| 42 |
if (!response.ok) {
|
| 43 |
throw new Error('Failed to fetch users');
|
| 44 |
}
|
|
|
|
| 2 |
|
| 3 |
import { useState, useEffect } from 'react';
|
| 4 |
import Link from 'next/link';
|
| 5 |
+
import { getStoredCredentials, createBasicAuthHeader } from '@/lib/auth/session';
|
| 6 |
|
| 7 |
interface User {
|
| 8 |
id: string;
|
|
|
|
| 31 |
const fetchUsers = async () => {
|
| 32 |
try {
|
| 33 |
setLoading(true);
|
| 34 |
+
|
| 35 |
+
const credentials = getStoredCredentials();
|
| 36 |
+
if (!credentials) {
|
| 37 |
+
throw new Error('Not authenticated');
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
const headers = {
|
| 41 |
+
Authorization: createBasicAuthHeader(credentials.username, credentials.password),
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
const params = new URLSearchParams({
|
| 45 |
page: page.toString(),
|
| 46 |
limit: '50',
|
|
|
|
| 49 |
...(search && { search }),
|
| 50 |
});
|
| 51 |
|
| 52 |
+
const response = await fetch(`/api/admin/users?${params}`, { headers });
|
| 53 |
if (!response.ok) {
|
| 54 |
throw new Error('Failed to fetch users');
|
| 55 |
}
|
src/app/api/admin/conversations/[conversationId]/export/tsv/route.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|
| 2 |
import { ConversationRepository } from '@/lib/repositories/conversation-repository';
|
| 3 |
import { MessageRepository } from '@/lib/repositories/message-repository';
|
| 4 |
import { getDatabase } from '@/lib/db';
|
|
|
|
| 5 |
|
| 6 |
/**
|
| 7 |
* Sanitizes a filename by removing or replacing invalid characters
|
|
@@ -83,12 +84,14 @@ function formatMessageWithSpeaker(
|
|
| 83 |
* GET /api/admin/conversations/:conversationId/export/tsv
|
| 84 |
*
|
| 85 |
* Export a single conversation's messages as a TSV file
|
|
|
|
| 86 |
*/
|
| 87 |
export async function GET(
|
| 88 |
request: NextRequest,
|
| 89 |
{ params }: { params: Promise<{ conversationId: string }> }
|
| 90 |
) {
|
| 91 |
try {
|
|
|
|
| 92 |
const { conversationId } = await params;
|
| 93 |
|
| 94 |
// Fetch conversation and messages - use view to get names
|
|
@@ -181,6 +184,14 @@ export async function GET(
|
|
| 181 |
},
|
| 182 |
});
|
| 183 |
} catch (error) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
console.error('Error exporting conversation to TSV:', error);
|
| 185 |
return NextResponse.json(
|
| 186 |
{ error: 'Failed to export conversation' },
|
|
|
|
| 2 |
import { ConversationRepository } from '@/lib/repositories/conversation-repository';
|
| 3 |
import { MessageRepository } from '@/lib/repositories/message-repository';
|
| 4 |
import { getDatabase } from '@/lib/db';
|
| 5 |
+
import { requireAdminAuth, createUnauthorizedResponse, createForbiddenResponse } from '@/lib/auth';
|
| 6 |
|
| 7 |
/**
|
| 8 |
* Sanitizes a filename by removing or replacing invalid characters
|
|
|
|
| 84 |
* GET /api/admin/conversations/:conversationId/export/tsv
|
| 85 |
*
|
| 86 |
* Export a single conversation's messages as a TSV file
|
| 87 |
+
* Requires admin authentication
|
| 88 |
*/
|
| 89 |
export async function GET(
|
| 90 |
request: NextRequest,
|
| 91 |
{ params }: { params: Promise<{ conversationId: string }> }
|
| 92 |
) {
|
| 93 |
try {
|
| 94 |
+
await requireAdminAuth(request);
|
| 95 |
const { conversationId } = await params;
|
| 96 |
|
| 97 |
// Fetch conversation and messages - use view to get names
|
|
|
|
| 184 |
},
|
| 185 |
});
|
| 186 |
} catch (error) {
|
| 187 |
+
if (error instanceof Error) {
|
| 188 |
+
if (error.message === 'Unauthorized') {
|
| 189 |
+
return createUnauthorizedResponse();
|
| 190 |
+
}
|
| 191 |
+
if (error.message === 'Forbidden') {
|
| 192 |
+
return createForbiddenResponse();
|
| 193 |
+
}
|
| 194 |
+
}
|
| 195 |
console.error('Error exporting conversation to TSV:', error);
|
| 196 |
return NextResponse.json(
|
| 197 |
{ error: 'Failed to export conversation' },
|
src/app/api/admin/conversations/[conversationId]/route.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
| 1 |
-
import { NextResponse } from 'next/server';
|
| 2 |
import { ConversationRepository } from '@/lib/repositories/conversation-repository';
|
| 3 |
import { getPromptService } from '@/lib/services/prompt-service';
|
|
|
|
| 4 |
|
| 5 |
/**
|
| 6 |
* GET /api/admin/conversations/:conversationId
|
| 7 |
* Get full conversation with all messages
|
|
|
|
| 8 |
*/
|
| 9 |
export async function GET(
|
| 10 |
-
request:
|
| 11 |
{ params }: { params: Promise<{ conversationId: string }> }
|
| 12 |
) {
|
| 13 |
try {
|
|
|
|
| 14 |
const { conversationId } = await params;
|
| 15 |
|
| 16 |
const repo = new ConversationRepository();
|
|
@@ -49,6 +52,14 @@ export async function GET(
|
|
| 49 |
});
|
| 50 |
}
|
| 51 |
} catch (error) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
console.error('[GET /api/admin/conversations/:conversationId] Error:', error);
|
| 53 |
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
| 54 |
}
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
import { ConversationRepository } from '@/lib/repositories/conversation-repository';
|
| 3 |
import { getPromptService } from '@/lib/services/prompt-service';
|
| 4 |
+
import { requireAdminAuth, createUnauthorizedResponse, createForbiddenResponse } from '@/lib/auth';
|
| 5 |
|
| 6 |
/**
|
| 7 |
* GET /api/admin/conversations/:conversationId
|
| 8 |
* Get full conversation with all messages
|
| 9 |
+
* Requires admin authentication
|
| 10 |
*/
|
| 11 |
export async function GET(
|
| 12 |
+
request: NextRequest,
|
| 13 |
{ params }: { params: Promise<{ conversationId: string }> }
|
| 14 |
) {
|
| 15 |
try {
|
| 16 |
+
await requireAdminAuth(request);
|
| 17 |
const { conversationId } = await params;
|
| 18 |
|
| 19 |
const repo = new ConversationRepository();
|
|
|
|
| 52 |
});
|
| 53 |
}
|
| 54 |
} catch (error) {
|
| 55 |
+
if (error instanceof Error) {
|
| 56 |
+
if (error.message === 'Unauthorized') {
|
| 57 |
+
return createUnauthorizedResponse();
|
| 58 |
+
}
|
| 59 |
+
if (error.message === 'Forbidden') {
|
| 60 |
+
return createForbiddenResponse();
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
console.error('[GET /api/admin/conversations/:conversationId] Error:', error);
|
| 64 |
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
| 65 |
}
|
src/app/api/admin/conversations/route.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
| 1 |
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
import { ConversationRepository } from '@/lib/repositories/conversation-repository';
|
|
|
|
| 3 |
|
| 4 |
/**
|
| 5 |
* GET /api/admin/conversations
|
| 6 |
* List all conversations with filtering, pagination, and sorting
|
|
|
|
| 7 |
*
|
| 8 |
* Query parameters:
|
| 9 |
* - userId: Partial search on user ID and username (e.g., "te" matches "test-user", "teresa", "user-test-123")
|
|
@@ -16,6 +18,7 @@ import { ConversationRepository } from '@/lib/repositories/conversation-reposito
|
|
| 16 |
*/
|
| 17 |
export async function GET(request: NextRequest) {
|
| 18 |
try {
|
|
|
|
| 19 |
const searchParams = request.nextUrl.searchParams;
|
| 20 |
const userId = searchParams.get('userId') || undefined;
|
| 21 |
const studentPromptId = searchParams.get('studentPromptId') || undefined;
|
|
@@ -74,6 +77,14 @@ export async function GET(request: NextRequest) {
|
|
| 74 |
},
|
| 75 |
});
|
| 76 |
} catch (error) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
console.error('[GET /api/admin/conversations] Error:', error);
|
| 78 |
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
| 79 |
}
|
|
|
|
| 1 |
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
import { ConversationRepository } from '@/lib/repositories/conversation-repository';
|
| 3 |
+
import { requireAdminAuth, createUnauthorizedResponse, createForbiddenResponse } from '@/lib/auth';
|
| 4 |
|
| 5 |
/**
|
| 6 |
* GET /api/admin/conversations
|
| 7 |
* List all conversations with filtering, pagination, and sorting
|
| 8 |
+
* Requires admin authentication
|
| 9 |
*
|
| 10 |
* Query parameters:
|
| 11 |
* - userId: Partial search on user ID and username (e.g., "te" matches "test-user", "teresa", "user-test-123")
|
|
|
|
| 18 |
*/
|
| 19 |
export async function GET(request: NextRequest) {
|
| 20 |
try {
|
| 21 |
+
await requireAdminAuth(request);
|
| 22 |
const searchParams = request.nextUrl.searchParams;
|
| 23 |
const userId = searchParams.get('userId') || undefined;
|
| 24 |
const studentPromptId = searchParams.get('studentPromptId') || undefined;
|
|
|
|
| 77 |
},
|
| 78 |
});
|
| 79 |
} catch (error) {
|
| 80 |
+
if (error instanceof Error) {
|
| 81 |
+
if (error.message === 'Unauthorized') {
|
| 82 |
+
return createUnauthorizedResponse();
|
| 83 |
+
}
|
| 84 |
+
if (error.message === 'Forbidden') {
|
| 85 |
+
return createForbiddenResponse();
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
console.error('[GET /api/admin/conversations] Error:', error);
|
| 89 |
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
| 90 |
}
|
src/app/api/admin/health/route.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
-
import { NextResponse } from 'next/server';
|
| 2 |
import { getDatabase } from '@/lib/db';
|
|
|
|
| 3 |
|
| 4 |
/**
|
| 5 |
* Cache for health metrics
|
|
@@ -16,9 +17,11 @@ const CACHE_TTL_MS = 30 * 1000; // 30 seconds
|
|
| 16 |
/**
|
| 17 |
* GET /api/admin/health
|
| 18 |
* Get database health metrics with caching
|
|
|
|
| 19 |
*/
|
| 20 |
-
export async function GET() {
|
| 21 |
try {
|
|
|
|
| 22 |
// Check cache first
|
| 23 |
if (healthCache && Date.now() < healthCache.expiresAt) {
|
| 24 |
return NextResponse.json({
|
|
@@ -84,6 +87,14 @@ export async function GET() {
|
|
| 84 |
|
| 85 |
return NextResponse.json(responseData);
|
| 86 |
} catch (error) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
console.error('[GET /api/admin/health] Error:', error);
|
| 88 |
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
| 89 |
}
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
import { getDatabase } from '@/lib/db';
|
| 3 |
+
import { requireAdminAuth, createUnauthorizedResponse, createForbiddenResponse } from '@/lib/auth';
|
| 4 |
|
| 5 |
/**
|
| 6 |
* Cache for health metrics
|
|
|
|
| 17 |
/**
|
| 18 |
* GET /api/admin/health
|
| 19 |
* Get database health metrics with caching
|
| 20 |
+
* Requires admin authentication
|
| 21 |
*/
|
| 22 |
+
export async function GET(request: NextRequest) {
|
| 23 |
try {
|
| 24 |
+
await requireAdminAuth(request);
|
| 25 |
// Check cache first
|
| 26 |
if (healthCache && Date.now() < healthCache.expiresAt) {
|
| 27 |
return NextResponse.json({
|
|
|
|
| 87 |
|
| 88 |
return NextResponse.json(responseData);
|
| 89 |
} catch (error) {
|
| 90 |
+
if (error instanceof Error) {
|
| 91 |
+
if (error.message === 'Unauthorized') {
|
| 92 |
+
return createUnauthorizedResponse();
|
| 93 |
+
}
|
| 94 |
+
if (error.message === 'Forbidden') {
|
| 95 |
+
return createForbiddenResponse();
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
console.error('[GET /api/admin/health] Error:', error);
|
| 99 |
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
| 100 |
}
|
src/app/api/admin/indexes/route.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
| 1 |
-
import { NextResponse } from 'next/server';
|
| 2 |
import { getDatabase } from '@/lib/db';
|
|
|
|
| 3 |
|
| 4 |
/**
|
| 5 |
* GET /api/admin/indexes
|
| 6 |
* List all custom indexes in the database
|
|
|
|
| 7 |
*/
|
| 8 |
-
export async function GET() {
|
| 9 |
try {
|
|
|
|
| 10 |
const db = getDatabase();
|
| 11 |
|
| 12 |
// Query PostgreSQL system catalog for indexes
|
|
@@ -32,6 +35,14 @@ export async function GET() {
|
|
| 32 |
})),
|
| 33 |
});
|
| 34 |
} catch (error) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
console.error('[GET /api/admin/indexes] Error:', error);
|
| 36 |
return NextResponse.json(
|
| 37 |
{ error: 'Internal server error', details: error instanceof Error ? error.message : 'Unknown' },
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
import { getDatabase } from '@/lib/db';
|
| 3 |
+
import { requireAdminAuth, createUnauthorizedResponse, createForbiddenResponse } from '@/lib/auth';
|
| 4 |
|
| 5 |
/**
|
| 6 |
* GET /api/admin/indexes
|
| 7 |
* List all custom indexes in the database
|
| 8 |
+
* Requires admin authentication
|
| 9 |
*/
|
| 10 |
+
export async function GET(request: NextRequest) {
|
| 11 |
try {
|
| 12 |
+
await requireAdminAuth(request);
|
| 13 |
const db = getDatabase();
|
| 14 |
|
| 15 |
// Query PostgreSQL system catalog for indexes
|
|
|
|
| 35 |
})),
|
| 36 |
});
|
| 37 |
} catch (error) {
|
| 38 |
+
if (error instanceof Error) {
|
| 39 |
+
if (error.message === 'Unauthorized') {
|
| 40 |
+
return createUnauthorizedResponse();
|
| 41 |
+
}
|
| 42 |
+
if (error.message === 'Forbidden') {
|
| 43 |
+
return createForbiddenResponse();
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
console.error('[GET /api/admin/indexes] Error:', error);
|
| 47 |
return NextResponse.json(
|
| 48 |
{ error: 'Internal server error', details: error instanceof Error ? error.message : 'Unknown' },
|
src/app/api/admin/stats/route.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
| 1 |
-
import { NextResponse } from 'next/server';
|
| 2 |
import { getDatabase } from '@/lib/db';
|
|
|
|
| 3 |
|
| 4 |
/**
|
| 5 |
* GET /api/admin/stats
|
| 6 |
* Get overall system statistics
|
|
|
|
| 7 |
*/
|
| 8 |
-
export async function GET() {
|
| 9 |
try {
|
|
|
|
| 10 |
const db = getDatabase();
|
| 11 |
|
| 12 |
// Calculate time ranges
|
|
@@ -90,6 +93,14 @@ export async function GET() {
|
|
| 90 |
},
|
| 91 |
});
|
| 92 |
} catch (error) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
console.error('[GET /api/admin/stats] Error:', error);
|
| 94 |
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
| 95 |
}
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
import { getDatabase } from '@/lib/db';
|
| 3 |
+
import { requireAdminAuth, createUnauthorizedResponse, createForbiddenResponse } from '@/lib/auth';
|
| 4 |
|
| 5 |
/**
|
| 6 |
* GET /api/admin/stats
|
| 7 |
* Get overall system statistics
|
| 8 |
+
* Requires admin authentication
|
| 9 |
*/
|
| 10 |
+
export async function GET(request: NextRequest) {
|
| 11 |
try {
|
| 12 |
+
await requireAdminAuth(request);
|
| 13 |
const db = getDatabase();
|
| 14 |
|
| 15 |
// Calculate time ranges
|
|
|
|
| 93 |
},
|
| 94 |
});
|
| 95 |
} catch (error) {
|
| 96 |
+
if (error instanceof Error) {
|
| 97 |
+
if (error.message === 'Unauthorized') {
|
| 98 |
+
return createUnauthorizedResponse();
|
| 99 |
+
}
|
| 100 |
+
if (error.message === 'Forbidden') {
|
| 101 |
+
return createForbiddenResponse();
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
console.error('[GET /api/admin/stats] Error:', error);
|
| 105 |
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
| 106 |
}
|
src/app/api/admin/users/[userId]/route.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
| 1 |
-
import { NextResponse } from 'next/server';
|
| 2 |
import { UserRepository } from '@/lib/repositories/user-repository';
|
|
|
|
| 3 |
|
| 4 |
/**
|
| 5 |
* GET /api/admin/users/:userId
|
| 6 |
* Get detailed user information with recent conversations
|
|
|
|
| 7 |
*/
|
| 8 |
export async function GET(
|
| 9 |
-
request:
|
| 10 |
{ params }: { params: Promise<{ userId: string }> }
|
| 11 |
) {
|
| 12 |
try {
|
|
|
|
| 13 |
const { userId } = await params;
|
| 14 |
|
| 15 |
const repo = new UserRepository();
|
|
@@ -26,6 +29,14 @@ export async function GET(
|
|
| 26 |
recentConversations: result.recentConversations,
|
| 27 |
});
|
| 28 |
} catch (error) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
console.error('[GET /api/admin/users/:userId] Error:', error);
|
| 30 |
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
| 31 |
}
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
import { UserRepository } from '@/lib/repositories/user-repository';
|
| 3 |
+
import { requireAdminAuth, createUnauthorizedResponse, createForbiddenResponse } from '@/lib/auth';
|
| 4 |
|
| 5 |
/**
|
| 6 |
* GET /api/admin/users/:userId
|
| 7 |
* Get detailed user information with recent conversations
|
| 8 |
+
* Requires admin authentication
|
| 9 |
*/
|
| 10 |
export async function GET(
|
| 11 |
+
request: NextRequest,
|
| 12 |
{ params }: { params: Promise<{ userId: string }> }
|
| 13 |
) {
|
| 14 |
try {
|
| 15 |
+
await requireAdminAuth(request);
|
| 16 |
const { userId } = await params;
|
| 17 |
|
| 18 |
const repo = new UserRepository();
|
|
|
|
| 29 |
recentConversations: result.recentConversations,
|
| 30 |
});
|
| 31 |
} catch (error) {
|
| 32 |
+
if (error instanceof Error) {
|
| 33 |
+
if (error.message === 'Unauthorized') {
|
| 34 |
+
return createUnauthorizedResponse();
|
| 35 |
+
}
|
| 36 |
+
if (error.message === 'Forbidden') {
|
| 37 |
+
return createForbiddenResponse();
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
console.error('[GET /api/admin/users/:userId] Error:', error);
|
| 41 |
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
| 42 |
}
|
src/app/api/admin/users/route.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
| 1 |
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
import { UserRepository } from '@/lib/repositories/user-repository';
|
|
|
|
| 3 |
|
| 4 |
/**
|
| 5 |
* GET /api/admin/users
|
| 6 |
* List all users with pagination, search, and sorting
|
|
|
|
| 7 |
*/
|
| 8 |
export async function GET(request: NextRequest) {
|
| 9 |
try {
|
|
|
|
| 10 |
const searchParams = request.nextUrl.searchParams;
|
| 11 |
const search = searchParams.get('search') || undefined;
|
| 12 |
const page = parseInt(searchParams.get('page') || '1');
|
|
@@ -41,6 +44,14 @@ export async function GET(request: NextRequest) {
|
|
| 41 |
},
|
| 42 |
});
|
| 43 |
} catch (error) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
console.error('[GET /api/admin/users] Error:', error);
|
| 45 |
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
| 46 |
}
|
|
|
|
| 1 |
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
import { UserRepository } from '@/lib/repositories/user-repository';
|
| 3 |
+
import { requireAdminAuth, createUnauthorizedResponse, createForbiddenResponse } from '@/lib/auth';
|
| 4 |
|
| 5 |
/**
|
| 6 |
* GET /api/admin/users
|
| 7 |
* List all users with pagination, search, and sorting
|
| 8 |
+
* Requires admin authentication
|
| 9 |
*/
|
| 10 |
export async function GET(request: NextRequest) {
|
| 11 |
try {
|
| 12 |
+
await requireAdminAuth(request);
|
| 13 |
const searchParams = request.nextUrl.searchParams;
|
| 14 |
const search = searchParams.get('search') || undefined;
|
| 15 |
const page = parseInt(searchParams.get('page') || '1');
|
|
|
|
| 44 |
},
|
| 45 |
});
|
| 46 |
} catch (error) {
|
| 47 |
+
if (error instanceof Error) {
|
| 48 |
+
if (error.message === 'Unauthorized') {
|
| 49 |
+
return createUnauthorizedResponse();
|
| 50 |
+
}
|
| 51 |
+
if (error.message === 'Forbidden') {
|
| 52 |
+
return createForbiddenResponse();
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
console.error('[GET /api/admin/users] Error:', error);
|
| 56 |
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
| 57 |
}
|
src/app/api/auth/me/route.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
import { requireBasicAuth, createUnauthorizedResponse } from '@/lib/auth';
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* GET /api/auth/me
|
| 6 |
+
* Returns current user info including session role
|
| 7 |
+
* Used by admin UI to check access permissions
|
| 8 |
+
*/
|
| 9 |
+
export async function GET(request: NextRequest) {
|
| 10 |
+
try {
|
| 11 |
+
const auth = await requireBasicAuth(request);
|
| 12 |
+
|
| 13 |
+
return NextResponse.json({
|
| 14 |
+
userId: auth.userId,
|
| 15 |
+
username: auth.username,
|
| 16 |
+
sessionRole: auth.sessionRole,
|
| 17 |
+
});
|
| 18 |
+
} catch (error) {
|
| 19 |
+
if (error instanceof Error && error.message === 'Unauthorized') {
|
| 20 |
+
return createUnauthorizedResponse();
|
| 21 |
+
}
|
| 22 |
+
console.error('[GET /api/auth/me] Error:', error);
|
| 23 |
+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
| 24 |
+
}
|
| 25 |
+
}
|
src/lib/auth/index.ts
CHANGED
|
@@ -10,14 +10,32 @@ import { parseBasicAuthHeader, getDefaultPassword } from './session';
|
|
| 10 |
// Re-export session utilities
|
| 11 |
export * from './session';
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
/**
|
| 14 |
* Basic HTTP Authentication middleware for API routes
|
| 15 |
* Checks for Basic Auth credentials against registered users
|
|
|
|
| 16 |
*
|
| 17 |
* @throws Error with message 'Unauthorized' if authentication fails
|
| 18 |
-
* @returns User info {userId, username} if authenticated
|
| 19 |
*/
|
| 20 |
-
export async function requireBasicAuth(request: NextRequest): Promise<{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
const authHeader = request.headers.get('authorization');
|
| 22 |
|
| 23 |
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
|
@@ -32,13 +50,6 @@ export async function requireBasicAuth(request: NextRequest): Promise<{ userId:
|
|
| 32 |
|
| 33 |
const { username, password } = credentials;
|
| 34 |
|
| 35 |
-
// Validate password against environment variable (same for all users)
|
| 36 |
-
const validPassword = getDefaultPassword();
|
| 37 |
-
|
| 38 |
-
if (password !== validPassword) {
|
| 39 |
-
throw new Error('Unauthorized');
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
// Check if user exists in database
|
| 43 |
const userRepo = new UserRepository();
|
| 44 |
const user = await userRepo.findByUsername(username);
|
|
@@ -47,11 +58,47 @@ export async function requireBasicAuth(request: NextRequest): Promise<{ userId:
|
|
| 47 |
throw new Error('Unauthorized');
|
| 48 |
}
|
| 49 |
|
| 50 |
-
//
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
|
| 57 |
/**
|
|
@@ -65,3 +112,15 @@ export function createUnauthorizedResponse(message = 'Unauthorized'): Response {
|
|
| 65 |
},
|
| 66 |
});
|
| 67 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
// Re-export session utilities
|
| 11 |
export * from './session';
|
| 12 |
|
| 13 |
+
/**
|
| 14 |
+
* Session role type - determines access level
|
| 15 |
+
*/
|
| 16 |
+
export type SessionRole = 'user' | 'admin';
|
| 17 |
+
|
| 18 |
+
/**
|
| 19 |
+
* Get admin password from environment variable
|
| 20 |
+
* Returns undefined if not set (admin access disabled)
|
| 21 |
+
*/
|
| 22 |
+
export function getAdminPassword(): string | undefined {
|
| 23 |
+
return process.env.ADMIN_PASSWORD;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
/**
|
| 27 |
* Basic HTTP Authentication middleware for API routes
|
| 28 |
* Checks for Basic Auth credentials against registered users
|
| 29 |
+
* Returns session role based on which password was used
|
| 30 |
*
|
| 31 |
* @throws Error with message 'Unauthorized' if authentication fails
|
| 32 |
+
* @returns User info {userId, username, sessionRole} if authenticated
|
| 33 |
*/
|
| 34 |
+
export async function requireBasicAuth(request: NextRequest): Promise<{
|
| 35 |
+
userId: string;
|
| 36 |
+
username: string;
|
| 37 |
+
sessionRole: SessionRole;
|
| 38 |
+
}> {
|
| 39 |
const authHeader = request.headers.get('authorization');
|
| 40 |
|
| 41 |
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
|
|
|
| 50 |
|
| 51 |
const { username, password } = credentials;
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
// Check if user exists in database
|
| 54 |
const userRepo = new UserRepository();
|
| 55 |
const user = await userRepo.findByUsername(username);
|
|
|
|
| 58 |
throw new Error('Unauthorized');
|
| 59 |
}
|
| 60 |
|
| 61 |
+
// Get passwords from environment
|
| 62 |
+
const adminPassword = getAdminPassword();
|
| 63 |
+
const userPassword = getDefaultPassword();
|
| 64 |
+
|
| 65 |
+
// Check admin password first (if configured)
|
| 66 |
+
if (adminPassword && password === adminPassword) {
|
| 67 |
+
return {
|
| 68 |
+
userId: user.id,
|
| 69 |
+
username: user.username,
|
| 70 |
+
sessionRole: 'admin',
|
| 71 |
+
};
|
| 72 |
+
} else if (userPassword && password === userPassword) {
|
| 73 |
+
return {
|
| 74 |
+
userId: user.id,
|
| 75 |
+
username: user.username,
|
| 76 |
+
sessionRole: 'user',
|
| 77 |
+
};
|
| 78 |
+
} else {
|
| 79 |
+
throw new Error('Unauthorized');
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/**
|
| 84 |
+
* Admin authentication middleware for API routes
|
| 85 |
+
* Requires admin session role (must login with ADMIN_PASSWORD)
|
| 86 |
+
*
|
| 87 |
+
* @throws Error with message 'Forbidden' if not admin
|
| 88 |
+
* @returns User info with admin session role
|
| 89 |
+
*/
|
| 90 |
+
export async function requireAdminAuth(request: NextRequest): Promise<{
|
| 91 |
+
userId: string;
|
| 92 |
+
username: string;
|
| 93 |
+
sessionRole: 'admin';
|
| 94 |
+
}> {
|
| 95 |
+
const auth = await requireBasicAuth(request);
|
| 96 |
+
|
| 97 |
+
if (auth.sessionRole !== 'admin') {
|
| 98 |
+
throw new Error('Forbidden');
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
return auth as { userId: string; username: string; sessionRole: 'admin' };
|
| 102 |
}
|
| 103 |
|
| 104 |
/**
|
|
|
|
| 112 |
},
|
| 113 |
});
|
| 114 |
}
|
| 115 |
+
|
| 116 |
+
/**
|
| 117 |
+
* Helper to create 403 Forbidden response
|
| 118 |
+
*/
|
| 119 |
+
export function createForbiddenResponse(message = 'Forbidden'): Response {
|
| 120 |
+
return new Response(JSON.stringify({ error: message }), {
|
| 121 |
+
status: 403,
|
| 122 |
+
headers: {
|
| 123 |
+
'Content-Type': 'application/json',
|
| 124 |
+
},
|
| 125 |
+
});
|
| 126 |
+
}
|
src/lib/auth/session.ts
CHANGED
|
@@ -81,8 +81,9 @@ export function parseBasicAuthHeader(authHeader: string): { username: string; pa
|
|
| 81 |
}
|
| 82 |
|
| 83 |
/**
|
| 84 |
-
* Get the
|
|
|
|
| 85 |
*/
|
| 86 |
-
export function getDefaultPassword(): string {
|
| 87 |
-
return process.env.BASIC_AUTH_PASSWORD || process.env.NEXT_PUBLIC_DEFAULT_PASSWORD
|
| 88 |
}
|
|
|
|
| 81 |
}
|
| 82 |
|
| 83 |
/**
|
| 84 |
+
* Get the user password from environment
|
| 85 |
+
* Returns undefined if not set
|
| 86 |
*/
|
| 87 |
+
export function getDefaultPassword(): string | undefined {
|
| 88 |
+
return process.env.BASIC_AUTH_PASSWORD || process.env.NEXT_PUBLIC_DEFAULT_PASSWORD;
|
| 89 |
}
|
tests/e2e/admin-conversation-export-concurrent.api.spec.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
| 1 |
import { test, expect } from '@playwright/test';
|
| 2 |
|
| 3 |
const BASE_URL = 'http://localhost:3000';
|
|
|
|
| 4 |
|
| 5 |
test.describe('Admin Conversation TSV Export - Concurrent Requests', () => {
|
| 6 |
let testUserId: string;
|
| 7 |
let testUsername: string;
|
| 8 |
let conversation1Id: string;
|
| 9 |
let conversation2Id: string;
|
|
|
|
| 10 |
const testPassword = process.env.BASIC_AUTH_PASSWORD || 'cz-2025';
|
| 11 |
|
| 12 |
test.beforeAll(async ({ request }) => {
|
|
@@ -24,6 +26,9 @@ test.describe('Admin Conversation TSV Export - Concurrent Requests', () => {
|
|
| 24 |
const userData = await registerResponse.json();
|
| 25 |
testUserId = userData.user.id;
|
| 26 |
|
|
|
|
|
|
|
|
|
|
| 27 |
// Create Basic Auth header
|
| 28 |
const authHeader = `Basic ${Buffer.from(`${testUsername}:${testPassword}`).toString('base64')}`;
|
| 29 |
|
|
@@ -117,14 +122,16 @@ test.describe('Admin Conversation TSV Export - Concurrent Requests', () => {
|
|
| 117 |
test('Sequential exports should not mix conversations', async ({ request }) => {
|
| 118 |
// Export first conversation
|
| 119 |
const response1 = await request.get(
|
| 120 |
-
`${BASE_URL}/api/admin/conversations/${conversation1Id}/export/tsv`
|
|
|
|
| 121 |
);
|
| 122 |
expect(response1.ok()).toBeTruthy();
|
| 123 |
const tsv1 = await response1.text();
|
| 124 |
|
| 125 |
// Export second conversation
|
| 126 |
const response2 = await request.get(
|
| 127 |
-
`${BASE_URL}/api/admin/conversations/${conversation2Id}/export/tsv`
|
|
|
|
| 128 |
);
|
| 129 |
expect(response2.ok()).toBeTruthy();
|
| 130 |
const tsv2 = await response2.text();
|
|
@@ -145,8 +152,8 @@ test.describe('Admin Conversation TSV Export - Concurrent Requests', () => {
|
|
| 145 |
test('Concurrent exports should not mix conversations', async ({ request }) => {
|
| 146 |
// Trigger both exports simultaneously
|
| 147 |
const [response1, response2] = await Promise.all([
|
| 148 |
-
request.get(`${BASE_URL}/api/admin/conversations/${conversation1Id}/export/tsv`),
|
| 149 |
-
request.get(`${BASE_URL}/api/admin/conversations/${conversation2Id}/export/tsv`),
|
| 150 |
]);
|
| 151 |
|
| 152 |
expect(response1.ok()).toBeTruthy();
|
|
@@ -173,10 +180,10 @@ test.describe('Admin Conversation TSV Export - Concurrent Requests', () => {
|
|
| 173 |
const exports = [];
|
| 174 |
for (let i = 0; i < 5; i++) {
|
| 175 |
exports.push(
|
| 176 |
-
request.get(`${BASE_URL}/api/admin/conversations/${conversation1Id}/export/tsv`)
|
| 177 |
);
|
| 178 |
exports.push(
|
| 179 |
-
request.get(`${BASE_URL}/api/admin/conversations/${conversation2Id}/export/tsv`)
|
| 180 |
);
|
| 181 |
}
|
| 182 |
|
|
@@ -212,7 +219,8 @@ test.describe('Admin Conversation TSV Export - Concurrent Requests', () => {
|
|
| 212 |
|
| 213 |
test('Exported TSV should include conversation ID in every row', async ({ request }) => {
|
| 214 |
const response = await request.get(
|
| 215 |
-
`${BASE_URL}/api/admin/conversations/${conversation1Id}/export/tsv`
|
|
|
|
| 216 |
);
|
| 217 |
expect(response.ok()).toBeTruthy();
|
| 218 |
const tsv = await response.text();
|
|
|
|
| 1 |
import { test, expect } from '@playwright/test';
|
| 2 |
|
| 3 |
const BASE_URL = 'http://localhost:3000';
|
| 4 |
+
const adminPassword = process.env.ADMIN_PASSWORD || 'cz-2025-admin';
|
| 5 |
|
| 6 |
test.describe('Admin Conversation TSV Export - Concurrent Requests', () => {
|
| 7 |
let testUserId: string;
|
| 8 |
let testUsername: string;
|
| 9 |
let conversation1Id: string;
|
| 10 |
let conversation2Id: string;
|
| 11 |
+
let adminAuthHeader: string;
|
| 12 |
const testPassword = process.env.BASIC_AUTH_PASSWORD || 'cz-2025';
|
| 13 |
|
| 14 |
test.beforeAll(async ({ request }) => {
|
|
|
|
| 26 |
const userData = await registerResponse.json();
|
| 27 |
testUserId = userData.user.id;
|
| 28 |
|
| 29 |
+
// Create Admin Auth header (required for admin endpoints)
|
| 30 |
+
adminAuthHeader = `Basic ${Buffer.from(`${testUsername}:${adminPassword}`).toString('base64')}`;
|
| 31 |
+
|
| 32 |
// Create Basic Auth header
|
| 33 |
const authHeader = `Basic ${Buffer.from(`${testUsername}:${testPassword}`).toString('base64')}`;
|
| 34 |
|
|
|
|
| 122 |
test('Sequential exports should not mix conversations', async ({ request }) => {
|
| 123 |
// Export first conversation
|
| 124 |
const response1 = await request.get(
|
| 125 |
+
`${BASE_URL}/api/admin/conversations/${conversation1Id}/export/tsv`,
|
| 126 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 127 |
);
|
| 128 |
expect(response1.ok()).toBeTruthy();
|
| 129 |
const tsv1 = await response1.text();
|
| 130 |
|
| 131 |
// Export second conversation
|
| 132 |
const response2 = await request.get(
|
| 133 |
+
`${BASE_URL}/api/admin/conversations/${conversation2Id}/export/tsv`,
|
| 134 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 135 |
);
|
| 136 |
expect(response2.ok()).toBeTruthy();
|
| 137 |
const tsv2 = await response2.text();
|
|
|
|
| 152 |
test('Concurrent exports should not mix conversations', async ({ request }) => {
|
| 153 |
// Trigger both exports simultaneously
|
| 154 |
const [response1, response2] = await Promise.all([
|
| 155 |
+
request.get(`${BASE_URL}/api/admin/conversations/${conversation1Id}/export/tsv`, { headers: { Authorization: adminAuthHeader } }),
|
| 156 |
+
request.get(`${BASE_URL}/api/admin/conversations/${conversation2Id}/export/tsv`, { headers: { Authorization: adminAuthHeader } }),
|
| 157 |
]);
|
| 158 |
|
| 159 |
expect(response1.ok()).toBeTruthy();
|
|
|
|
| 180 |
const exports = [];
|
| 181 |
for (let i = 0; i < 5; i++) {
|
| 182 |
exports.push(
|
| 183 |
+
request.get(`${BASE_URL}/api/admin/conversations/${conversation1Id}/export/tsv`, { headers: { Authorization: adminAuthHeader } })
|
| 184 |
);
|
| 185 |
exports.push(
|
| 186 |
+
request.get(`${BASE_URL}/api/admin/conversations/${conversation2Id}/export/tsv`, { headers: { Authorization: adminAuthHeader } })
|
| 187 |
);
|
| 188 |
}
|
| 189 |
|
|
|
|
| 219 |
|
| 220 |
test('Exported TSV should include conversation ID in every row', async ({ request }) => {
|
| 221 |
const response = await request.get(
|
| 222 |
+
`${BASE_URL}/api/admin/conversations/${conversation1Id}/export/tsv`,
|
| 223 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 224 |
);
|
| 225 |
expect(response.ok()).toBeTruthy();
|
| 226 |
const tsv = await response.text();
|
tests/e2e/admin-conversation-export.api.spec.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
| 1 |
import { test, expect } from '@playwright/test';
|
| 2 |
|
| 3 |
const BASE_URL = 'http://localhost:3000';
|
|
|
|
| 4 |
|
| 5 |
test.describe('Admin Conversation TSV Export API', () => {
|
| 6 |
let testUserId: string;
|
| 7 |
let testConversationId: string;
|
| 8 |
let testUsername: string;
|
|
|
|
| 9 |
const testPassword = process.env.BASIC_AUTH_PASSWORD || 'cz-2025';
|
| 10 |
|
| 11 |
test.beforeAll(async ({ request }) => {
|
|
@@ -23,6 +25,9 @@ test.describe('Admin Conversation TSV Export API', () => {
|
|
| 23 |
const userData = await registerResponse.json();
|
| 24 |
testUserId = userData.user.id;
|
| 25 |
|
|
|
|
|
|
|
|
|
|
| 26 |
// Create Basic Auth header
|
| 27 |
const authHeader = `Basic ${Buffer.from(`${testUsername}:${testPassword}`).toString('base64')}`;
|
| 28 |
|
|
@@ -100,7 +105,8 @@ test.describe('Admin Conversation TSV Export API', () => {
|
|
| 100 |
request,
|
| 101 |
}) => {
|
| 102 |
const response = await request.get(
|
| 103 |
-
`${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`
|
|
|
|
| 104 |
);
|
| 105 |
|
| 106 |
expect(response.ok()).toBeTruthy();
|
|
@@ -121,7 +127,8 @@ test.describe('Admin Conversation TSV Export API', () => {
|
|
| 121 |
request,
|
| 122 |
}) => {
|
| 123 |
const response = await request.get(
|
| 124 |
-
`${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`
|
|
|
|
| 125 |
);
|
| 126 |
|
| 127 |
const tsvContent = await response.text();
|
|
@@ -148,7 +155,8 @@ test.describe('Admin Conversation TSV Export API', () => {
|
|
| 148 |
request,
|
| 149 |
}) => {
|
| 150 |
const response = await request.get(
|
| 151 |
-
`${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`
|
|
|
|
| 152 |
);
|
| 153 |
|
| 154 |
const tsvContent = await response.text();
|
|
@@ -161,7 +169,8 @@ test.describe('Admin Conversation TSV Export API', () => {
|
|
| 161 |
request,
|
| 162 |
}) => {
|
| 163 |
const response = await request.get(
|
| 164 |
-
`${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`
|
|
|
|
| 165 |
);
|
| 166 |
|
| 167 |
const tsvContent = await response.text();
|
|
@@ -174,7 +183,8 @@ test.describe('Admin Conversation TSV Export API', () => {
|
|
| 174 |
request,
|
| 175 |
}) => {
|
| 176 |
const response = await request.get(
|
| 177 |
-
`${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`
|
|
|
|
| 178 |
);
|
| 179 |
|
| 180 |
const tsvContent = await response.text();
|
|
@@ -188,14 +198,16 @@ test.describe('Admin Conversation TSV Export API', () => {
|
|
| 188 |
}) => {
|
| 189 |
// First, get the conversation to see how many messages it has
|
| 190 |
const detailResponse = await request.get(
|
| 191 |
-
`${BASE_URL}/api/admin/conversations/${testConversationId}`
|
|
|
|
| 192 |
);
|
| 193 |
const detailData = await detailResponse.json();
|
| 194 |
const messageCount = detailData.messages.length;
|
| 195 |
|
| 196 |
// Now get the TSV
|
| 197 |
const response = await request.get(
|
| 198 |
-
`${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`
|
|
|
|
| 199 |
);
|
| 200 |
|
| 201 |
const tsvContent = await response.text();
|
|
@@ -210,7 +222,8 @@ test.describe('Admin Conversation TSV Export API', () => {
|
|
| 210 |
request,
|
| 211 |
}) => {
|
| 212 |
const response = await request.get(
|
| 213 |
-
`${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`
|
|
|
|
| 214 |
);
|
| 215 |
|
| 216 |
const tsvContent = await response.text();
|
|
@@ -264,7 +277,8 @@ test.describe('Admin Conversation TSV Export API', () => {
|
|
| 264 |
|
| 265 |
// Export the FIRST conversation
|
| 266 |
const response = await request.get(
|
| 267 |
-
`${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`
|
|
|
|
| 268 |
);
|
| 269 |
|
| 270 |
const tsvContent = await response.text();
|
|
@@ -284,7 +298,8 @@ test.describe('Admin Conversation TSV Export API', () => {
|
|
| 284 |
}) => {
|
| 285 |
const fakeConvId = '00000000-0000-0000-0000-000000000000';
|
| 286 |
const response = await request.get(
|
| 287 |
-
`${BASE_URL}/api/admin/conversations/${fakeConvId}/export/tsv`
|
|
|
|
| 288 |
);
|
| 289 |
|
| 290 |
expect(response.status()).toBe(404);
|
|
@@ -298,7 +313,8 @@ test.describe('Admin Conversation TSV Export API', () => {
|
|
| 298 |
request,
|
| 299 |
}) => {
|
| 300 |
const response = await request.get(
|
| 301 |
-
`${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`
|
|
|
|
| 302 |
);
|
| 303 |
|
| 304 |
const contentDisposition = response.headers()['content-disposition'];
|
|
@@ -360,7 +376,8 @@ test.describe('Admin Conversation TSV Export API', () => {
|
|
| 360 |
|
| 361 |
// Export should work
|
| 362 |
const response = await request.get(
|
| 363 |
-
`${BASE_URL}/api/admin/conversations/${specialConvId}/export/tsv`
|
|
|
|
| 364 |
);
|
| 365 |
|
| 366 |
expect(response.ok()).toBeTruthy();
|
|
@@ -387,7 +404,8 @@ test.describe('Admin Conversation TSV Export API', () => {
|
|
| 387 |
request,
|
| 388 |
}) => {
|
| 389 |
const response = await request.get(
|
| 390 |
-
`${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`
|
|
|
|
| 391 |
);
|
| 392 |
|
| 393 |
const tsvContent = await response.text();
|
|
@@ -439,7 +457,10 @@ test.describe('Admin Conversation TSV Export API', () => {
|
|
| 439 |
});
|
| 440 |
|
| 441 |
// Export TSV
|
| 442 |
-
const response = await request.get(
|
|
|
|
|
|
|
|
|
|
| 443 |
|
| 444 |
// Should return 200 (verifies UTF-8 encoding works, not ByteString error)
|
| 445 |
expect(response.ok()).toBeTruthy();
|
|
|
|
| 1 |
import { test, expect } from '@playwright/test';
|
| 2 |
|
| 3 |
const BASE_URL = 'http://localhost:3000';
|
| 4 |
+
const adminPassword = process.env.ADMIN_PASSWORD || 'cz-2025-admin';
|
| 5 |
|
| 6 |
test.describe('Admin Conversation TSV Export API', () => {
|
| 7 |
let testUserId: string;
|
| 8 |
let testConversationId: string;
|
| 9 |
let testUsername: string;
|
| 10 |
+
let adminAuthHeader: string;
|
| 11 |
const testPassword = process.env.BASIC_AUTH_PASSWORD || 'cz-2025';
|
| 12 |
|
| 13 |
test.beforeAll(async ({ request }) => {
|
|
|
|
| 25 |
const userData = await registerResponse.json();
|
| 26 |
testUserId = userData.user.id;
|
| 27 |
|
| 28 |
+
// Create Admin Auth header (required for admin endpoints)
|
| 29 |
+
adminAuthHeader = `Basic ${Buffer.from(`${testUsername}:${adminPassword}`).toString('base64')}`;
|
| 30 |
+
|
| 31 |
// Create Basic Auth header
|
| 32 |
const authHeader = `Basic ${Buffer.from(`${testUsername}:${testPassword}`).toString('base64')}`;
|
| 33 |
|
|
|
|
| 105 |
request,
|
| 106 |
}) => {
|
| 107 |
const response = await request.get(
|
| 108 |
+
`${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`,
|
| 109 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 110 |
);
|
| 111 |
|
| 112 |
expect(response.ok()).toBeTruthy();
|
|
|
|
| 127 |
request,
|
| 128 |
}) => {
|
| 129 |
const response = await request.get(
|
| 130 |
+
`${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`,
|
| 131 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 132 |
);
|
| 133 |
|
| 134 |
const tsvContent = await response.text();
|
|
|
|
| 155 |
request,
|
| 156 |
}) => {
|
| 157 |
const response = await request.get(
|
| 158 |
+
`${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`,
|
| 159 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 160 |
);
|
| 161 |
|
| 162 |
const tsvContent = await response.text();
|
|
|
|
| 169 |
request,
|
| 170 |
}) => {
|
| 171 |
const response = await request.get(
|
| 172 |
+
`${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`,
|
| 173 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 174 |
);
|
| 175 |
|
| 176 |
const tsvContent = await response.text();
|
|
|
|
| 183 |
request,
|
| 184 |
}) => {
|
| 185 |
const response = await request.get(
|
| 186 |
+
`${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`,
|
| 187 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 188 |
);
|
| 189 |
|
| 190 |
const tsvContent = await response.text();
|
|
|
|
| 198 |
}) => {
|
| 199 |
// First, get the conversation to see how many messages it has
|
| 200 |
const detailResponse = await request.get(
|
| 201 |
+
`${BASE_URL}/api/admin/conversations/${testConversationId}`,
|
| 202 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 203 |
);
|
| 204 |
const detailData = await detailResponse.json();
|
| 205 |
const messageCount = detailData.messages.length;
|
| 206 |
|
| 207 |
// Now get the TSV
|
| 208 |
const response = await request.get(
|
| 209 |
+
`${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`,
|
| 210 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 211 |
);
|
| 212 |
|
| 213 |
const tsvContent = await response.text();
|
|
|
|
| 222 |
request,
|
| 223 |
}) => {
|
| 224 |
const response = await request.get(
|
| 225 |
+
`${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`,
|
| 226 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 227 |
);
|
| 228 |
|
| 229 |
const tsvContent = await response.text();
|
|
|
|
| 277 |
|
| 278 |
// Export the FIRST conversation
|
| 279 |
const response = await request.get(
|
| 280 |
+
`${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`,
|
| 281 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 282 |
);
|
| 283 |
|
| 284 |
const tsvContent = await response.text();
|
|
|
|
| 298 |
}) => {
|
| 299 |
const fakeConvId = '00000000-0000-0000-0000-000000000000';
|
| 300 |
const response = await request.get(
|
| 301 |
+
`${BASE_URL}/api/admin/conversations/${fakeConvId}/export/tsv`,
|
| 302 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 303 |
);
|
| 304 |
|
| 305 |
expect(response.status()).toBe(404);
|
|
|
|
| 313 |
request,
|
| 314 |
}) => {
|
| 315 |
const response = await request.get(
|
| 316 |
+
`${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`,
|
| 317 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 318 |
);
|
| 319 |
|
| 320 |
const contentDisposition = response.headers()['content-disposition'];
|
|
|
|
| 376 |
|
| 377 |
// Export should work
|
| 378 |
const response = await request.get(
|
| 379 |
+
`${BASE_URL}/api/admin/conversations/${specialConvId}/export/tsv`,
|
| 380 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 381 |
);
|
| 382 |
|
| 383 |
expect(response.ok()).toBeTruthy();
|
|
|
|
| 404 |
request,
|
| 405 |
}) => {
|
| 406 |
const response = await request.get(
|
| 407 |
+
`${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`,
|
| 408 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 409 |
);
|
| 410 |
|
| 411 |
const tsvContent = await response.text();
|
|
|
|
| 457 |
});
|
| 458 |
|
| 459 |
// Export TSV
|
| 460 |
+
const response = await request.get(
|
| 461 |
+
`${BASE_URL}/api/admin/conversations/${speakerTestConvId}/export/tsv`,
|
| 462 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 463 |
+
);
|
| 464 |
|
| 465 |
// Should return 200 (verifies UTF-8 encoding works, not ByteString error)
|
| 466 |
expect(response.ok()).toBeTruthy();
|
tests/e2e/admin-conversations.api.spec.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
| 1 |
import { test, expect } from '@playwright/test';
|
| 2 |
|
| 3 |
const BASE_URL = 'http://localhost:3000';
|
|
|
|
| 4 |
|
| 5 |
test.describe('Admin Conversations APIs', () => {
|
| 6 |
let testUserId: string;
|
| 7 |
let testConversationId: string;
|
| 8 |
let testUsername: string;
|
|
|
|
| 9 |
const testPassword = process.env.BASIC_AUTH_PASSWORD || 'cz-2025';
|
| 10 |
|
| 11 |
test.beforeAll(async ({ request }) => {
|
|
@@ -23,6 +25,9 @@ test.describe('Admin Conversations APIs', () => {
|
|
| 23 |
const userData = await registerResponse.json();
|
| 24 |
testUserId = userData.user.id;
|
| 25 |
|
|
|
|
|
|
|
|
|
|
| 26 |
// Create Basic Auth header
|
| 27 |
const authHeader = `Basic ${Buffer.from(`${testUsername}:${testPassword}`).toString('base64')}`;
|
| 28 |
|
|
@@ -56,7 +61,9 @@ test.describe('Admin Conversations APIs', () => {
|
|
| 56 |
test('GET /api/admin/conversations - should return conversations list with default params', async ({
|
| 57 |
request,
|
| 58 |
}) => {
|
| 59 |
-
const response = await request.get(`${BASE_URL}/api/admin/conversations`
|
|
|
|
|
|
|
| 60 |
expect(response.ok()).toBeTruthy();
|
| 61 |
expect(response.status()).toBe(200);
|
| 62 |
|
|
@@ -81,7 +88,9 @@ test.describe('Admin Conversations APIs', () => {
|
|
| 81 |
test('GET /api/admin/conversations - should validate conversation object structure', async ({
|
| 82 |
request,
|
| 83 |
}) => {
|
| 84 |
-
const response = await request.get(`${BASE_URL}/api/admin/conversations?limit=1`
|
|
|
|
|
|
|
| 85 |
const data = await response.json();
|
| 86 |
|
| 87 |
if (data.conversations.length > 0) {
|
|
@@ -120,7 +129,8 @@ test.describe('Admin Conversations APIs', () => {
|
|
| 120 |
|
| 121 |
test('GET /api/admin/conversations - should filter by userId', async ({ request }) => {
|
| 122 |
const response = await request.get(
|
| 123 |
-
`${BASE_URL}/api/admin/conversations?userId=${testUserId}`
|
|
|
|
| 124 |
);
|
| 125 |
expect(response.ok()).toBeTruthy();
|
| 126 |
|
|
@@ -140,7 +150,8 @@ test.describe('Admin Conversations APIs', () => {
|
|
| 140 |
request,
|
| 141 |
}) => {
|
| 142 |
const response = await request.get(
|
| 143 |
-
`${BASE_URL}/api/admin/conversations?studentPromptId=ruirui`
|
|
|
|
| 144 |
);
|
| 145 |
expect(response.ok()).toBeTruthy();
|
| 146 |
|
|
@@ -154,7 +165,8 @@ test.describe('Admin Conversations APIs', () => {
|
|
| 154 |
|
| 155 |
test('GET /api/admin/conversations - should filter by coachPromptId', async ({ request }) => {
|
| 156 |
const response = await request.get(
|
| 157 |
-
`${BASE_URL}/api/admin/conversations?coachPromptId=empathetic`
|
|
|
|
| 158 |
);
|
| 159 |
expect(response.ok()).toBeTruthy();
|
| 160 |
|
|
@@ -168,7 +180,8 @@ test.describe('Admin Conversations APIs', () => {
|
|
| 168 |
|
| 169 |
test('GET /api/admin/conversations - should support multiple filters', async ({ request }) => {
|
| 170 |
const response = await request.get(
|
| 171 |
-
`${BASE_URL}/api/admin/conversations?userId=${testUserId}&studentPromptId=ruirui&coachPromptId=empathetic`
|
|
|
|
| 172 |
);
|
| 173 |
expect(response.ok()).toBeTruthy();
|
| 174 |
|
|
@@ -184,12 +197,14 @@ test.describe('Admin Conversations APIs', () => {
|
|
| 184 |
|
| 185 |
test('GET /api/admin/conversations - should sort by createdAt', async ({ request }) => {
|
| 186 |
const ascResponse = await request.get(
|
| 187 |
-
`${BASE_URL}/api/admin/conversations?sortBy=createdAt&sortOrder=asc&limit=10`
|
|
|
|
| 188 |
);
|
| 189 |
expect(ascResponse.ok()).toBeTruthy();
|
| 190 |
|
| 191 |
const descResponse = await request.get(
|
| 192 |
-
`${BASE_URL}/api/admin/conversations?sortBy=createdAt&sortOrder=desc&limit=10`
|
|
|
|
| 193 |
);
|
| 194 |
expect(descResponse.ok()).toBeTruthy();
|
| 195 |
|
|
@@ -213,7 +228,8 @@ test.describe('Admin Conversations APIs', () => {
|
|
| 213 |
|
| 214 |
test('GET /api/admin/conversations - should sort by lastActiveAt', async ({ request }) => {
|
| 215 |
const response = await request.get(
|
| 216 |
-
`${BASE_URL}/api/admin/conversations?sortBy=lastActiveAt&sortOrder=desc&limit=10`
|
|
|
|
| 217 |
);
|
| 218 |
expect(response.ok()).toBeTruthy();
|
| 219 |
|
|
@@ -229,7 +245,8 @@ test.describe('Admin Conversations APIs', () => {
|
|
| 229 |
|
| 230 |
test('GET /api/admin/conversations - should sort by messageCount', async ({ request }) => {
|
| 231 |
const response = await request.get(
|
| 232 |
-
`${BASE_URL}/api/admin/conversations?sortBy=messageCount&sortOrder=desc&limit=10`
|
|
|
|
| 233 |
);
|
| 234 |
expect(response.ok()).toBeTruthy();
|
| 235 |
|
|
@@ -247,7 +264,8 @@ test.describe('Admin Conversations APIs', () => {
|
|
| 247 |
request,
|
| 248 |
}) => {
|
| 249 |
const response = await request.get(
|
| 250 |
-
`${BASE_URL}/api/admin/conversations?sortBy=invalidField`
|
|
|
|
| 251 |
);
|
| 252 |
|
| 253 |
expect(response.status()).toBe(400);
|
|
@@ -258,13 +276,16 @@ test.describe('Admin Conversations APIs', () => {
|
|
| 258 |
});
|
| 259 |
|
| 260 |
test('GET /api/admin/conversations - should support pagination', async ({ request }) => {
|
| 261 |
-
const page1Response = await request.get(`${BASE_URL}/api/admin/conversations?page=1&limit=5`
|
|
|
|
|
|
|
| 262 |
expect(page1Response.ok()).toBeTruthy();
|
| 263 |
const page1Data = await page1Response.json();
|
| 264 |
|
| 265 |
if (page1Data.pagination.totalPages > 1) {
|
| 266 |
const page2Response = await request.get(
|
| 267 |
-
`${BASE_URL}/api/admin/conversations?page=2&limit=5`
|
|
|
|
| 268 |
);
|
| 269 |
expect(page2Response.ok()).toBeTruthy();
|
| 270 |
const page2Data = await page2Response.json();
|
|
@@ -280,7 +301,9 @@ test.describe('Admin Conversations APIs', () => {
|
|
| 280 |
});
|
| 281 |
|
| 282 |
test('GET /api/admin/conversations - should enforce max limit of 100', async ({ request }) => {
|
| 283 |
-
const response = await request.get(`${BASE_URL}/api/admin/conversations?limit=200`
|
|
|
|
|
|
|
| 284 |
const data = await response.json();
|
| 285 |
|
| 286 |
// Should cap at 100
|
|
@@ -291,7 +314,8 @@ test.describe('Admin Conversations APIs', () => {
|
|
| 291 |
request,
|
| 292 |
}) => {
|
| 293 |
const response = await request.get(
|
| 294 |
-
`${BASE_URL}/api/admin/conversations/${testConversationId}`
|
|
|
|
| 295 |
);
|
| 296 |
expect(response.ok()).toBeTruthy();
|
| 297 |
expect(response.status()).toBe(200);
|
|
@@ -323,7 +347,8 @@ test.describe('Admin Conversations APIs', () => {
|
|
| 323 |
request,
|
| 324 |
}) => {
|
| 325 |
const response = await request.get(
|
| 326 |
-
`${BASE_URL}/api/admin/conversations/${testConversationId}`
|
|
|
|
| 327 |
);
|
| 328 |
const data = await response.json();
|
| 329 |
|
|
@@ -363,7 +388,8 @@ test.describe('Admin Conversations APIs', () => {
|
|
| 363 |
request,
|
| 364 |
}) => {
|
| 365 |
const response = await request.get(
|
| 366 |
-
`${BASE_URL}/api/admin/conversations/${testConversationId}`
|
|
|
|
| 367 |
);
|
| 368 |
const data = await response.json();
|
| 369 |
|
|
@@ -376,7 +402,9 @@ test.describe('Admin Conversations APIs', () => {
|
|
| 376 |
request,
|
| 377 |
}) => {
|
| 378 |
const fakeConvId = '00000000-0000-0000-0000-000000000000';
|
| 379 |
-
const response = await request.get(`${BASE_URL}/api/admin/conversations/${fakeConvId}`
|
|
|
|
|
|
|
| 380 |
|
| 381 |
expect(response.status()).toBe(404);
|
| 382 |
|
|
@@ -390,7 +418,8 @@ test.describe('Admin Conversations APIs', () => {
|
|
| 390 |
}) => {
|
| 391 |
const fakeUserId = '00000000-0000-0000-0000-000000000000';
|
| 392 |
const response = await request.get(
|
| 393 |
-
`${BASE_URL}/api/admin/conversations?userId=${fakeUserId}`
|
|
|
|
| 394 |
);
|
| 395 |
expect(response.ok()).toBeTruthy();
|
| 396 |
|
|
@@ -403,7 +432,8 @@ test.describe('Admin Conversations APIs', () => {
|
|
| 403 |
request,
|
| 404 |
}) => {
|
| 405 |
const response = await request.get(
|
| 406 |
-
`${BASE_URL}/api/admin/conversations/${testConversationId}`
|
|
|
|
| 407 |
);
|
| 408 |
const data = await response.json();
|
| 409 |
|
|
|
|
| 1 |
import { test, expect } from '@playwright/test';
|
| 2 |
|
| 3 |
const BASE_URL = 'http://localhost:3000';
|
| 4 |
+
const adminPassword = process.env.ADMIN_PASSWORD || 'cz-2025-admin';
|
| 5 |
|
| 6 |
test.describe('Admin Conversations APIs', () => {
|
| 7 |
let testUserId: string;
|
| 8 |
let testConversationId: string;
|
| 9 |
let testUsername: string;
|
| 10 |
+
let adminAuthHeader: string;
|
| 11 |
const testPassword = process.env.BASIC_AUTH_PASSWORD || 'cz-2025';
|
| 12 |
|
| 13 |
test.beforeAll(async ({ request }) => {
|
|
|
|
| 25 |
const userData = await registerResponse.json();
|
| 26 |
testUserId = userData.user.id;
|
| 27 |
|
| 28 |
+
// Create Admin Auth header (required for admin endpoints)
|
| 29 |
+
adminAuthHeader = `Basic ${Buffer.from(`${testUsername}:${adminPassword}`).toString('base64')}`;
|
| 30 |
+
|
| 31 |
// Create Basic Auth header
|
| 32 |
const authHeader = `Basic ${Buffer.from(`${testUsername}:${testPassword}`).toString('base64')}`;
|
| 33 |
|
|
|
|
| 61 |
test('GET /api/admin/conversations - should return conversations list with default params', async ({
|
| 62 |
request,
|
| 63 |
}) => {
|
| 64 |
+
const response = await request.get(`${BASE_URL}/api/admin/conversations`, {
|
| 65 |
+
headers: { Authorization: adminAuthHeader },
|
| 66 |
+
});
|
| 67 |
expect(response.ok()).toBeTruthy();
|
| 68 |
expect(response.status()).toBe(200);
|
| 69 |
|
|
|
|
| 88 |
test('GET /api/admin/conversations - should validate conversation object structure', async ({
|
| 89 |
request,
|
| 90 |
}) => {
|
| 91 |
+
const response = await request.get(`${BASE_URL}/api/admin/conversations?limit=1`, {
|
| 92 |
+
headers: { Authorization: adminAuthHeader },
|
| 93 |
+
});
|
| 94 |
const data = await response.json();
|
| 95 |
|
| 96 |
if (data.conversations.length > 0) {
|
|
|
|
| 129 |
|
| 130 |
test('GET /api/admin/conversations - should filter by userId', async ({ request }) => {
|
| 131 |
const response = await request.get(
|
| 132 |
+
`${BASE_URL}/api/admin/conversations?userId=${testUserId}`,
|
| 133 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 134 |
);
|
| 135 |
expect(response.ok()).toBeTruthy();
|
| 136 |
|
|
|
|
| 150 |
request,
|
| 151 |
}) => {
|
| 152 |
const response = await request.get(
|
| 153 |
+
`${BASE_URL}/api/admin/conversations?studentPromptId=ruirui`,
|
| 154 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 155 |
);
|
| 156 |
expect(response.ok()).toBeTruthy();
|
| 157 |
|
|
|
|
| 165 |
|
| 166 |
test('GET /api/admin/conversations - should filter by coachPromptId', async ({ request }) => {
|
| 167 |
const response = await request.get(
|
| 168 |
+
`${BASE_URL}/api/admin/conversations?coachPromptId=empathetic`,
|
| 169 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 170 |
);
|
| 171 |
expect(response.ok()).toBeTruthy();
|
| 172 |
|
|
|
|
| 180 |
|
| 181 |
test('GET /api/admin/conversations - should support multiple filters', async ({ request }) => {
|
| 182 |
const response = await request.get(
|
| 183 |
+
`${BASE_URL}/api/admin/conversations?userId=${testUserId}&studentPromptId=ruirui&coachPromptId=empathetic`,
|
| 184 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 185 |
);
|
| 186 |
expect(response.ok()).toBeTruthy();
|
| 187 |
|
|
|
|
| 197 |
|
| 198 |
test('GET /api/admin/conversations - should sort by createdAt', async ({ request }) => {
|
| 199 |
const ascResponse = await request.get(
|
| 200 |
+
`${BASE_URL}/api/admin/conversations?sortBy=createdAt&sortOrder=asc&limit=10`,
|
| 201 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 202 |
);
|
| 203 |
expect(ascResponse.ok()).toBeTruthy();
|
| 204 |
|
| 205 |
const descResponse = await request.get(
|
| 206 |
+
`${BASE_URL}/api/admin/conversations?sortBy=createdAt&sortOrder=desc&limit=10`,
|
| 207 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 208 |
);
|
| 209 |
expect(descResponse.ok()).toBeTruthy();
|
| 210 |
|
|
|
|
| 228 |
|
| 229 |
test('GET /api/admin/conversations - should sort by lastActiveAt', async ({ request }) => {
|
| 230 |
const response = await request.get(
|
| 231 |
+
`${BASE_URL}/api/admin/conversations?sortBy=lastActiveAt&sortOrder=desc&limit=10`,
|
| 232 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 233 |
);
|
| 234 |
expect(response.ok()).toBeTruthy();
|
| 235 |
|
|
|
|
| 245 |
|
| 246 |
test('GET /api/admin/conversations - should sort by messageCount', async ({ request }) => {
|
| 247 |
const response = await request.get(
|
| 248 |
+
`${BASE_URL}/api/admin/conversations?sortBy=messageCount&sortOrder=desc&limit=10`,
|
| 249 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 250 |
);
|
| 251 |
expect(response.ok()).toBeTruthy();
|
| 252 |
|
|
|
|
| 264 |
request,
|
| 265 |
}) => {
|
| 266 |
const response = await request.get(
|
| 267 |
+
`${BASE_URL}/api/admin/conversations?sortBy=invalidField`,
|
| 268 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 269 |
);
|
| 270 |
|
| 271 |
expect(response.status()).toBe(400);
|
|
|
|
| 276 |
});
|
| 277 |
|
| 278 |
test('GET /api/admin/conversations - should support pagination', async ({ request }) => {
|
| 279 |
+
const page1Response = await request.get(`${BASE_URL}/api/admin/conversations?page=1&limit=5`, {
|
| 280 |
+
headers: { Authorization: adminAuthHeader },
|
| 281 |
+
});
|
| 282 |
expect(page1Response.ok()).toBeTruthy();
|
| 283 |
const page1Data = await page1Response.json();
|
| 284 |
|
| 285 |
if (page1Data.pagination.totalPages > 1) {
|
| 286 |
const page2Response = await request.get(
|
| 287 |
+
`${BASE_URL}/api/admin/conversations?page=2&limit=5`,
|
| 288 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 289 |
);
|
| 290 |
expect(page2Response.ok()).toBeTruthy();
|
| 291 |
const page2Data = await page2Response.json();
|
|
|
|
| 301 |
});
|
| 302 |
|
| 303 |
test('GET /api/admin/conversations - should enforce max limit of 100', async ({ request }) => {
|
| 304 |
+
const response = await request.get(`${BASE_URL}/api/admin/conversations?limit=200`, {
|
| 305 |
+
headers: { Authorization: adminAuthHeader },
|
| 306 |
+
});
|
| 307 |
const data = await response.json();
|
| 308 |
|
| 309 |
// Should cap at 100
|
|
|
|
| 314 |
request,
|
| 315 |
}) => {
|
| 316 |
const response = await request.get(
|
| 317 |
+
`${BASE_URL}/api/admin/conversations/${testConversationId}`,
|
| 318 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 319 |
);
|
| 320 |
expect(response.ok()).toBeTruthy();
|
| 321 |
expect(response.status()).toBe(200);
|
|
|
|
| 347 |
request,
|
| 348 |
}) => {
|
| 349 |
const response = await request.get(
|
| 350 |
+
`${BASE_URL}/api/admin/conversations/${testConversationId}`,
|
| 351 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 352 |
);
|
| 353 |
const data = await response.json();
|
| 354 |
|
|
|
|
| 388 |
request,
|
| 389 |
}) => {
|
| 390 |
const response = await request.get(
|
| 391 |
+
`${BASE_URL}/api/admin/conversations/${testConversationId}`,
|
| 392 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 393 |
);
|
| 394 |
const data = await response.json();
|
| 395 |
|
|
|
|
| 402 |
request,
|
| 403 |
}) => {
|
| 404 |
const fakeConvId = '00000000-0000-0000-0000-000000000000';
|
| 405 |
+
const response = await request.get(`${BASE_URL}/api/admin/conversations/${fakeConvId}`, {
|
| 406 |
+
headers: { Authorization: adminAuthHeader },
|
| 407 |
+
});
|
| 408 |
|
| 409 |
expect(response.status()).toBe(404);
|
| 410 |
|
|
|
|
| 418 |
}) => {
|
| 419 |
const fakeUserId = '00000000-0000-0000-0000-000000000000';
|
| 420 |
const response = await request.get(
|
| 421 |
+
`${BASE_URL}/api/admin/conversations?userId=${fakeUserId}`,
|
| 422 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 423 |
);
|
| 424 |
expect(response.ok()).toBeTruthy();
|
| 425 |
|
|
|
|
| 432 |
request,
|
| 433 |
}) => {
|
| 434 |
const response = await request.get(
|
| 435 |
+
`${BASE_URL}/api/admin/conversations/${testConversationId}`,
|
| 436 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 437 |
);
|
| 438 |
const data = await response.json();
|
| 439 |
|
tests/e2e/admin-stats-health.api.spec.ts
CHANGED
|
@@ -1,10 +1,30 @@
|
|
| 1 |
import { test, expect } from '@playwright/test';
|
| 2 |
|
| 3 |
const BASE_URL = 'http://localhost:3000';
|
|
|
|
| 4 |
|
| 5 |
test.describe('Admin Stats and Health APIs', () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
test('GET /api/admin/stats - should return system statistics', async ({ request }) => {
|
| 7 |
-
const response = await request.get(`${BASE_URL}/api/admin/stats`
|
|
|
|
|
|
|
| 8 |
expect(response.ok()).toBeTruthy();
|
| 9 |
expect(response.status()).toBe(200);
|
| 10 |
|
|
@@ -50,7 +70,9 @@ test.describe('Admin Stats and Health APIs', () => {
|
|
| 50 |
});
|
| 51 |
|
| 52 |
test('GET /api/admin/health - should return database health metrics', async ({ request }) => {
|
| 53 |
-
const response = await request.get(`${BASE_URL}/api/admin/health`
|
|
|
|
|
|
|
| 54 |
expect(response.ok()).toBeTruthy();
|
| 55 |
expect(response.status()).toBe(200);
|
| 56 |
|
|
@@ -86,7 +108,9 @@ test.describe('Admin Stats and Health APIs', () => {
|
|
| 86 |
});
|
| 87 |
|
| 88 |
test('GET /api/admin/stats - should have consistent data', async ({ request }) => {
|
| 89 |
-
const response = await request.get(`${BASE_URL}/api/admin/stats`
|
|
|
|
|
|
|
| 90 |
const stats = await response.json();
|
| 91 |
|
| 92 |
// New users in last 7 days should not exceed total users
|
|
@@ -110,7 +134,9 @@ test.describe('Admin Stats and Health APIs', () => {
|
|
| 110 |
test('GET /api/admin/health - should show prompt_templates table with data', async ({
|
| 111 |
request,
|
| 112 |
}) => {
|
| 113 |
-
const response = await request.get(`${BASE_URL}/api/admin/health`
|
|
|
|
|
|
|
| 114 |
const health = await response.json();
|
| 115 |
|
| 116 |
const promptTemplatesTable = health.tables.find((t: any) => t.name === 'prompt_templates');
|
|
@@ -123,8 +149,8 @@ test.describe('Admin Stats and Health APIs', () => {
|
|
| 123 |
test('Stats API totals should match Health API row counts', async ({ request }) => {
|
| 124 |
// Fetch both endpoints
|
| 125 |
const [statsRes, healthRes] = await Promise.all([
|
| 126 |
-
request.get(`${BASE_URL}/api/admin/stats`),
|
| 127 |
-
request.get(`${BASE_URL}/api/admin/health`),
|
| 128 |
]);
|
| 129 |
|
| 130 |
expect(statsRes.ok()).toBeTruthy();
|
|
|
|
| 1 |
import { test, expect } from '@playwright/test';
|
| 2 |
|
| 3 |
const BASE_URL = 'http://localhost:3000';
|
| 4 |
+
const adminPassword = process.env.ADMIN_PASSWORD || 'cz-2025-admin';
|
| 5 |
|
| 6 |
test.describe('Admin Stats and Health APIs', () => {
|
| 7 |
+
let adminAuthHeader: string;
|
| 8 |
+
let testUsername: string;
|
| 9 |
+
|
| 10 |
+
test.beforeAll(async ({ request }) => {
|
| 11 |
+
// Create a test user for admin auth
|
| 12 |
+
const timestamp = Date.now();
|
| 13 |
+
testUsername = `admin_stats_test_${timestamp}`;
|
| 14 |
+
|
| 15 |
+
const registerResponse = await request.post(`${BASE_URL}/api/auth/register`, {
|
| 16 |
+
data: { username: testUsername },
|
| 17 |
+
});
|
| 18 |
+
expect(registerResponse.ok()).toBeTruthy();
|
| 19 |
+
|
| 20 |
+
// Create Admin Auth header
|
| 21 |
+
adminAuthHeader = `Basic ${Buffer.from(`${testUsername}:${adminPassword}`).toString('base64')}`;
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
test('GET /api/admin/stats - should return system statistics', async ({ request }) => {
|
| 25 |
+
const response = await request.get(`${BASE_URL}/api/admin/stats`, {
|
| 26 |
+
headers: { Authorization: adminAuthHeader },
|
| 27 |
+
});
|
| 28 |
expect(response.ok()).toBeTruthy();
|
| 29 |
expect(response.status()).toBe(200);
|
| 30 |
|
|
|
|
| 70 |
});
|
| 71 |
|
| 72 |
test('GET /api/admin/health - should return database health metrics', async ({ request }) => {
|
| 73 |
+
const response = await request.get(`${BASE_URL}/api/admin/health`, {
|
| 74 |
+
headers: { Authorization: adminAuthHeader },
|
| 75 |
+
});
|
| 76 |
expect(response.ok()).toBeTruthy();
|
| 77 |
expect(response.status()).toBe(200);
|
| 78 |
|
|
|
|
| 108 |
});
|
| 109 |
|
| 110 |
test('GET /api/admin/stats - should have consistent data', async ({ request }) => {
|
| 111 |
+
const response = await request.get(`${BASE_URL}/api/admin/stats`, {
|
| 112 |
+
headers: { Authorization: adminAuthHeader },
|
| 113 |
+
});
|
| 114 |
const stats = await response.json();
|
| 115 |
|
| 116 |
// New users in last 7 days should not exceed total users
|
|
|
|
| 134 |
test('GET /api/admin/health - should show prompt_templates table with data', async ({
|
| 135 |
request,
|
| 136 |
}) => {
|
| 137 |
+
const response = await request.get(`${BASE_URL}/api/admin/health`, {
|
| 138 |
+
headers: { Authorization: adminAuthHeader },
|
| 139 |
+
});
|
| 140 |
const health = await response.json();
|
| 141 |
|
| 142 |
const promptTemplatesTable = health.tables.find((t: any) => t.name === 'prompt_templates');
|
|
|
|
| 149 |
test('Stats API totals should match Health API row counts', async ({ request }) => {
|
| 150 |
// Fetch both endpoints
|
| 151 |
const [statsRes, healthRes] = await Promise.all([
|
| 152 |
+
request.get(`${BASE_URL}/api/admin/stats`, { headers: { Authorization: adminAuthHeader } }),
|
| 153 |
+
request.get(`${BASE_URL}/api/admin/health`, { headers: { Authorization: adminAuthHeader } }),
|
| 154 |
]);
|
| 155 |
|
| 156 |
expect(statsRes.ok()).toBeTruthy();
|
tests/e2e/admin-users.api.spec.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
| 1 |
import { test, expect } from '@playwright/test';
|
| 2 |
|
| 3 |
const BASE_URL = 'http://localhost:3000';
|
|
|
|
| 4 |
|
| 5 |
test.describe('Admin Users APIs', () => {
|
| 6 |
let testUserId: string;
|
| 7 |
let testUsername: string;
|
|
|
|
| 8 |
|
| 9 |
test.beforeAll(async ({ request }) => {
|
| 10 |
// Create a test user
|
|
@@ -20,12 +22,17 @@ test.describe('Admin Users APIs', () => {
|
|
| 20 |
expect(response.ok()).toBeTruthy();
|
| 21 |
const data = await response.json();
|
| 22 |
testUserId = data.user.id;
|
|
|
|
|
|
|
|
|
|
| 23 |
});
|
| 24 |
|
| 25 |
test('GET /api/admin/users - should return users list with default params', async ({
|
| 26 |
request,
|
| 27 |
}) => {
|
| 28 |
-
const response = await request.get(`${BASE_URL}/api/admin/users`
|
|
|
|
|
|
|
| 29 |
expect(response.ok()).toBeTruthy();
|
| 30 |
expect(response.status()).toBe(200);
|
| 31 |
|
|
@@ -53,7 +60,9 @@ test.describe('Admin Users APIs', () => {
|
|
| 53 |
});
|
| 54 |
|
| 55 |
test('GET /api/admin/users - should validate user object structure', async ({ request }) => {
|
| 56 |
-
const response = await request.get(`${BASE_URL}/api/admin/users?limit=1`
|
|
|
|
|
|
|
| 57 |
const data = await response.json();
|
| 58 |
|
| 59 |
if (data.users.length > 0) {
|
|
@@ -86,7 +95,9 @@ test.describe('Admin Users APIs', () => {
|
|
| 86 |
});
|
| 87 |
|
| 88 |
test('GET /api/admin/users - should support search by username', async ({ request }) => {
|
| 89 |
-
const response = await request.get(`${BASE_URL}/api/admin/users?search=${testUsername}`
|
|
|
|
|
|
|
| 90 |
expect(response.ok()).toBeTruthy();
|
| 91 |
|
| 92 |
const data = await response.json();
|
|
@@ -103,7 +114,8 @@ test.describe('Admin Users APIs', () => {
|
|
| 103 |
|
| 104 |
test('GET /api/admin/users - should support sorting by username', async ({ request }) => {
|
| 105 |
const response = await request.get(
|
| 106 |
-
`${BASE_URL}/api/admin/users?sortBy=username&sortOrder=asc&limit=10`
|
|
|
|
| 107 |
);
|
| 108 |
expect(response.ok()).toBeTruthy();
|
| 109 |
|
|
@@ -118,12 +130,14 @@ test.describe('Admin Users APIs', () => {
|
|
| 118 |
|
| 119 |
test('GET /api/admin/users - should support sorting by createdAt', async ({ request }) => {
|
| 120 |
const ascResponse = await request.get(
|
| 121 |
-
`${BASE_URL}/api/admin/users?sortBy=createdAt&sortOrder=asc&limit=10`
|
|
|
|
| 122 |
);
|
| 123 |
expect(ascResponse.ok()).toBeTruthy();
|
| 124 |
|
| 125 |
const descResponse = await request.get(
|
| 126 |
-
`${BASE_URL}/api/admin/users?sortBy=createdAt&sortOrder=desc&limit=10`
|
|
|
|
| 127 |
);
|
| 128 |
expect(descResponse.ok()).toBeTruthy();
|
| 129 |
|
|
@@ -148,12 +162,16 @@ test.describe('Admin Users APIs', () => {
|
|
| 148 |
});
|
| 149 |
|
| 150 |
test('GET /api/admin/users - should support pagination', async ({ request }) => {
|
| 151 |
-
const page1Response = await request.get(`${BASE_URL}/api/admin/users?page=1&limit=5`
|
|
|
|
|
|
|
| 152 |
expect(page1Response.ok()).toBeTruthy();
|
| 153 |
const page1Data = await page1Response.json();
|
| 154 |
|
| 155 |
if (page1Data.pagination.totalPages > 1) {
|
| 156 |
-
const page2Response = await request.get(`${BASE_URL}/api/admin/users?page=2&limit=5`
|
|
|
|
|
|
|
| 157 |
expect(page2Response.ok()).toBeTruthy();
|
| 158 |
const page2Data = await page2Response.json();
|
| 159 |
|
|
@@ -169,14 +187,18 @@ test.describe('Admin Users APIs', () => {
|
|
| 169 |
|
| 170 |
test('GET /api/admin/users - should respect limit parameter', async ({ request }) => {
|
| 171 |
const limit = 3;
|
| 172 |
-
const response = await request.get(`${BASE_URL}/api/admin/users?limit=${limit}`
|
|
|
|
|
|
|
| 173 |
const data = await response.json();
|
| 174 |
|
| 175 |
expect(data.users.length).toBeLessThanOrEqual(limit);
|
| 176 |
});
|
| 177 |
|
| 178 |
test('GET /api/admin/users - should enforce max limit of 100', async ({ request }) => {
|
| 179 |
-
const response = await request.get(`${BASE_URL}/api/admin/users?limit=200`
|
|
|
|
|
|
|
| 180 |
const data = await response.json();
|
| 181 |
|
| 182 |
// Should cap at 100
|
|
@@ -184,7 +206,9 @@ test.describe('Admin Users APIs', () => {
|
|
| 184 |
});
|
| 185 |
|
| 186 |
test('GET /api/admin/users/[userId] - should return user detail', async ({ request }) => {
|
| 187 |
-
const response = await request.get(`${BASE_URL}/api/admin/users/${testUserId}`
|
|
|
|
|
|
|
| 188 |
expect(response.ok()).toBeTruthy();
|
| 189 |
expect(response.status()).toBe(200);
|
| 190 |
|
|
@@ -236,7 +260,8 @@ test.describe('Admin Users APIs', () => {
|
|
| 236 |
|
| 237 |
// Now fetch user detail
|
| 238 |
const userDetailResponse = await request.get(
|
| 239 |
-
`${BASE_URL}/api/admin/users/${userData.user.id}`
|
|
|
|
| 240 |
);
|
| 241 |
const data = await userDetailResponse.json();
|
| 242 |
|
|
@@ -271,7 +296,9 @@ test.describe('Admin Users APIs', () => {
|
|
| 271 |
request,
|
| 272 |
}) => {
|
| 273 |
const fakeUserId = '00000000-0000-0000-0000-000000000000';
|
| 274 |
-
const response = await request.get(`${BASE_URL}/api/admin/users/${fakeUserId}`
|
|
|
|
|
|
|
| 275 |
|
| 276 |
expect(response.status()).toBe(404);
|
| 277 |
|
|
@@ -282,7 +309,8 @@ test.describe('Admin Users APIs', () => {
|
|
| 282 |
|
| 283 |
test('GET /api/admin/users - should handle empty search results', async ({ request }) => {
|
| 284 |
const response = await request.get(
|
| 285 |
-
`${BASE_URL}/api/admin/users?search=nonexistentuser12345xyz`
|
|
|
|
| 286 |
);
|
| 287 |
expect(response.ok()).toBeTruthy();
|
| 288 |
|
|
|
|
| 1 |
import { test, expect } from '@playwright/test';
|
| 2 |
|
| 3 |
const BASE_URL = 'http://localhost:3000';
|
| 4 |
+
const adminPassword = process.env.ADMIN_PASSWORD || 'cz-2025-admin';
|
| 5 |
|
| 6 |
test.describe('Admin Users APIs', () => {
|
| 7 |
let testUserId: string;
|
| 8 |
let testUsername: string;
|
| 9 |
+
let adminAuthHeader: string;
|
| 10 |
|
| 11 |
test.beforeAll(async ({ request }) => {
|
| 12 |
// Create a test user
|
|
|
|
| 22 |
expect(response.ok()).toBeTruthy();
|
| 23 |
const data = await response.json();
|
| 24 |
testUserId = data.user.id;
|
| 25 |
+
|
| 26 |
+
// Create Admin Auth header (required for admin endpoints)
|
| 27 |
+
adminAuthHeader = `Basic ${Buffer.from(`${testUsername}:${adminPassword}`).toString('base64')}`;
|
| 28 |
});
|
| 29 |
|
| 30 |
test('GET /api/admin/users - should return users list with default params', async ({
|
| 31 |
request,
|
| 32 |
}) => {
|
| 33 |
+
const response = await request.get(`${BASE_URL}/api/admin/users`, {
|
| 34 |
+
headers: { Authorization: adminAuthHeader },
|
| 35 |
+
});
|
| 36 |
expect(response.ok()).toBeTruthy();
|
| 37 |
expect(response.status()).toBe(200);
|
| 38 |
|
|
|
|
| 60 |
});
|
| 61 |
|
| 62 |
test('GET /api/admin/users - should validate user object structure', async ({ request }) => {
|
| 63 |
+
const response = await request.get(`${BASE_URL}/api/admin/users?limit=1`, {
|
| 64 |
+
headers: { Authorization: adminAuthHeader },
|
| 65 |
+
});
|
| 66 |
const data = await response.json();
|
| 67 |
|
| 68 |
if (data.users.length > 0) {
|
|
|
|
| 95 |
});
|
| 96 |
|
| 97 |
test('GET /api/admin/users - should support search by username', async ({ request }) => {
|
| 98 |
+
const response = await request.get(`${BASE_URL}/api/admin/users?search=${testUsername}`, {
|
| 99 |
+
headers: { Authorization: adminAuthHeader },
|
| 100 |
+
});
|
| 101 |
expect(response.ok()).toBeTruthy();
|
| 102 |
|
| 103 |
const data = await response.json();
|
|
|
|
| 114 |
|
| 115 |
test('GET /api/admin/users - should support sorting by username', async ({ request }) => {
|
| 116 |
const response = await request.get(
|
| 117 |
+
`${BASE_URL}/api/admin/users?sortBy=username&sortOrder=asc&limit=10`,
|
| 118 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 119 |
);
|
| 120 |
expect(response.ok()).toBeTruthy();
|
| 121 |
|
|
|
|
| 130 |
|
| 131 |
test('GET /api/admin/users - should support sorting by createdAt', async ({ request }) => {
|
| 132 |
const ascResponse = await request.get(
|
| 133 |
+
`${BASE_URL}/api/admin/users?sortBy=createdAt&sortOrder=asc&limit=10`,
|
| 134 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 135 |
);
|
| 136 |
expect(ascResponse.ok()).toBeTruthy();
|
| 137 |
|
| 138 |
const descResponse = await request.get(
|
| 139 |
+
`${BASE_URL}/api/admin/users?sortBy=createdAt&sortOrder=desc&limit=10`,
|
| 140 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 141 |
);
|
| 142 |
expect(descResponse.ok()).toBeTruthy();
|
| 143 |
|
|
|
|
| 162 |
});
|
| 163 |
|
| 164 |
test('GET /api/admin/users - should support pagination', async ({ request }) => {
|
| 165 |
+
const page1Response = await request.get(`${BASE_URL}/api/admin/users?page=1&limit=5`, {
|
| 166 |
+
headers: { Authorization: adminAuthHeader },
|
| 167 |
+
});
|
| 168 |
expect(page1Response.ok()).toBeTruthy();
|
| 169 |
const page1Data = await page1Response.json();
|
| 170 |
|
| 171 |
if (page1Data.pagination.totalPages > 1) {
|
| 172 |
+
const page2Response = await request.get(`${BASE_URL}/api/admin/users?page=2&limit=5`, {
|
| 173 |
+
headers: { Authorization: adminAuthHeader },
|
| 174 |
+
});
|
| 175 |
expect(page2Response.ok()).toBeTruthy();
|
| 176 |
const page2Data = await page2Response.json();
|
| 177 |
|
|
|
|
| 187 |
|
| 188 |
test('GET /api/admin/users - should respect limit parameter', async ({ request }) => {
|
| 189 |
const limit = 3;
|
| 190 |
+
const response = await request.get(`${BASE_URL}/api/admin/users?limit=${limit}`, {
|
| 191 |
+
headers: { Authorization: adminAuthHeader },
|
| 192 |
+
});
|
| 193 |
const data = await response.json();
|
| 194 |
|
| 195 |
expect(data.users.length).toBeLessThanOrEqual(limit);
|
| 196 |
});
|
| 197 |
|
| 198 |
test('GET /api/admin/users - should enforce max limit of 100', async ({ request }) => {
|
| 199 |
+
const response = await request.get(`${BASE_URL}/api/admin/users?limit=200`, {
|
| 200 |
+
headers: { Authorization: adminAuthHeader },
|
| 201 |
+
});
|
| 202 |
const data = await response.json();
|
| 203 |
|
| 204 |
// Should cap at 100
|
|
|
|
| 206 |
});
|
| 207 |
|
| 208 |
test('GET /api/admin/users/[userId] - should return user detail', async ({ request }) => {
|
| 209 |
+
const response = await request.get(`${BASE_URL}/api/admin/users/${testUserId}`, {
|
| 210 |
+
headers: { Authorization: adminAuthHeader },
|
| 211 |
+
});
|
| 212 |
expect(response.ok()).toBeTruthy();
|
| 213 |
expect(response.status()).toBe(200);
|
| 214 |
|
|
|
|
| 260 |
|
| 261 |
// Now fetch user detail
|
| 262 |
const userDetailResponse = await request.get(
|
| 263 |
+
`${BASE_URL}/api/admin/users/${userData.user.id}`,
|
| 264 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 265 |
);
|
| 266 |
const data = await userDetailResponse.json();
|
| 267 |
|
|
|
|
| 296 |
request,
|
| 297 |
}) => {
|
| 298 |
const fakeUserId = '00000000-0000-0000-0000-000000000000';
|
| 299 |
+
const response = await request.get(`${BASE_URL}/api/admin/users/${fakeUserId}`, {
|
| 300 |
+
headers: { Authorization: adminAuthHeader },
|
| 301 |
+
});
|
| 302 |
|
| 303 |
expect(response.status()).toBe(404);
|
| 304 |
|
|
|
|
| 309 |
|
| 310 |
test('GET /api/admin/users - should handle empty search results', async ({ request }) => {
|
| 311 |
const response = await request.get(
|
| 312 |
+
`${BASE_URL}/api/admin/users?search=nonexistentuser12345xyz`,
|
| 313 |
+
{ headers: { Authorization: adminAuthHeader } }
|
| 314 |
);
|
| 315 |
expect(response.ok()).toBeTruthy();
|
| 316 |
|