test: add admin route guard coverage
Some checks failed
app-ci-deploy / test-build-deploy (push) Has been cancelled
Some checks failed
app-ci-deploy / test-build-deploy (push) Has been cancelled
This commit is contained in:
parent
e68eef8865
commit
0db53bfd9c
39
.gitea/workflows/deploy.yml
Normal file
39
.gitea/workflows/deploy.yml
Normal 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
|
||||||
|
'
|
||||||
33
App.test.tsx
33
App.test.tsx
@ -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')
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,4 +1,14 @@
|
|||||||
services:
|
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:
|
frontend-dev:
|
||||||
container_name: cc-app-dev
|
container_name: cc-app-dev
|
||||||
image: cc-app-dev:latest
|
image: cc-app-dev:latest
|
||||||
@ -15,6 +25,9 @@ services:
|
|||||||
- kevlarai-network
|
- kevlarai-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
app-test-node-modules:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
kevlarai-network:
|
kevlarai-network:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@ -5,7 +5,9 @@
|
|||||||
"start": "vite --host",
|
"start": "vite --host",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
"build-storybook": "storybook build"
|
"build-storybook": "storybook build",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dagrejs/dagre": "^1.1.4",
|
"@dagrejs/dagre": "^1.1.4",
|
||||||
@ -87,4 +89,4 @@
|
|||||||
"vite-plugin-pwa": "^0.21.1",
|
"vite-plugin-pwa": "^0.21.1",
|
||||||
"vitest": "^1.6.0"
|
"vitest": "^1.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
123
src/AppRoutes.admin.test.tsx
Normal file
123
src/AppRoutes.admin.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -69,8 +69,10 @@ const FullContextRoutes: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AppRoutes: React.FC = () => {
|
const AppRoutes: React.FC = () => {
|
||||||
const { user, loading: isAuthLoading } = useAuth();
|
const { user, user_role, loading: isAuthLoading } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const platformAdminRoles = ['super_admin', 'cc_admin', 'cc_developer'];
|
||||||
|
const isPlatformAdmin = !!user && platformAdminRoles.includes(user_role ?? '');
|
||||||
|
|
||||||
// Debug log for routing
|
// Debug log for routing
|
||||||
logger.debug('routing', '🔄 Rendering routes', {
|
logger.debug('routing', '🔄 Rendering routes', {
|
||||||
@ -123,7 +125,13 @@ const AppRoutes: React.FC = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="/admin"
|
path="/admin"
|
||||||
element={
|
element={
|
||||||
<PlatformAdminPage />
|
!user ? (
|
||||||
|
<Navigate to="/login" replace state={{ from: location }} />
|
||||||
|
) : isPlatformAdmin ? (
|
||||||
|
<PlatformAdminPage />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/dashboard" replace />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
17
vitest.config.ts
Normal file
17
vitest.config.ts
Normal 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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user