diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85b3788..73ae52b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, dev] pull_request: - branches: [main] + branches: [main, dev] jobs: backend-lint: @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version: '3.12' - run: pip install ruff==0.1.9 - run: ruff check . @@ -25,11 +25,14 @@ jobs: defaults: run: working-directory: backend + env: + DEBUG: 'True' + SECRET_KEY: 'ci-test-secret-key' steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version: '3.12' - run: pip install -r requirements.txt - run: mypy apps/ --ignore-missing-imports @@ -64,7 +67,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version: '3.12' - run: pip install -r requirements.txt - run: python manage.py migrate - run: pytest --cov=apps --cov-report=term-missing --cov-fail-under=60 -q @@ -115,6 +118,9 @@ jobs: run: docker compose up -d env: OPENAI_API_KEY: 'test-key-not-real' + # One runner address drives the whole suite; the production default + # (60/minute) throttles legitimate test traffic. CI-only value. + THROTTLE_ANON_RATE: '1000/minute' - name: Wait for backend health run: | timeout 90 bash -c 'until curl -sf http://localhost:8011/api/health/ > /dev/null 2>&1; do sleep 3; done' diff --git a/backend/apps/voice_notes/serializers.py b/backend/apps/voice_notes/serializers.py index 4edd678..45e6dd5 100644 --- a/backend/apps/voice_notes/serializers.py +++ b/backend/apps/voice_notes/serializers.py @@ -33,13 +33,25 @@ def get_queryset(self): class FolderSerializer(serializers.ModelSerializer): - parent = UserScopedFolderField(allow_null=True, required=False) - class Meta: model = Folder fields = ('id', 'name', 'color', 'parent', 'created_at') read_only_fields = ('id', 'created_at') + def get_fields(self): + """Replace the auto-built ``parent`` field with the user-scoped one. + + ``parent`` cannot be declared as a class attribute the way the other + scoped fields are: DRF's ``Field.parent`` already owns that name on the + base class, so a class-level assignment reads as an override of an + unrelated attribute even though the serializer metaclass moves declared + fields out of the class namespace. Installing the field here yields the + identical field map without the name collision. + """ + fields = super().get_fields() + fields['parent'] = UserScopedFolderField(allow_null=True, required=False) + return fields + def validate_parent(self, value): # Enforce a single level of nesting: a parent cannot itself be nested. if value is not None and value.parent_id is not None: @@ -87,7 +99,7 @@ class VoiceNoteListSerializer(serializers.ModelSerializer): audio_url = serializers.SerializerMethodField() transcription_text = serializers.CharField(source='transcription', read_only=True) transcription_confidence = serializers.FloatField(source='confidence_score', read_only=True) - folder = serializers.PrimaryKeyRelatedField(read_only=True) + folder: 'serializers.PrimaryKeyRelatedField[Folder]' = serializers.PrimaryKeyRelatedField(read_only=True) folder_name = serializers.CharField(source='folder.name', read_only=True, default=None) class Meta: diff --git a/backend/config/urls.py b/backend/config/urls.py index 52b90fa..049ec57 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -7,7 +7,7 @@ SpectacularRedocView, SpectacularSwaggerView, ) -from rest_framework.decorators import api_view, permission_classes +from rest_framework.decorators import api_view, permission_classes, throttle_classes from rest_framework.permissions import AllowAny from rest_framework.response import Response @@ -16,7 +16,11 @@ @api_view(['GET']) @permission_classes([AllowAny]) +@throttle_classes([]) def health_check(request): + """Liveness probe. Exempt from the anonymous throttle: uptime checks and the + e2e suite hit it alongside unauthenticated traffic, and a 429 here reads as an + outage.""" return Response({'status': 'ok'}) diff --git a/backend/mypy.ini b/backend/mypy.ini index 722cf93..614b750 100644 --- a/backend/mypy.ini +++ b/backend/mypy.ini @@ -1,5 +1,5 @@ [mypy] -python_version = 3.9 +python_version = 3.12 plugins = mypy_django_plugin.main, mypy_drf_plugin.main diff --git a/backend/tests/test_folders.py b/backend/tests/test_folders.py index f8beb66..122d40e 100644 --- a/backend/tests/test_folders.py +++ b/backend/tests/test_folders.py @@ -59,6 +59,15 @@ def test_cannot_parent_to_another_users_folder(self, api_client, user, user_b): resp = api_client.post('/api/folders/', {'name': 'Mine', 'parent': foreign.id}) assert resp.status_code == status.HTTP_400_BAD_REQUEST + def test_cannot_reparent_to_another_users_folder_on_update(self, api_client, user, user_b): + mine = Folder.objects.create(user=user, name='Mine') + foreign = Folder.objects.create(user=user_b, name='Foreign') + _auth(api_client, user) + resp = api_client.patch(f'/api/folders/{mine.id}/', {'parent': foreign.id}) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + mine.refresh_from_db() + assert mine.parent is None + @pytest.mark.django_db class TestNoteFolderAssignment: diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..0b0a8d9 --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,29 @@ +"""The liveness probe must answer 200 regardless of the anonymous throttle. + +conftest.py switches throttling off for the whole suite, so this test puts the +production default back on the base view class and tightens the anon rate to +one request per hour. A control request proves the throttle is really biting; +the health probe must still answer 200 every time. +""" +import pytest +from django.core.cache import cache +from rest_framework.test import APIClient +from rest_framework.throttling import AnonRateThrottle +from rest_framework.views import APIView + + +@pytest.mark.django_db +def test_health_is_not_throttled(monkeypatch): + monkeypatch.setattr(APIView, 'throttle_classes', [AnonRateThrottle]) + monkeypatch.setattr(AnonRateThrottle, 'THROTTLE_RATES', {'anon': '1/hour'}) + cache.clear() + client = APIClient() + + # Control: an ordinary anonymous endpoint is throttled on the second call. + client.post('/api/auth/register/', {}) + assert client.post('/api/auth/register/', {}).status_code == 429 + + for _ in range(3): + response = client.get('/api/health/') + assert response.status_code == 200 + assert response.json() == {'status': 'ok'} diff --git a/docker-compose.yml b/docker-compose.yml index 672ab14..0973842 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,6 +49,9 @@ services: - DEEPGRAM_MODEL=${DEEPGRAM_MODEL:-nova-3} - ALLOWED_HOSTS=localhost,127.0.0.1,backend - REDIS_URL=redis://redis:6379/0 + # Anonymous throttle (settings.py default 60/minute). CI raises it: the + # e2e suite sends all its anonymous traffic from one address in one minute. + - THROTTLE_ANON_RATE=${THROTTLE_ANON_RATE:-60/minute} - PYTHONDONTWRITEBYTECODE=1 volumes: - ./backend:/app @@ -118,6 +121,11 @@ services: condition: service_healthy redis: condition: service_healthy + # Start after the backend so the two services do not race to populate the + # shared media_files volume (Docker copies /app/media from whichever image + # mounts the empty volume first; two at once fail with "file exists"). + backend: + condition: service_healthy volumes: postgres_data: diff --git a/frontend/e2e/tests/comprehensive-harness.spec.js b/frontend/e2e/tests/comprehensive-harness.spec.js index d7322b3..2ac19b8 100644 --- a/frontend/e2e/tests/comprehensive-harness.spec.js +++ b/frontend/e2e/tests/comprehensive-harness.spec.js @@ -100,7 +100,7 @@ test.describe('Authenticated', () => { }); test('page loads with header and recorder', async ({ page }) => { - await expect(page.locator('h1:has-text("Record Voice Note")')).toBeVisible(); + await expect(page.locator('h1:has-text("New Voice Note")')).toBeVisible(); await expect(page.locator('text=Ready to record')).toBeVisible(); }); diff --git a/frontend/playwright.config.js b/frontend/playwright.config.js index 4a4a7a2..0d91895 100644 --- a/frontend/playwright.config.js +++ b/frontend/playwright.config.js @@ -2,6 +2,13 @@ const { defineConfig, devices } = require('@playwright/test'); module.exports = defineConfig({ testDir: './e2e/tests', + // production-sweep.spec.js drives the live production deployment + // (https://clio.chadacus.dev) and registers real accounts on it. testIgnore + // removes it at discovery, so this config cannot run it at all, not even when + // the file is named on the command line. That is deliberate: no env var can + // turn it back on in CI. To run it by hand, point Playwright at a separate + // config (`npx playwright test -c `) that does not ignore it. + testIgnore: /production-sweep\.spec\.js/, fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, diff --git a/frontend/src/components/AudioRecorder/MicrophonePermission.tsx b/frontend/src/components/AudioRecorder/MicrophonePermission.tsx index baa8b72..0d63e33 100644 --- a/frontend/src/components/AudioRecorder/MicrophonePermission.tsx +++ b/frontend/src/components/AudioRecorder/MicrophonePermission.tsx @@ -1,8 +1,7 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { MicrophoneIcon, NoSymbolIcon, - ExclamationTriangleIcon, CheckCircleIcon, InformationCircleIcon } from '@heroicons/react/24/outline'; @@ -24,11 +23,12 @@ const MicrophonePermission: React.FC = ({ const [isRequesting, setIsRequesting] = useState(false); const [showHelp, setShowHelp] = useState(false); + const onPermissionChangeRef = useRef(onPermissionChange); useEffect(() => { - checkPermissionStatus(); - }, []); + onPermissionChangeRef.current = onPermissionChange; + }, [onPermissionChange]); - const checkPermissionStatus = async () => { + const checkPermissionStatus = useCallback(async () => { setPermissionState('checking'); try { @@ -40,24 +40,28 @@ const MicrophonePermission: React.FC = ({ const state = permissionStatus.state as PermissionState; setPermissionState(state); - onPermissionChange(state === 'granted'); + onPermissionChangeRef.current(state === 'granted'); // Listen for permission changes permissionStatus.onchange = () => { const newState = permissionStatus.state as PermissionState; setPermissionState(newState); - onPermissionChange(newState === 'granted'); + onPermissionChangeRef.current(newState === 'granted'); }; } else { // Fallback: assume we need to prompt setPermissionState('prompt'); - onPermissionChange(false); + onPermissionChangeRef.current(false); } } catch (_error) { setPermissionState('prompt'); - onPermissionChange(false); + onPermissionChangeRef.current(false); } - }; + }, []); + + useEffect(() => { + checkPermissionStatus(); + }, [checkPermissionStatus]); const requestPermission = async () => { setIsRequesting(true); diff --git a/frontend/src/components/AudioRecorder/RecorderControls.tsx b/frontend/src/components/AudioRecorder/RecorderControls.tsx index 0b5b376..5b1a957 100644 --- a/frontend/src/components/AudioRecorder/RecorderControls.tsx +++ b/frontend/src/components/AudioRecorder/RecorderControls.tsx @@ -8,7 +8,6 @@ import { } from '@heroicons/react/24/outline'; import { useAudioRecorder } from '../../hooks/useAudioRecorder'; import WaveformDisplay from './WaveformDisplay'; -import PerformanceIndicator from '../Performance/PerformanceIndicator'; import toast from 'react-hot-toast'; interface RecorderControlsProps { @@ -17,7 +16,6 @@ interface RecorderControlsProps { onRecordingStop?: () => void; disabled?: boolean; className?: string; - showPerformanceIndicator?: boolean; } const RecorderControls: React.FC = ({ @@ -26,7 +24,6 @@ const RecorderControls: React.FC = ({ onRecordingStop, disabled = false, className = '', - showPerformanceIndicator = true, }) => { const [hasPermission, setHasPermission] = useState(null); @@ -43,11 +40,13 @@ const RecorderControls: React.FC = ({ enablePerformanceManagement: true, }); + const { checkMicrophonePermission } = recorder; + // Check microphone permission on component mount useEffect(() => { const checkInitialPermission = async () => { try { - const permission = await recorder.checkMicrophonePermission(); + const permission = await checkMicrophonePermission(); setHasPermission(permission); } catch (error) { console.error('Error checking microphone permission:', error); @@ -56,7 +55,7 @@ const RecorderControls: React.FC = ({ }; checkInitialPermission(); - }, [recorder.checkMicrophonePermission]); + }, [checkMicrophonePermission]); const handleStartRecording = async () => { try { diff --git a/frontend/src/components/Layout/Layout.tsx b/frontend/src/components/Layout/Layout.tsx index d2b65b7..faef957 100644 --- a/frontend/src/components/Layout/Layout.tsx +++ b/frontend/src/components/Layout/Layout.tsx @@ -3,7 +3,6 @@ import { Link, useLocation, useNavigate } from 'react-router-dom'; import { HomeIcon, MicrophoneIcon, - DocumentTextIcon, UserIcon, Bars3Icon, XMarkIcon, diff --git a/frontend/src/components/Performance/PerformanceIndicator.tsx b/frontend/src/components/Performance/PerformanceIndicator.tsx index c94aff1..861e945 100644 --- a/frontend/src/components/Performance/PerformanceIndicator.tsx +++ b/frontend/src/components/Performance/PerformanceIndicator.tsx @@ -28,7 +28,6 @@ const PerformanceIndicator: React.FC = ({ qualitySettings, performanceStatus, getQualityDescription, - getPerformanceStatusColor, getPerformanceStatusText, getRecommendedSettings } = performanceManager; diff --git a/frontend/src/hooks/useAudioRecorder.ts b/frontend/src/hooks/useAudioRecorder.ts index bd81653..acf5398 100644 --- a/frontend/src/hooks/useAudioRecorder.ts +++ b/frontend/src/hooks/useAudioRecorder.ts @@ -19,7 +19,8 @@ export const useAudioRecorder = (options: UseAudioRecorderOptions = {}) => { onDataAvailable, onRecordingComplete, onError, - enablePerformanceManagement = true + // enablePerformanceManagement is intentionally not destructured: the + // performance-manager block below is disabled, so nothing reads it yet. } = options; // Performance management DISABLED - was causing MediaRecorder interference @@ -49,7 +50,6 @@ export const useAudioRecorder = (options: UseAudioRecorderOptions = {}) => { const audioContextRef = useRef(null); const animationFrameRef = useRef(null); const audioLevelUpdateRef = useRef(null); - const lastAudioLevelUpdate = useRef(0); // Audio quality settings - using defaults since Performance Manager is disabled const getEffectiveSampleRate = useCallback(() => { @@ -64,10 +64,6 @@ export const useAudioRecorder = (options: UseAudioRecorderOptions = {}) => { return true; // Always enable visualization }, []); - const getAudioAnalysisInterval = useCallback(() => { - return 16; // 60fps default - }, []); - const shouldEnableDebugLogging = useCallback(() => { return false; // Debug logging disabled }, []); @@ -166,16 +162,6 @@ export const useAudioRecorder = (options: UseAudioRecorderOptions = {}) => { {} ]; - let mediaRecorderOptions: MediaRecorderOptions = {}; - - // Find the first supported configuration - for (const config of fallbackConfigs) { - if (!config.mimeType || MediaRecorder.isTypeSupported(config.mimeType)) { - mediaRecorderOptions = { ...config }; - break; - } - } - let mediaRecorder!: MediaRecorder; let creationSuccess = false; let lastError: any = null; @@ -234,7 +220,7 @@ export const useAudioRecorder = (options: UseAudioRecorderOptions = {}) => { if (audioBlob.size === 0) { console.error('[useAudioRecorder] Created empty audio blob!', { chunksAvailable: chunksRef.current.length, - recordingDuration: state.recordingTime + recordingDuration: recordingTimeRef.current }); onError?.(new Error('Recording failed: Empty audio data')); return; @@ -340,7 +326,7 @@ export const useAudioRecorder = (options: UseAudioRecorderOptions = {}) => { if (shouldEnableVisualization() && analyzerRef.current) { // Add small delay to ensure MediaRecorder is fully started setTimeout(() => { - updateAudioLevel(); + updateAudioLevelRef.current(); }, 200); // 200ms delay to ensure proper coordination } @@ -384,9 +370,9 @@ export const useAudioRecorder = (options: UseAudioRecorderOptions = {}) => { onError?.(audioError); } - }, [mimeType, sampleRate, onDataAvailable, onRecordingComplete, onError, - getEffectiveSampleRate, getEffectiveMimeType, shouldEnableVisualization, - shouldEnableDebugLogging]); // Removed performanceManager and currentQualitySettings + }, [onDataAvailable, onRecordingComplete, onError, + getEffectiveSampleRate, getEffectiveMimeType, + shouldEnableVisualization]); // Removed performanceManager and currentQualitySettings const stopRecording = useCallback(() => { if (state.mediaRecorder && state.isRecording) { @@ -471,10 +457,15 @@ export const useAudioRecorder = (options: UseAudioRecorderOptions = {}) => { // Track recording state in a ref so the audio loop can read it without stale closures const isRecordingRef = useRef(false); const isPausedRef = useRef(false); + const recordingTimeRef = useRef(0); + // Lets startRecording, which is defined above updateAudioLevel, reach the + // current implementation without depending on it. + const updateAudioLevelRef = useRef<() => void>(() => {}); // Keep refs in sync isRecordingRef.current = state.isRecording; isPausedRef.current = state.isPaused; + recordingTimeRef.current = state.recordingTime; const updateAudioLevel = useCallback(() => { const analyzer = analyzerRef.current; @@ -517,6 +508,8 @@ export const useAudioRecorder = (options: UseAudioRecorderOptions = {}) => { animationFrameRef.current = requestAnimationFrame(updateAudioLevel); }, []); + updateAudioLevelRef.current = updateAudioLevel; + const getVisualizationData = useCallback((): AudioVisualizationData | null => { if (!analyzerRef.current) return null; diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 6b11955..3d6e2d7 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,6 +1,5 @@ import React, { useState } from 'react'; import { Link } from 'react-router-dom'; -import { MicrophoneIcon } from '@heroicons/react/24/outline'; import { useAuth } from '../hooks/useAuth'; import LoadingSpinner from '../components/Common/LoadingSpinner'; diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx index 5be137d..95c5bf2 100644 --- a/frontend/src/pages/RegisterPage.tsx +++ b/frontend/src/pages/RegisterPage.tsx @@ -1,6 +1,5 @@ import React, { useState } from 'react'; import { Link } from 'react-router-dom'; -import { MicrophoneIcon } from '@heroicons/react/24/outline'; import { useAuth } from '../hooks/useAuth'; import LoadingSpinner from '../components/Common/LoadingSpinner'; @@ -188,14 +187,7 @@ const RegisterPage: React.FC = () => {

- By creating an account, you agree to our{' '} - - Terms of Service - {' '} - and{' '} - - Privacy Policy - . + By creating an account, you agree to our Terms of Service and Privacy Policy.

diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index fd614c6..d630906 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -22,8 +22,12 @@ api.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config; + // The refresh call itself must not trigger another refresh: its 401 means + // there is no session, and re-entering here would loop forever (the loop + // was only ever cut short by the anon throttle answering 429). + const isRefreshCall = typeof originalRequest?.url === 'string' && originalRequest.url.includes('/auth/refresh/'); - if (error.response?.status === 401 && !originalRequest._retry) { + if (error.response?.status === 401 && !originalRequest._retry && !isRefreshCall) { originalRequest._retry = true; // Don't attempt refresh if we're already on the login/register page diff --git a/frontend/src/types/speech.d.ts b/frontend/src/types/speech.d.ts index ba79630..edd1845 100644 --- a/frontend/src/types/speech.d.ts +++ b/frontend/src/types/speech.d.ts @@ -42,17 +42,12 @@ interface SpeechRecognitionAlternative { readonly confidence: number; } -interface Window { - SpeechRecognition: typeof SpeechRecognition; - webkitSpeechRecognition: typeof SpeechRecognition; -} - -declare var SpeechRecognition: { +interface SpeechRecognitionConstructor { prototype: SpeechRecognition; new (): SpeechRecognition; -}; +} -declare var webkitSpeechRecognition: { - prototype: SpeechRecognition; - new (): SpeechRecognition; -}; \ No newline at end of file +interface Window { + SpeechRecognition: SpeechRecognitionConstructor; + webkitSpeechRecognition: SpeechRecognitionConstructor; +} \ No newline at end of file diff --git a/frontend/src/utils/PerformanceManager.ts b/frontend/src/utils/PerformanceManager.ts index 9e43b1e..1b28c96 100644 --- a/frontend/src/utils/PerformanceManager.ts +++ b/frontend/src/utils/PerformanceManager.ts @@ -96,6 +96,11 @@ export class PerformanceManager { result += Math.sqrt(i) * Math.sin(i / 1000); } + // Read the accumulator so the timed loop cannot be optimised away. + if (!Number.isFinite(result)) { + console.warn('[PerformanceManager] CPU benchmark produced a non-finite result'); + } + const cpuTime = performance.now() - startTime; // Canvas benchmark: Drawing operations @@ -266,7 +271,6 @@ export class PerformanceManager { * Downgrade quality settings to improve performance */ private downgradeQuality(): void { - const currentTier = this.getCurrentTier(); let newSettings = { ...this.qualitySettings }; // Progressive degradation steps