test: add admin route guard coverage
Some checks failed
app-ci-deploy / test-build-deploy (push) Has been cancelled

This commit is contained in:
kcar 2026-05-27 23:24:26 +01:00
parent e68eef8865
commit 0db53bfd9c
7 changed files with 206 additions and 37 deletions

View File

@ -0,0 +1,39 @@
name: app-ci-deploy
on:
push:
branches: [master]
workflow_dispatch:
jobs:
test-build-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npm run test:run
- run: npm run build
- name: Configure SSH
run: |
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s\n' "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Deploy app
run: |
ssh -i ~/.ssh/deploy_key "${{ secrets.DEPLOY_USER }}@${{ secrets.APP_DEPLOY_HOST }}" '
set -euo pipefail
cd /home/kcar/app
git fetch origin master
git reset --hard origin/master
docker network inspect kevlarai-network >/dev/null 2>&1 || docker network create kevlarai-network
docker compose -p app-prod -f docker-compose.yml up -d --build
docker compose -p app-prod -f docker-compose.yml ps
curl -fsSI http://127.0.0.1:3000 >/dev/null
'

View File

@ -1,33 +0,0 @@
import { describe, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { WrappedApp, App } from '../src/App';
describe('App', () => {
it('Renders hello world', () => {
// ARRANGE
render(<WrappedApp />);
// ACT
// EXPECT
expect(
screen.getByRole('heading', {
level: 1,
}
)).toHaveTextContent('Hello World')
});
it('Renders not found if invalid path', () => {
// ARRANGE
render(
<MemoryRouter initialEntries={['/this-route-does-not-exist']}>
<App />
</MemoryRouter>
);
// ACT
// EXPECT
expect(
screen.getByRole('heading', {
level: 1,
}
)).toHaveTextContent('Not Found')
});
});

View File

@ -1,4 +1,14 @@
services:
frontend-test:
image: node:20-bookworm
working_dir: /app
volumes:
- .:/app
- app-test-node-modules:/app/node_modules
command: sh -lc "npm ci && npm run test:run"
profiles:
- test
frontend-dev:
container_name: cc-app-dev
image: cc-app-dev:latest
@ -15,6 +25,9 @@ services:
- kevlarai-network
restart: unless-stopped
volumes:
app-test-node-modules:
networks:
kevlarai-network:
external: true

View File

@ -5,7 +5,9 @@
"start": "vite --host",
"build": "vite build",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
"build-storybook": "storybook build",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"@dagrejs/dagre": "^1.1.4",
@ -87,4 +89,4 @@
"vite-plugin-pwa": "^0.21.1",
"vitest": "^1.6.0"
}
}
}

View File

@ -0,0 +1,123 @@
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import AppRoutes from './AppRoutes';
const mockUseAuth = vi.fn();
vi.mock('./contexts/AuthContext', () => ({
useAuth: () => mockUseAuth(),
}));
vi.mock('./contexts/UserContext', () => ({
useUser: () => ({ isInitialized: true }),
}));
vi.mock('./debugConfig', () => ({
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
vi.mock('./pages/Layout', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('./pages/auth/PlatformAdminPage', () => ({ default: () => <div>Platform Admin Page</div> }));
vi.mock('./pages/auth/adminPage', () => ({ default: () => <div>Legacy Admin</div> }));
vi.mock('./pages/auth/loginPage', () => ({ default: () => <div>Login Page</div> }));
vi.mock('./pages/auth/signupPage', () => ({ default: () => <div>Signup Page</div> }));
vi.mock('./pages/user/dashboardPage', () => ({ default: () => <div>Dashboard Page</div> }));
vi.mock('./pages/user/NotFound', () => ({ default: () => <div>Private Not Found</div> }));
vi.mock('./pages/NotFoundPublic', () => ({ default: () => <div>Public Not Found</div> }));
vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => <div>Public Home</div> }));
vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => <div>Single Player</div> }));
vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplayer</div> }));
vi.mock('./pages/tldraw/CCExamMarker/CCExamMarker', () => ({ CCExamMarker: () => <div>Exam Marker</div> }));
vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> }));
vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> }));
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> }));
vi.mock('./pages/tldraw/devPage', () => ({ default: () => <div>Dev</div> }));
vi.mock('./pages/react-flow/teacherPlanner', () => ({ default: () => <div>Teacher Planner</div> }));
vi.mock('./pages/morphicPage', () => ({ default: () => <div>Morphic</div> }));
vi.mock('./pages/tldraw/ShareHandler', () => ({ default: () => <div>Share</div> }));
vi.mock('./pages/searxngPage', () => ({ default: () => <div>Search</div> }));
vi.mock('./pages/dev/SimpleUploadTest', () => ({ default: () => <div>Upload Test</div> }));
vi.mock('./pages/tldraw/CCDocumentIntelligence/CCDocumentIntelligence', () => ({
CCDocumentIntelligence: () => <div>Doc Intelligence</div>,
}));
vi.mock('./pages/timetable', () => ({
TimetablePage: () => <div>Timetable</div>,
ClassesPage: () => <div>Classes</div>,
LessonPage: () => <div>Lesson</div>,
TaughtLessonsPage: () => <div>Taught Lessons</div>,
MyClassesPage: () => <div>My Classes</div>,
EnrollmentRequestsPage: () => <div>Enrollment Requests</div>,
StaffManagerPage: () => <div>Staff Manager</div>,
StudentManagerPage: () => <div>Student Manager</div>,
SchoolSettingsPage: () => <div>School Settings</div>,
ClassDetailPage: () => <div>Class Detail</div>,
StudentLessonsPage: () => <div>Student Lessons</div>,
LessonPlansPage: () => <div>Lesson Plans</div>,
LessonPlanDetailPage: () => <div>Lesson Plan Detail</div>,
}));
function renderAt(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<AppRoutes />
</MemoryRouter>
);
}
function authState(overrides: Record<string, unknown>) {
return {
user: null,
user_role: null,
accessToken: null,
loading: false,
error: null,
signIn: vi.fn(),
signOut: vi.fn(),
clearError: vi.fn(),
...overrides,
};
}
describe('/admin route authorization', () => {
beforeEach(() => {
mockUseAuth.mockReset();
});
it('redirects anonymous users away from /admin', () => {
mockUseAuth.mockReturnValue(authState({ user: null, user_role: null }));
renderAt('/admin');
expect(screen.queryByText('Platform Admin Page')).not.toBeInTheDocument();
expect(screen.getByText('Login Page')).toBeInTheDocument();
});
it('redirects authenticated non-admin users away from /admin', () => {
mockUseAuth.mockReturnValue(authState({
user: { id: 'user-1', email: 'teacher@example.com', user_type: 'email_teacher' },
user_role: 'email_teacher',
accessToken: 'token',
}));
renderAt('/admin');
expect(screen.queryByText('Platform Admin Page')).not.toBeInTheDocument();
expect(screen.getByText('Dashboard Page')).toBeInTheDocument();
});
it('allows super_admin users to access /admin', () => {
mockUseAuth.mockReturnValue(authState({
user: { id: 'admin-1', email: 'admin@example.com', user_type: 'super_admin' },
user_role: 'super_admin',
accessToken: 'token',
}));
renderAt('/admin');
expect(screen.getByText('Platform Admin Page')).toBeInTheDocument();
});
});

View File

@ -69,8 +69,10 @@ const FullContextRoutes: React.FC = () => {
};
const AppRoutes: React.FC = () => {
const { user, loading: isAuthLoading } = useAuth();
const { user, user_role, loading: isAuthLoading } = useAuth();
const location = useLocation();
const platformAdminRoles = ['super_admin', 'cc_admin', 'cc_developer'];
const isPlatformAdmin = !!user && platformAdminRoles.includes(user_role ?? '');
// Debug log for routing
logger.debug('routing', '🔄 Rendering routes', {
@ -123,7 +125,13 @@ const AppRoutes: React.FC = () => {
<Route
path="/admin"
element={
<PlatformAdminPage />
!user ? (
<Navigate to="/login" replace state={{ from: location }} />
) : isPlatformAdmin ? (
<PlatformAdminPage />
) : (
<Navigate to="/dashboard" replace />
)
}
/>

17
vitest.config.ts Normal file
View File

@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/setupTests.ts'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});