diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..641e491 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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 + ' diff --git a/App.test.tsx b/App.test.tsx deleted file mode 100644 index 414f0a3..0000000 --- a/App.test.tsx +++ /dev/null @@ -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(); - // ACT - // EXPECT - expect( - screen.getByRole('heading', { - level: 1, - } - )).toHaveTextContent('Hello World') - }); - it('Renders not found if invalid path', () => { - // ARRANGE - render( - - - - ); - // ACT - // EXPECT - expect( - screen.getByRole('heading', { - level: 1, - } - )).toHaveTextContent('Not Found') - }); -}); \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index b9b603e..74c4361 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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 diff --git a/package.json b/package.json index c6fa9af..7561261 100644 --- a/package.json +++ b/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/src/AppRoutes.admin.test.tsx b/src/AppRoutes.admin.test.tsx new file mode 100644 index 0000000..96572a0 --- /dev/null +++ b/src/AppRoutes.admin.test.tsx @@ -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 }) =>
{children}
, +})); +vi.mock('./pages/auth/PlatformAdminPage', () => ({ default: () =>
Platform Admin Page
})); +vi.mock('./pages/auth/adminPage', () => ({ default: () =>
Legacy Admin
})); +vi.mock('./pages/auth/loginPage', () => ({ default: () =>
Login Page
})); +vi.mock('./pages/auth/signupPage', () => ({ default: () =>
Signup Page
})); +vi.mock('./pages/user/dashboardPage', () => ({ default: () =>
Dashboard Page
})); +vi.mock('./pages/user/NotFound', () => ({ default: () =>
Private Not Found
})); +vi.mock('./pages/NotFoundPublic', () => ({ default: () =>
Public Not Found
})); +vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () =>
Public Home
})); +vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () =>
Single Player
})); +vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () =>
Multiplayer
})); +vi.mock('./pages/tldraw/CCExamMarker/CCExamMarker', () => ({ CCExamMarker: () =>
Exam Marker
})); +vi.mock('./pages/user/calendarPage', () => ({ default: () =>
Calendar
})); +vi.mock('./pages/user/settingsPage', () => ({ default: () =>
Settings
})); +vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () =>
TLDraw Dev
})); +vi.mock('./pages/tldraw/devPage', () => ({ default: () =>
Dev
})); +vi.mock('./pages/react-flow/teacherPlanner', () => ({ default: () =>
Teacher Planner
})); +vi.mock('./pages/morphicPage', () => ({ default: () =>
Morphic
})); +vi.mock('./pages/tldraw/ShareHandler', () => ({ default: () =>
Share
})); +vi.mock('./pages/searxngPage', () => ({ default: () =>
Search
})); +vi.mock('./pages/dev/SimpleUploadTest', () => ({ default: () =>
Upload Test
})); +vi.mock('./pages/tldraw/CCDocumentIntelligence/CCDocumentIntelligence', () => ({ + CCDocumentIntelligence: () =>
Doc Intelligence
, +})); +vi.mock('./pages/timetable', () => ({ + TimetablePage: () =>
Timetable
, + ClassesPage: () =>
Classes
, + LessonPage: () =>
Lesson
, + TaughtLessonsPage: () =>
Taught Lessons
, + MyClassesPage: () =>
My Classes
, + EnrollmentRequestsPage: () =>
Enrollment Requests
, + StaffManagerPage: () =>
Staff Manager
, + StudentManagerPage: () =>
Student Manager
, + SchoolSettingsPage: () =>
School Settings
, + ClassDetailPage: () =>
Class Detail
, + StudentLessonsPage: () =>
Student Lessons
, + LessonPlansPage: () =>
Lesson Plans
, + LessonPlanDetailPage: () =>
Lesson Plan Detail
, +})); + +function renderAt(path: string) { + return render( + + + + ); +} + +function authState(overrides: Record) { + 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(); + }); +}); diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index 6dbf04a..85665b7 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -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 = () => { + !user ? ( + + ) : isPlatformAdmin ? ( + + ) : ( + + ) } /> diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..836bb7b --- /dev/null +++ b/vitest.config.ts @@ -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'), + }, + }, +});