diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..d7a325e --- /dev/null +++ b/.coveragerc @@ -0,0 +1,60 @@ +# Coverage.py Configuration for NordaBiz +# ======================================= + +[run] +# Source directories to measure +source = . + +# Omit these from coverage +omit = + tests/* + venv/* + .venv/* + scripts/* + */migrations/* + */__pycache__/* + .auto-claude/* + setup.py + +# Branch coverage +branch = True + +# Parallel mode for multi-process +parallel = True + +[report] +# Minimum coverage percentage +fail_under = 80 + +# Exclude these lines from coverage +exclude_lines = + # Have to re-enable the standard pragma + pragma: no cover + + # Don't complain about missing debug-only code + def __repr__ + if self\.debug + + # Don't complain if tests don't hit defensive assertions + raise AssertionError + raise NotImplementedError + + # Don't complain if non-runnable code isn't run + if 0: + if __name__ == .__main__.: + + # Don't complain about abstract methods + @abstractmethod + +# Show missing lines +show_missing = True + +# Output precision +precision = 2 + +[html] +# HTML report directory +directory = tests/coverage_html + +# Title +title = NordaBiz Coverage Report diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..0448bd0 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,173 @@ +name: NordaBiz Tests + +on: + push: + branches: [master, develop] + pull_request: + branches: [master] + +env: + PYTHON_VERSION: '3.11' + +jobs: + # ============================================================================= + # Unit and Integration Tests + # ============================================================================= + unit-tests: + name: Unit & Integration Tests + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:14 + env: + POSTGRES_USER: nordabiz_test + POSTGRES_PASSWORD: testpassword + POSTGRES_DB: nordabiz_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: 'pip' + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install -r requirements.txt + + - name: Run unit tests + run: | + pytest tests/unit/ -v --cov=. --cov-report=xml + env: + TESTING: 'true' + DATABASE_URL: postgresql://nordabiz_test:testpassword@localhost:5432/nordabiz_test + + - name: Run integration tests + run: | + pytest tests/integration/ -v --cov=. --cov-report=xml --cov-append + env: + TESTING: 'true' + DATABASE_URL: postgresql://nordabiz_test:testpassword@localhost:5432/nordabiz_test + + - name: Run security tests + run: | + pytest tests/security/ -v + env: + TESTING: 'true' + DATABASE_URL: postgresql://nordabiz_test:testpassword@localhost:5432/nordabiz_test + + - name: Check coverage + run: | + coverage report --fail-under=80 + continue-on-error: true # Don't fail build on coverage (for now) + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: ./coverage.xml + fail_ci_if_error: false + + # ============================================================================= + # E2E Tests (on staging) + # ============================================================================= + e2e-tests: + name: E2E Tests (Playwright) + runs-on: ubuntu-latest + needs: unit-tests # Only run if unit tests pass + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: 'pip' + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install -r requirements.txt + playwright install chromium + + - name: Run E2E tests on staging + run: | + pytest tests/e2e/ -v --base-url=${{ secrets.STAGING_URL }} + env: + BASE_URL: ${{ secrets.STAGING_URL }} + TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }} + TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }} + continue-on-error: true # E2E tests may fail if staging unavailable + + # ============================================================================= + # Smoke Tests (on production) - only on master + # ============================================================================= + smoke-tests: + name: Smoke Tests (Production) + runs-on: ubuntu-latest + needs: unit-tests + if: github.ref == 'refs/heads/master' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: 'pip' + + - name: Install dependencies + run: | + pip install pytest requests + + - name: Run smoke tests + run: | + pytest tests/smoke/test_production_health.py -v + env: + PROD_URL: https://nordabiznes.pl + + # ============================================================================= + # Notify on Failure + # ============================================================================= + notify-on-failure: + name: Send Failure Notification + runs-on: ubuntu-latest + needs: [unit-tests, e2e-tests] + if: failure() + + steps: + - name: Send failure email + uses: dawidd6/action-send-mail@v3 + with: + server_address: smtp.gmail.com + server_port: 587 + username: ${{ secrets.EMAIL_USERNAME }} + password: ${{ secrets.EMAIL_PASSWORD }} + subject: "❌ NordaBiz Tests Failed - ${{ github.ref_name }}" + to: ${{ secrets.NOTIFY_EMAIL }} + from: NordaBiz CI + body: | + Testy nie przeszły! + + Branch: ${{ github.ref_name }} + Commit: ${{ github.sha }} + Autor: ${{ github.actor }} + Wiadomość: ${{ github.event.head_commit.message }} + + Zobacz szczegóły: + ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} diff --git a/CLAUDE.md b/CLAUDE.md index 963ce6f..1858d2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,21 @@ nordabiz/ - **Uruchomienie:** `python3 app.py` - **Docker DB:** `docker compose up -d` (jeśli nie działa) +### Staging (NOWE - 2026-02-02) +- **Serwer:** NORDABIZ-STAGING-01 (VM 248, IP 10.22.68.248) +- **Baza:** PostgreSQL `nordabiz_staging` na 10.22.68.248:5432 +- **Domena:** staging.nordabiznes.pl +- **NPM Proxy Host ID:** 44 +- **SSL:** Let's Encrypt (ważny do 2026-05-03) + +**Cel:** Testowanie zmian przed wdrożeniem na produkcję (E2E, integration tests) + +**Weryfikacja:** +```bash +curl -I https://staging.nordabiznes.pl/health +ssh maciejpi@10.22.68.248 +``` + ### Production - **Serwer:** NORDABIZ-01 (VM 249, IP 10.22.68.249) - **Baza:** PostgreSQL na 10.22.68.249:5432 @@ -257,6 +272,56 @@ DATABASE_URL = 'postgresql://user:password123@localhost/db' - Po utworzeniu tabel: `GRANT ALL ON TABLE ... TO nordabiz_app` - Po utworzeniu sekwencji: `GRANT USAGE, SELECT ON SEQUENCE ... TO nordabiz_app` +## Infrastruktura testowa (NOWE - 2026-02-02) + +### Uruchamianie testów lokalnie + +```bash +# Wszystkie testy +pytest tests/ -v + +# Tylko unit testy +pytest tests/unit/ -v + +# Tylko integration testy +pytest tests/integration/ -v + +# Testy z coverage +pytest tests/ --cov=. --cov-report=html +``` + +### Struktura testów + +``` +tests/ +├── conftest.py # Fixtures (app, client, auth_client, admin_client) +├── pytest.ini # Konfiguracja pytest +├── unit/ # Unit testy (bez bazy) +├── integration/ # Integration testy (z bazą) +├── security/ # OWASP security tests +├── smoke/ # Smoke testy produkcyjne +└── e2e/ # Playwright E2E (na staging) +``` + +### CI/CD (GitHub Actions) + +Workflow: `.github/workflows/test.yml` + +| Job | Trigger | Środowisko | +|-----|---------|------------| +| unit-tests | push/PR | GitHub runner | +| e2e-tests | po unit | staging.nordabiznes.pl | +| smoke-tests | master only | nordabiznes.pl | + +### GitHub Secrets + +| Secret | Użycie | +|--------|--------| +| STAGING_URL | E2E tests base URL | +| TEST_USER_EMAIL | Logowanie w testach | +| TEST_USER_PASSWORD | Logowanie w testach | +| TEST_DATABASE_URL | Integration tests | + ### Testowanie na produkcji **Konta testowe (PROD):** @@ -296,6 +361,54 @@ Szczegóły: `docs/DEVELOPMENT.md#chatbot-ai` - **Backup:** Proxmox Backup Server (VM snapshots) - **DNS wewnętrzny:** nordabiznes.inpi.local +## Disaster Recovery + +**Pełna dokumentacja:** `docs/DR-PLAYBOOK.md` + +### Metryki SLA + +| Metryka | Wartość | +|---------|---------| +| **RTO** | 30-60 min | +| **RPO** | 1 godzina | + +### Lokalizacje backupów + +| Lokalizacja | Ścieżka | Retencja | +|-------------|---------|----------| +| Hourly (lokalnie) | `/var/backups/nordabiz/hourly/` | 24h | +| Daily (lokalnie) | `/var/backups/nordabiz/daily/` | 30 dni | +| Offsite (PBS) | `10.22.68.127:/backup/nordabiz/` | 30 dni | +| VM Snapshots | Proxmox | 3 snapshoty | + +### Szybkie przywracanie + +```bash +# Lista dostępnych backupów +ssh maciejpi@10.22.68.249 "ls -lt /var/backups/nordabiz/hourly/ | head -5" + +# Restore z backupu +ssh maciejpi@10.22.68.249 "sudo /var/www/nordabiznes/scripts/dr-restore.sh /var/backups/nordabiz/hourly/nordabiz_YYYYMMDD_HH.dump" + +# Weryfikacja +curl -I https://nordabiznes.pl/health +``` + +### Cron backupy (automatyczne) + +```bash +# Backup co godzinę +0 * * * * postgres pg_dump -Fc nordabiz > /var/backups/nordabiz/hourly/nordabiz_$(date +\%Y\%m\%d_\%H).dump + +# Backup dzienny +0 2 * * * postgres pg_dump -Fc nordabiz > /var/backups/nordabiz/daily/nordabiz_$(date +\%Y\%m\%d).dump + +# Sync do PBS +0 4 * * * root rsync -avz /var/backups/nordabiz/daily/ maciejpi@10.22.68.127:/backup/nordabiz/daily/ +``` + +Data wdrożenia: 2026-02-02 + ## Szablon profilu firmy ### Docelowa struktura (po optymalizacji) diff --git a/docs/DR-PLAYBOOK.md b/docs/DR-PLAYBOOK.md new file mode 100644 index 0000000..565d5a5 --- /dev/null +++ b/docs/DR-PLAYBOOK.md @@ -0,0 +1,425 @@ +# NordaBiz Disaster Recovery Playbook + +**Wersja:** 1.0 +**Data utworzenia:** 2026-02-02 +**Ostatnia aktualizacja:** 2026-02-02 + +--- + +## Spis treści + +1. [Przegląd systemu backupów](#przegląd-systemu-backupów) +2. [Lokalizacje backupów](#lokalizacje-backupów) +3. [Scenariusze awarii](#scenariusze-awarii) +4. [Procedury odtwarzania](#procedury-odtwarzania) +5. [Testy DR](#testy-dr) +6. [Kontakty i eskalacja](#kontakty-i-eskalacja) + +--- + +## Przegląd systemu backupów + +### Metryki SLA + +| Metryka | Wartość | Opis | +|---------|---------|------| +| **RTO** | 30-60 min | Recovery Time Objective - czas do przywrócenia | +| **RPO** | 1 godzina | Recovery Point Objective - maksymalna utrata danych | +| **Retencja hourly** | 24 godziny | Backupy co godzinę | +| **Retencja daily** | 30 dni | Backupy dzienne | +| **Offsite** | PBS (10.22.68.127) | Proxmox Backup Server | + +### Harmonogram backupów + +| Typ | Częstotliwość | Godzina | Retencja | +|-----|---------------|---------|----------| +| Hourly DB | Co godzinę | :00 | 24h | +| Daily DB | Codziennie | 02:00 | 30 dni | +| Config | Tygodniowo | Niedziela 03:00 | 4 tygodnie | +| Offsite sync | Codziennie | 04:00 | 30 dni | +| VM Snapshot | Przed deployment | Manual | 3 snapshoty | + +--- + +## Lokalizacje backupów + +### Backup lokalny (NORDABIZ-01) + +``` +/var/backups/nordabiz/ +├── hourly/ # Backupy co godzinę (retencja 24h) +│ ├── nordabiz_20260202_00.dump +│ ├── nordabiz_20260202_01.dump +│ └── ... +├── daily/ # Backupy dzienne (retencja 30 dni) +│ ├── nordabiz_20260201.dump +│ ├── nordabiz_20260202.dump +│ └── ... +└── config/ # Konfiguracja (retencja 4 tygodnie) + ├── config_20260126.tar.gz + └── config_20260202.tar.gz +``` + +### Backup offsite (PBS - 10.22.68.127) + +``` +/backup/nordabiz/ +├── daily/ # Mirror backupów dziennych +└── config/ # Mirror konfiguracji +``` + +### VM Snapshots (Proxmox) + +- **Lokalizacja:** Proxmox VE (hypervisor) +- **VM ID:** 249 (NORDABIZ-01), 119 (R11-REVPROXY-01) +- **Storage:** local-lvm lub PBS +- **Dostęp:** Proxmox Web UI (https://10.22.68.10:8006) + +--- + +## Scenariusze awarii + +### Scenariusz 1: Uszkodzenie bazy danych + +**Objawy:** +- Błędy SQL w logach aplikacji +- HTTP 500 na stronie +- `psql: FATAL: database "nordabiz" does not exist` + +**Procedura:** +1. Zatrzymaj aplikację: `sudo systemctl stop nordabiznes` +2. Zidentyfikuj ostatni działający backup +3. Użyj skryptu restore: `sudo ./scripts/dr-restore.sh /var/backups/nordabiz/hourly/latest.dump` +4. Zweryfikuj: `curl -I http://localhost:5000/health` + +**RTO:** ~15 minut + +--- + +### Scenariusz 2: Awaria VM (NORDABIZ-01) + +**Objawy:** +- Brak odpowiedzi SSH +- HTTP 502 na https://nordabiznes.pl +- VM nie odpowiada w Proxmox + +**Procedura:** + +#### Opcja A: Restart VM +1. Zaloguj się do Proxmox: https://10.22.68.10:8006 +2. VM 249 → Stop → Start +3. Poczekaj 2-3 minuty +4. Zweryfikuj: `curl -I https://nordabiznes.pl/health` + +#### Opcja B: Rollback do snapshotu +1. Proxmox → VM 249 → Snapshots +2. Wybierz ostatni działający snapshot +3. Kliknij "Rollback" +4. Start VM +5. Zweryfikuj + +#### Opcja C: Pełne odtworzenie (nowa VM) +1. Utwórz nową VM w Proxmox (4 vCPU, 8GB RAM, 100GB SSD) +2. Zainstaluj Ubuntu 22.04 LTS +3. Postępuj zgodnie z sekcją [Pełne odtworzenie systemu](#pełne-odtworzenie-systemu) + +**RTO:** 5 min (opcja A), 10 min (opcja B), 60 min (opcja C) + +--- + +### Scenariusz 3: Ransomware / kompromitacja + +**Objawy:** +- Zaszyfrowane pliki +- Nieautoryzowane zmiany w bazie +- Podejrzana aktywność w logach + +**Procedura:** +1. **NATYCHMIAST** odłącz VM od sieci (Proxmox → VM → Network → Disconnect) +2. Utwórz snapshot stanu (dla forensics) +3. Przywróć z offsite backup (PBS): + ```bash + scp maciejpi@10.22.68.127:/backup/nordabiz/daily/nordabiz_YYYYMMDD.dump /tmp/ + ``` +4. Utwórz nową VM (nie używaj starej!) +5. Postępuj zgodnie z [Pełne odtworzenie systemu](#pełne-odtworzenie-systemu) +6. Zmień wszystkie hasła i klucze API +7. Zgłoś incydent bezpieczeństwa + +**RTO:** 60-120 minut + +--- + +### Scenariusz 4: Awaria NPM (Reverse Proxy) + +**Objawy:** +- ERR_CONNECTION_REFUSED na https://nordabiznes.pl +- NPM container nie działa + +**Procedura:** +1. SSH do R11-REVPROXY-01: `ssh maciejpi@10.22.68.250` +2. Sprawdź container: `docker ps | grep nginx-proxy-manager` +3. Restart containera: `docker restart nginx-proxy-manager_app_1` +4. Jeśli nie działa, sprawdź logi: `docker logs nginx-proxy-manager_app_1` +5. Zweryfikuj konfigurację proxy: + ```bash + docker exec nginx-proxy-manager_app_1 sqlite3 /data/database.sqlite \ + "SELECT forward_port FROM proxy_host WHERE id = 27;" + # Musi być: 5000 + ``` + +**RTO:** 5-10 minut + +--- + +## Procedury odtwarzania + +### Przywracanie z backupu godzinowego + +```bash +# 1. Lista dostępnych backupów +ls -la /var/backups/nordabiz/hourly/ + +# 2. Wybierz backup (np. z godziny 14:00) +BACKUP_FILE="/var/backups/nordabiz/hourly/nordabiz_20260202_14.dump" + +# 3. Uruchom skrypt restore +sudo /var/www/nordabiznes/scripts/dr-restore.sh $BACKUP_FILE + +# 4. Weryfikacja +curl -I http://localhost:5000/health +``` + +### Przywracanie z backupu dziennego + +```bash +# 1. Lista backupów dziennych +ls -la /var/backups/nordabiz/daily/ + +# 2. Uruchom restore +sudo /var/www/nordabiznes/scripts/dr-restore.sh /var/backups/nordabiz/daily/nordabiz_20260201.dump +``` + +### Przywracanie z offsite (PBS) + +```bash +# 1. Skopiuj backup z PBS +scp maciejpi@10.22.68.127:/backup/nordabiz/daily/nordabiz_20260201.dump /tmp/ + +# 2. Uruchom restore +sudo /var/www/nordabiznes/scripts/dr-restore.sh /tmp/nordabiz_20260201.dump +``` + +### Pełne odtworzenie systemu + +Wykonaj gdy VM jest całkowicie niedostępna lub skompromitowana. + +#### Krok 1: Przygotowanie nowej VM + +```bash +# Na Proxmox - utwórz VM: +# - ID: 249 (lub nowy) +# - CPU: 4 vCPU +# - RAM: 8 GB +# - Disk: 100 GB SSD +# - Network: vmbr0 +# - IP: 10.22.68.249/24 +# - Gateway: 10.22.68.1 +# - OS: Ubuntu 22.04 LTS +``` + +#### Krok 2: Instalacja pakietów + +```bash +sudo apt update && sudo apt upgrade -y +sudo apt install -y python3 python3-venv python3-pip postgresql-14 git nginx rsync +``` + +#### Krok 3: Konfiguracja PostgreSQL + +```bash +sudo -u postgres createuser nordabiz_app +sudo -u postgres createdb nordabiz -O nordabiz_app +sudo -u postgres psql -c "ALTER USER nordabiz_app WITH PASSWORD 'NOWE_HASLO';" +``` + +#### Krok 4: Przywrócenie bazy z backup + +```bash +# Skopiuj backup z PBS +scp maciejpi@10.22.68.127:/backup/nordabiz/daily/nordabiz_LATEST.dump /tmp/ + +# Restore +sudo -u postgres pg_restore -d nordabiz -O --role=nordabiz_app /tmp/nordabiz_LATEST.dump + +# Nadaj uprawnienia +sudo -u postgres psql -d nordabiz -c "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO nordabiz_app;" +sudo -u postgres psql -d nordabiz -c "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO nordabiz_app;" +``` + +#### Krok 5: Instalacja aplikacji + +```bash +sudo mkdir -p /var/www/nordabiznes +sudo chown www-data:www-data /var/www/nordabiznes + +# Clone z Gitea +sudo -u www-data git clone https://10.22.68.180:3000/maciejpi/nordabiz.git /var/www/nordabiznes + +# Virtualenv +cd /var/www/nordabiznes +sudo -u www-data python3 -m venv venv +sudo -u www-data venv/bin/pip install -r requirements.txt +``` + +#### Krok 6: Przywrócenie konfiguracji + +```bash +# Skopiuj .env z backup +scp maciejpi@10.22.68.127:/backup/nordabiz/config/latest/.env /var/www/nordabiznes/.env +sudo chown www-data:www-data /var/www/nordabiznes/.env +sudo chmod 600 /var/www/nordabiznes/.env + +# WAŻNE: Zaktualizuj DATABASE_URL w .env jeśli zmieniłeś hasło! +``` + +#### Krok 7: Konfiguracja systemd + +```bash +sudo cp /var/www/nordabiznes/docs/nordabiznes.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable nordabiznes +sudo systemctl start nordabiznes +``` + +#### Krok 8: Weryfikacja + +```bash +# Status usługi +sudo systemctl status nordabiznes + +# Health check +curl -I http://localhost:5000/health + +# Logi +sudo journalctl -u nordabiznes -f +``` + +#### Krok 9: Aktualizacja NPM (jeśli zmieniono IP) + +Jeśli nowa VM ma inny IP, zaktualizuj NPM: + +```bash +ssh maciejpi@10.22.68.250 +# NPM Admin: http://10.22.68.250:81 +# Proxy Host 27 → Forward Host: NOWE_IP, Port: 5000 +``` + +--- + +## Testy DR + +### Test kwartalny + +Wykonuj co kwartał: + +1. **Przygotowanie:** + - Utwórz testową VM w Proxmox + - Skopiuj backup z PBS + +2. **Wykonanie:** + - Pełne odtworzenie zgodnie z procedurą + - Zmierz czas (RTO) + - Zweryfikuj działanie aplikacji + +3. **Dokumentacja:** + - Zapisz wyniki w `docs/DR-TEST-RESULTS.md` + - Zaktualizuj procedury jeśli potrzeba + +### Checklist testu DR + +- [ ] Backup istnieje i jest aktualny (< 1h) +- [ ] Backup offsite zsynchronizowany (< 24h) +- [ ] Skrypt dr-restore.sh działa +- [ ] Aplikacja startuje po restore +- [ ] Health check zwraca HTTP 200 +- [ ] Dane są kompletne (sprawdź liczbę firm) +- [ ] Chat AI działa (testowe pytanie) +- [ ] Logowanie działa + +--- + +## Kontakty i eskalacja + +### Poziomy eskalacji + +| Poziom | Czas reakcji | Kto | +|--------|--------------|-----| +| L1 | < 15 min | Administrator (self-service) | +| L2 | < 1h | Wsparcie techniczne | +| L3 | < 4h | Eskalacja do dostawcy | + +### Komendy diagnostyczne + +```bash +# Quick health check +curl -I https://nordabiznes.pl/health + +# Sprawdź status usługi +ssh maciejpi@10.22.68.249 "sudo systemctl status nordabiznes" + +# Sprawdź logi +ssh maciejpi@10.22.68.249 "sudo journalctl -u nordabiznes -n 50" + +# Sprawdź NPM +ssh maciejpi@10.22.68.250 "docker ps | grep nginx-proxy-manager" + +# Sprawdź port forwarding +ssh maciejpi@10.22.68.250 "docker exec nginx-proxy-manager_app_1 sqlite3 /data/database.sqlite \"SELECT forward_port FROM proxy_host WHERE id = 27;\"" +``` + +--- + +## Appendix: Komendy referencyjne + +### Backup + +```bash +# Ręczny backup bazy +sudo -u postgres pg_dump -Fc nordabiz > /tmp/backup_manual.dump + +# Backup konfiguracji +sudo tar -czf /tmp/config_backup.tar.gz /var/www/nordabiznes/.env /etc/systemd/system/nordabiznes.service +``` + +### Restore + +```bash +# Szybki restore (skrypt) +sudo /var/www/nordabiznes/scripts/dr-restore.sh /var/backups/nordabiz/hourly/latest.dump + +# Ręczny restore +sudo systemctl stop nordabiznes +sudo -u postgres dropdb nordabiz +sudo -u postgres createdb nordabiz -O nordabiz_app +sudo -u postgres pg_restore -d nordabiz -O /tmp/backup.dump +sudo systemctl start nordabiznes +``` + +### Monitoring + +```bash +# Sprawdź rozmiar backupów +du -sh /var/backups/nordabiz/* + +# Sprawdź ostatni backup +ls -lt /var/backups/nordabiz/hourly/ | head -5 + +# Sprawdź cron +cat /etc/cron.d/nordabiz-backup +``` + +--- + +**Dokument utrzymywany przez:** Zespół NordaBiz +**Następny przegląd:** 2026-05-02 diff --git a/docs/OFFSITE-BACKUP-SETUP.md b/docs/OFFSITE-BACKUP-SETUP.md new file mode 100644 index 0000000..a394ff3 --- /dev/null +++ b/docs/OFFSITE-BACKUP-SETUP.md @@ -0,0 +1,89 @@ +# Konfiguracja Offsite Backup do PBS + +**Status:** Wymaga ręcznej konfiguracji SSH +**Data:** 2026-02-02 + +## Klucz SSH do dodania na PBS + +Klucz publiczny z NORDABIZ-01 (10.22.68.249): + +``` +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHHpPjhwjOUBTmo0MFus4QsgAlI5JxbPNlhW0aPV7vIg maciejpi@NORDABIZ-01 +``` + +## Instrukcja konfiguracji + +### Krok 1: Dodanie klucza na PBS (10.22.68.127) + +Zaloguj się na PBS przez konsolę Proxmox lub inną metodę i wykonaj: + +```bash +# Na PBS (10.22.68.127) +mkdir -p /home/maciejpi/.ssh +echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHHpPjhwjOUBTmo0MFus4QsgAlI5JxbPNlhW0aPV7vIg maciejpi@NORDABIZ-01" >> /home/maciejpi/.ssh/authorized_keys +chmod 700 /home/maciejpi/.ssh +chmod 600 /home/maciejpi/.ssh/authorized_keys +chown -R maciejpi:maciejpi /home/maciejpi/.ssh +``` + +### Krok 2: Utworzenie katalogów backup na PBS + +```bash +# Na PBS (10.22.68.127) +sudo mkdir -p /backup/nordabiz/{daily,config} +sudo chown -R maciejpi:maciejpi /backup/nordabiz +``` + +### Krok 3: Weryfikacja połączenia z NORDABIZ-01 + +```bash +# Na NORDABIZ-01 (10.22.68.249) +ssh maciejpi@10.22.68.127 "echo OK" +``` + +### Krok 4: Dodanie cron offsite + +Po weryfikacji połączenia, utwórz plik cron: + +```bash +# Na NORDABIZ-01 +sudo tee /etc/cron.d/nordabiz-offsite << 'EOF' +# NordaBiz Offsite Backup +SHELL=/bin/bash +PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin + +# Sync daily backups do PBS o 4:00 +0 4 * * * root rsync -avz --delete /var/backups/nordabiz/daily/ maciejpi@10.22.68.127:/backup/nordabiz/daily/ 2>> /var/log/nordabiznes/backup.log + +# Sync config do PBS o 4:30 +30 4 * * * root rsync -avz /var/backups/nordabiz/config/ maciejpi@10.22.68.127:/backup/nordabiz/config/ 2>> /var/log/nordabiznes/backup.log +EOF + +sudo chmod 644 /etc/cron.d/nordabiz-offsite +``` + +### Krok 5: Test synchronizacji + +```bash +# Na NORDABIZ-01 +rsync -avz --dry-run /var/backups/nordabiz/daily/ maciejpi@10.22.68.127:/backup/nordabiz/daily/ +``` + +## Alternatywny serwer offsite + +Jeśli PBS jest niedostępny, można użyć r11-git-inpi (10.22.68.180) jako alternatywy: + +```bash +# Zmień IP w cron na 10.22.68.180 +# Dodaj klucz SSH do Gitea server +``` + +## Weryfikacja + +```bash +# Sprawdź backupy na PBS +ssh maciejpi@10.22.68.127 "ls -la /backup/nordabiz/daily/" + +# Sprawdź logi +ssh maciejpi@10.22.68.249 "tail -20 /var/log/nordabiznes/backup.log" +``` diff --git a/requirements.txt b/requirements.txt index 13d2df1..0c67dbd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,3 +37,21 @@ python-whois==0.9.4 # Google News URL Decoding googlenewsdecoder>=0.1.2 + +# =========================================== +# Testing Dependencies +# =========================================== + +# Test framework +pytest>=8.0.0 +pytest-flask>=1.3.0 +pytest-cov>=4.1.0 +pytest-xdist>=3.5.0 +pytest-timeout>=2.2.0 +pytest-mock>=3.12.0 + +# E2E testing with Playwright +playwright>=1.40.0 +pytest-playwright>=0.4.4 + +# HTTP client for smoke tests (requests already included above) diff --git a/scripts/dr-restore.sh b/scripts/dr-restore.sh new file mode 100644 index 0000000..bc91040 --- /dev/null +++ b/scripts/dr-restore.sh @@ -0,0 +1,164 @@ +#!/bin/bash +# +# NordaBiz Disaster Recovery Restore Script +# +# Przywraca bazę danych PostgreSQL z backupu i restartuje aplikację. +# +# Użycie: +# ./dr-restore.sh /path/to/backup.dump +# ./dr-restore.sh /var/backups/nordabiz/daily/nordabiz_20260202.dump +# +# Wymagania: +# - Uruchomienie jako root lub z sudo +# - Backup w formacie pg_dump -Fc (custom format) +# +# Autor: NordaBiz Team +# Data: 2026-02-02 + +set -e + +# Kolory dla output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Konfiguracja +DB_NAME="nordabiz" +DB_USER="nordabiz_app" +APP_SERVICE="nordabiznes" +HEALTH_URL="http://localhost:5000/health" +LOG_FILE="/var/log/nordabiznes/dr-restore.log" + +# Funkcje pomocnicze +log() { + echo -e "${GREEN}[$(date '+%Y-%m-%d %H:%M:%S')]${NC} $1" + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE" +} + +error() { + echo -e "${RED}[ERROR]${NC} $1" >&2 + echo "[ERROR] $1" >> "$LOG_FILE" + exit 1 +} + +warn() { + echo -e "${YELLOW}[WARN]${NC} $1" + echo "[WARN] $1" >> "$LOG_FILE" +} + +# Sprawdzenie argumentów +if [ -z "$1" ]; then + echo "Użycie: $0 <ścieżka_do_backupu>" + echo "" + echo "Przykłady:" + echo " $0 /var/backups/nordabiz/daily/nordabiz_20260202.dump" + echo " $0 /var/backups/nordabiz/hourly/nordabiz_20260202_14.dump" + echo "" + echo "Dostępne backupy:" + echo " Dzienne: ls /var/backups/nordabiz/daily/" + echo " Godzinowe: ls /var/backups/nordabiz/hourly/" + exit 1 +fi + +BACKUP_FILE="$1" + +# Sprawdzenie czy backup istnieje +if [ ! -f "$BACKUP_FILE" ]; then + error "Plik backupu nie istnieje: $BACKUP_FILE" +fi + +# Sprawdzenie uprawnień (root lub sudo) +if [ "$EUID" -ne 0 ]; then + error "Ten skrypt wymaga uprawnień root. Użyj: sudo $0 $1" +fi + +# Potwierdzenie od użytkownika +echo "" +echo "==========================================" +echo " NordaBiz Disaster Recovery Restore" +echo "==========================================" +echo "" +echo "Backup: $BACKUP_FILE" +echo "Rozmiar: $(du -h "$BACKUP_FILE" | cut -f1)" +echo "Data modyfikacji: $(stat -c %y "$BACKUP_FILE" 2>/dev/null || stat -f %Sm "$BACKUP_FILE")" +echo "" +echo -e "${YELLOW}UWAGA: Ta operacja nadpisze obecną bazę danych!${NC}" +echo "" +read -p "Czy kontynuować? (tak/nie): " CONFIRM + +if [ "$CONFIRM" != "tak" ]; then + echo "Anulowano." + exit 0 +fi + +# Rozpoczęcie procesu restore +log "Rozpoczynam przywracanie z: $BACKUP_FILE" + +# Krok 1: Zatrzymanie aplikacji +log "Krok 1/5: Zatrzymywanie aplikacji..." +systemctl stop "$APP_SERVICE" || warn "Aplikacja już zatrzymana" + +# Krok 2: Backup obecnej bazy (na wszelki wypadek) +SAFETY_BACKUP="/tmp/nordabiz_pre_restore_$(date +%Y%m%d_%H%M%S).dump" +log "Krok 2/5: Tworzenie backupu bezpieczeństwa: $SAFETY_BACKUP" +sudo -u postgres pg_dump -Fc "$DB_NAME" > "$SAFETY_BACKUP" 2>/dev/null || warn "Nie udało się utworzyć backupu bezpieczeństwa (baza może być uszkodzona)" + +# Krok 3: Usunięcie i odtworzenie bazy +log "Krok 3/5: Przywracanie bazy danych..." + +# Zamknięcie wszystkich połączeń +sudo -u postgres psql -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$DB_NAME' AND pid <> pg_backend_pid();" 2>/dev/null || true + +# Usunięcie i utworzenie bazy +sudo -u postgres psql -c "DROP DATABASE IF EXISTS $DB_NAME;" 2>/dev/null || error "Nie można usunąć bazy danych" +sudo -u postgres psql -c "CREATE DATABASE $DB_NAME OWNER $DB_USER;" || error "Nie można utworzyć bazy danych" + +# Przywrócenie z backupu +sudo -u postgres pg_restore -d "$DB_NAME" -O --role="$DB_USER" "$BACKUP_FILE" || error "Nie można przywrócić bazy z backupu" + +log "Baza danych przywrócona pomyślnie" + +# Krok 4: Nadanie uprawnień +log "Krok 4/5: Nadawanie uprawnień..." +sudo -u postgres psql -d "$DB_NAME" -c "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO $DB_USER;" +sudo -u postgres psql -d "$DB_NAME" -c "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO $DB_USER;" + +# Krok 5: Uruchomienie aplikacji +log "Krok 5/5: Uruchamianie aplikacji..." +systemctl start "$APP_SERVICE" + +# Oczekiwanie na uruchomienie +sleep 5 + +# Weryfikacja health check +log "Weryfikacja health check..." +HEALTH_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_URL" 2>/dev/null || echo "000") + +if [ "$HEALTH_RESPONSE" = "200" ]; then + log "Health check: OK (HTTP 200)" +else + warn "Health check zwrócił: HTTP $HEALTH_RESPONSE" + warn "Sprawdź logi: journalctl -u $APP_SERVICE -n 50" +fi + +# Podsumowanie +echo "" +echo "==========================================" +echo " Restore zakończony" +echo "==========================================" +echo "" +log "Restore zakończony pomyślnie" +echo "Backup bezpieczeństwa: $SAFETY_BACKUP" +echo "" +echo "Weryfikacja:" +echo " - Health check: curl -I http://localhost:5000/health" +echo " - Logi: journalctl -u $APP_SERVICE -f" +echo " - Status: systemctl status $APP_SERVICE" +echo "" + +# Sprawdzenie liczby rekordów +COMPANY_COUNT=$(sudo -u postgres psql -d "$DB_NAME" -t -c "SELECT COUNT(*) FROM companies;" 2>/dev/null | xargs) +log "Liczba firm w bazie: $COMPANY_COUNT" + +exit 0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..24351b0 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,28 @@ +""" +NordaBiz Test Suite +=================== + +Test categories: +- unit/ : Unit tests (fast, no external dependencies) +- integration/ : Integration tests (requires database) +- e2e/ : End-to-end tests (requires staging/production) +- smoke/ : Smoke tests (quick production health checks) +- security/ : Security tests (OWASP Top 10) +- migration/ : Database migration tests +- dr/ : Disaster recovery tests + +Running tests: + # All tests + pytest + + # Specific category + pytest tests/unit/ -v + pytest tests/smoke/ -v + + # With markers + pytest -m "unit" -v + pytest -m "not slow" -v + + # With coverage + pytest --cov=. --cov-report=html +""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6ac9795 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,291 @@ +""" +NordaBiz Test Configuration and Fixtures +========================================= + +Shared fixtures for all test types: +- app: Flask application instance +- client: Test client for HTTP requests +- auth_client: Client with logged-in regular user session +- admin_client: Client with logged-in admin session +- db_session: Database session for integration tests +""" + +import os +import sys +from pathlib import Path + +import pytest + +# Add project root to path for imports +PROJECT_ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +# Set testing environment before importing app +os.environ['FLASK_ENV'] = 'testing' +os.environ['TESTING'] = 'true' + +# If no DATABASE_URL, use SQLite for tests +if not os.environ.get('DATABASE_URL'): + os.environ['DATABASE_URL'] = 'sqlite:///:memory:' + + +# ============================================================================= +# Database Availability Check +# ============================================================================= + +def is_database_available(): + """Check if database is available for testing.""" + try: + from database import engine + with engine.connect() as conn: + pass + return True + except Exception: + return False + + +# ============================================================================= +# Flask Application Fixtures +# ============================================================================= + +@pytest.fixture(scope='session') +def app(): + """Create Flask application for testing.""" + from app import app as flask_app + + flask_app.config.update({ + 'TESTING': True, + 'WTF_CSRF_ENABLED': False, # Disable CSRF for tests + 'LOGIN_DISABLED': False, + 'SERVER_NAME': 'localhost', + 'SESSION_COOKIE_SECURE': False, + 'SESSION_COOKIE_HTTPONLY': True, + }) + + with flask_app.app_context(): + yield flask_app + + +@pytest.fixture +def client(app): + """Create test client for HTTP requests.""" + return app.test_client() + + +@pytest.fixture +def runner(app): + """Create CLI runner for testing CLI commands.""" + return app.test_cli_runner() + + +# ============================================================================= +# Authentication Fixtures +# ============================================================================= + +# Test credentials (from CLAUDE.md) +TEST_USER_EMAIL = 'test@nordabiznes.pl' +TEST_USER_PASSWORD = '&Rc2LdbSw&jiGR0ek@Bz' +TEST_ADMIN_EMAIL = 'testadmin@nordabiznes.pl' +TEST_ADMIN_PASSWORD = 'cSfQbbwegwv1v3Q2Dm0Q' + + +def _scrypt_available(): + """Check if hashlib.scrypt is available (required for password verification).""" + try: + import hashlib + hashlib.scrypt(b'test', salt=b'salt', n=2, r=1, p=1) + return True + except (AttributeError, ValueError): + return False + + +SCRYPT_AVAILABLE = _scrypt_available() + + +@pytest.fixture +def auth_client(client, app): + """ + Create test client with logged-in regular user session. + + Uses test account: test@nordabiznes.pl + Role: MEMBER (regular user) + """ + if not SCRYPT_AVAILABLE: + pytest.skip("hashlib.scrypt not available - cannot test login") + + with app.app_context(): + response = client.post('/login', data={ + 'email': TEST_USER_EMAIL, + 'password': TEST_USER_PASSWORD, + }, follow_redirects=True) + + # Check if login succeeded by verifying we're not redirected back to login + if '/login' in response.request.path: + pytest.skip("Login failed - check test user credentials or database") + + return client + + +@pytest.fixture +def admin_client(client, app): + """ + Create test client with logged-in admin session. + + Uses test account: testadmin@nordabiznes.pl + Role: ADMIN + """ + if not SCRYPT_AVAILABLE: + pytest.skip("hashlib.scrypt not available - cannot test login") + + with app.app_context(): + response = client.post('/login', data={ + 'email': TEST_ADMIN_EMAIL, + 'password': TEST_ADMIN_PASSWORD, + }, follow_redirects=True) + + # Check if login succeeded + if '/login' in response.request.path: + pytest.skip("Admin login failed - check test admin credentials or database") + + return client + + +# ============================================================================= +# Database Fixtures +# ============================================================================= + +class DatabaseWrapper: + """Wrapper for database access in tests.""" + + def __init__(self): + from database import engine, SessionLocal + self.engine = engine + self.SessionLocal = SessionLocal + self._session = None + + @property + def session(self): + if self._session is None: + self._session = self.SessionLocal() + return self._session + + def close(self): + if self._session: + self._session.close() + self._session = None + + +@pytest.fixture(scope='session') +def db(app): + """Get database wrapper instance.""" + wrapper = DatabaseWrapper() + yield wrapper + wrapper.close() + + +@pytest.fixture +def db_session(db, app): + """ + Create database session for integration tests. + + Rolls back changes after each test. + """ + with app.app_context(): + session = db.SessionLocal() + yield session + session.rollback() + session.close() + + +# ============================================================================= +# Mock Fixtures +# ============================================================================= + +@pytest.fixture +def mock_gemini(mocker): + """Mock Gemini AI service for unit tests.""" + mock = mocker.patch('gemini_service.generate_content') + mock.return_value = { + 'text': 'Mocked AI response for testing', + 'companies': [] + } + return mock + + +@pytest.fixture +def mock_email(mocker): + """Mock email sending for tests.""" + return mocker.patch('flask_mail.Mail.send') + + +# ============================================================================= +# Test Data Fixtures +# ============================================================================= + +@pytest.fixture +def sample_company(): + """Sample company data for tests.""" + return { + 'name': 'Test Company Sp. z o.o.', + 'slug': 'test-company-sp-z-o-o', + 'nip': '1234567890', + 'category': 'IT', + 'short_description': 'Test company for automated tests', + 'website': 'https://test-company.pl', + 'email': 'contact@test-company.pl', + 'phone': '+48 123 456 789', + } + + +@pytest.fixture +def sample_user(): + """Sample user data for tests.""" + return { + 'email': 'newuser@test.pl', + 'password': 'SecurePassword123!', + 'first_name': 'Test', + 'last_name': 'User', + } + + +# ============================================================================= +# URL Fixtures +# ============================================================================= + +@pytest.fixture +def production_url(): + """Production URL for smoke tests.""" + return 'https://nordabiznes.pl' + + +@pytest.fixture +def staging_url(): + """Staging URL for E2E tests.""" + return 'https://staging.nordabiznes.pl' + + +# ============================================================================= +# Cleanup Fixtures +# ============================================================================= + +@pytest.fixture(autouse=True) +def cleanup_after_test(): + """Cleanup after each test.""" + yield + # Add any cleanup logic here if needed + + +# ============================================================================= +# Markers Configuration +# ============================================================================= + +def pytest_configure(config): + """Configure pytest markers.""" + config.addinivalue_line("markers", "unit: Unit tests") + config.addinivalue_line("markers", "integration: Integration tests") + config.addinivalue_line("markers", "e2e: End-to-end tests") + config.addinivalue_line("markers", "smoke: Smoke tests") + config.addinivalue_line("markers", "security: Security tests") + config.addinivalue_line("markers", "migration: Migration tests") + config.addinivalue_line("markers", "dr: Disaster recovery tests") + config.addinivalue_line("markers", "slow: Slow tests") diff --git a/tests/dr/__init__.py b/tests/dr/__init__.py new file mode 100644 index 0000000..28daf21 --- /dev/null +++ b/tests/dr/__init__.py @@ -0,0 +1 @@ +"""Disaster recovery tests - backup and restore verification.""" diff --git a/tests/dr/test_dr_procedures.py b/tests/dr/test_dr_procedures.py new file mode 100644 index 0000000..2f172b6 --- /dev/null +++ b/tests/dr/test_dr_procedures.py @@ -0,0 +1,158 @@ +""" +Disaster Recovery tests +======================== + +Verify backup and restore procedures work correctly. +These tests may require SSH access to production/staging. +""" + +import os +import subprocess +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.dr + +PROJECT_ROOT = Path(__file__).parent.parent.parent +STAGING_HOST = os.environ.get('STAGING_HOST', 'maciejpi@10.22.68.251') +PROD_HOST = os.environ.get('PROD_HOST', 'maciejpi@10.22.68.249') + + +def ssh_command(host: str, cmd: str, timeout: int = 60) -> tuple[int, str, str]: + """Execute SSH command.""" + result = subprocess.run( + ['ssh', host, cmd], + capture_output=True, + text=True, + timeout=timeout + ) + return result.returncode, result.stdout, result.stderr + + +class TestDRScripts: + """Tests for DR scripts availability.""" + + def test_dr_restore_script_exists(self): + """DR restore script should exist locally.""" + script = PROJECT_ROOT / 'scripts' / 'dr-restore.sh' + assert script.exists(), f"DR restore script not found: {script}" + + def test_dr_restore_script_executable(self): + """DR restore script should be executable.""" + script = PROJECT_ROOT / 'scripts' / 'dr-restore.sh' + + if not script.exists(): + pytest.skip("DR restore script not found") + + # Check if it has executable permission + assert os.access(script, os.X_OK), "DR restore script is not executable" + + def test_dr_playbook_exists(self): + """DR playbook documentation should exist.""" + playbook = PROJECT_ROOT / 'docs' / 'DR-PLAYBOOK.md' + assert playbook.exists(), f"DR playbook not found: {playbook}" + + +class TestBackupAvailability: + """Tests for backup availability on production.""" + + @pytest.mark.slow + def test_hourly_backup_available(self): + """Hourly backup should be available on production.""" + returncode, stdout, stderr = ssh_command( + PROD_HOST, + 'ls -la /var/backups/nordabiz/hourly/ | tail -3' + ) + + assert returncode == 0, f"Failed to list hourly backups: {stderr}" + assert 'nordabiz_' in stdout, "No hourly backups found" + + @pytest.mark.slow + def test_daily_backup_available(self): + """Daily backup should be available on production.""" + returncode, stdout, stderr = ssh_command( + PROD_HOST, + 'ls -la /var/backups/nordabiz/daily/ | tail -3' + ) + + assert returncode == 0, f"Failed to list daily backups: {stderr}" + assert 'nordabiz_' in stdout, "No daily backups found" + + +class TestRestoreOnStaging: + """Tests for restore procedure on staging.""" + + @pytest.mark.slow + def test_can_copy_backup_to_staging(self): + """Should be able to copy backup from prod to staging.""" + # Get latest backup filename + returncode, stdout, _ = ssh_command( + PROD_HOST, + 'ls -t /var/backups/nordabiz/hourly/ | head -1' + ) + + if returncode != 0 or not stdout.strip(): + pytest.skip("No backup available on production") + + backup_file = stdout.strip() + + # Try to copy to staging (dry run - just check connectivity) + returncode, stdout, stderr = ssh_command( + STAGING_HOST, + f'scp -o StrictHostKeyChecking=no {PROD_HOST}:/var/backups/nordabiz/hourly/{backup_file} /tmp/ && echo OK' + ) + + # This might fail due to SSH key issues - that's expected in CI + if returncode != 0: + pytest.skip(f"Cannot copy backup to staging: {stderr}") + + assert 'OK' in stdout + + @pytest.mark.slow + def test_restore_script_runs_on_staging(self): + """Restore script should run on staging.""" + # This is a destructive test - should only run on staging + returncode, stdout, stderr = ssh_command( + STAGING_HOST, + 'test -x /var/www/nordabiznes/scripts/dr-restore.sh && echo OK' + ) + + if returncode != 0: + pytest.skip("DR restore script not available on staging") + + assert 'OK' in stdout + + +class TestHealthAfterRestore: + """Tests for application health after restore.""" + + @pytest.mark.slow + def test_staging_health_check(self): + """Staging should respond to health check.""" + import requests + + staging_url = os.environ.get('STAGING_URL', 'https://staging.nordabiznes.pl') + + try: + response = requests.get(f'{staging_url}/health', timeout=10) + assert response.status_code == 200 + assert response.json().get('status') == 'ok' + except requests.exceptions.RequestException as e: + pytest.skip(f"Staging not accessible: {e}") + + @pytest.mark.slow + def test_staging_database_has_data(self): + """Staging database should have company data after restore.""" + import requests + + staging_url = os.environ.get('STAGING_URL', 'https://staging.nordabiznes.pl') + + try: + response = requests.get(f'{staging_url}/api/companies', timeout=10) + assert response.status_code == 200 + + data = response.json() + assert len(data) > 0, "Staging database appears empty" + except requests.exceptions.RequestException as e: + pytest.skip(f"Staging not accessible: {e}") diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..883c813 --- /dev/null +++ b/tests/e2e/__init__.py @@ -0,0 +1 @@ +"""End-to-end tests - requires Playwright and staging/production.""" diff --git a/tests/e2e/test_login_flow.py b/tests/e2e/test_login_flow.py new file mode 100644 index 0000000..797b3c5 --- /dev/null +++ b/tests/e2e/test_login_flow.py @@ -0,0 +1,159 @@ +""" +E2E tests for login flow using Playwright +========================================== + +These tests run in a real browser and test the complete user flow. + +Requirements: + pip install playwright pytest-playwright + playwright install chromium + +Usage: + pytest tests/e2e/ --base-url=https://staging.nordabiznes.pl +""" + +import os + +import pytest + +# Skip all tests if playwright not installed +pytest.importorskip("playwright") + +from playwright.sync_api import Page, expect + +pytestmark = pytest.mark.e2e + +# Test credentials +TEST_USER_EMAIL = 'test@nordabiznes.pl' +TEST_USER_PASSWORD = '&Rc2LdbSw&jiGR0ek@Bz' +TEST_ADMIN_EMAIL = 'testadmin@nordabiznes.pl' +TEST_ADMIN_PASSWORD = 'cSfQbbwegwv1v3Q2Dm0Q' + +# Base URL (override with --base-url or environment variable) +BASE_URL = os.environ.get('BASE_URL', 'https://staging.nordabiznes.pl') + + +@pytest.fixture +def base_url(): + """Get base URL for tests.""" + return BASE_URL + + +class TestLoginFlow: + """Tests for login user flow.""" + + def test_login_page_loads(self, page: Page, base_url): + """Login page should load correctly.""" + page.goto(f'{base_url}/auth/login') + + # Should have login form + expect(page.locator('input[name="email"]')).to_be_visible() + expect(page.locator('input[name="password"]')).to_be_visible() + expect(page.locator('button[type="submit"]')).to_be_visible() + + def test_user_can_login(self, page: Page, base_url): + """User should be able to log in with valid credentials.""" + page.goto(f'{base_url}/auth/login') + + # Fill login form + page.fill('input[name="email"]', TEST_USER_EMAIL) + page.fill('input[name="password"]', TEST_USER_PASSWORD) + page.click('button[type="submit"]') + + # Should redirect to dashboard + page.wait_for_url('**/dashboard**', timeout=10000) + expect(page).to_have_url(f'{base_url}/dashboard') + + def test_invalid_login_shows_error(self, page: Page, base_url): + """Invalid credentials should show error message.""" + page.goto(f'{base_url}/auth/login') + + page.fill('input[name="email"]', 'wrong@test.pl') + page.fill('input[name="password"]', 'wrongpassword') + page.click('button[type="submit"]') + + # Should stay on login page with error + page.wait_for_timeout(2000) + expect(page).to_have_url(f'{base_url}/auth/login') + + def test_user_can_logout(self, page: Page, base_url): + """User should be able to log out.""" + # First login + page.goto(f'{base_url}/auth/login') + page.fill('input[name="email"]', TEST_USER_EMAIL) + page.fill('input[name="password"]', TEST_USER_PASSWORD) + page.click('button[type="submit"]') + page.wait_for_url('**/dashboard**', timeout=10000) + + # Then logout + page.click('text=Wyloguj') # or find logout button/link + + # Should redirect to home or login + page.wait_for_timeout(2000) + + +@pytest.fixture +def logged_in_page(page: Page, base_url): + """Fixture that provides a page with logged in user.""" + page.goto(f'{base_url}/auth/login') + page.fill('input[name="email"]', TEST_USER_EMAIL) + page.fill('input[name="password"]', TEST_USER_PASSWORD) + page.click('button[type="submit"]') + page.wait_for_url('**/dashboard**', timeout=10000) + return page + + +class TestDashboard: + """Tests for dashboard functionality.""" + + def test_dashboard_shows_user_info(self, logged_in_page: Page): + """Dashboard should show user information.""" + expect(logged_in_page.locator('body')).to_contain_text('Witaj') + + def test_dashboard_has_navigation(self, logged_in_page: Page): + """Dashboard should have navigation menu.""" + expect(logged_in_page.locator('nav')).to_be_visible() + + +class TestCompanyCatalog: + """Tests for company catalog browsing.""" + + def test_catalog_shows_companies(self, page: Page, base_url): + """Catalog should show list of companies.""" + page.goto(f'{base_url}/companies') + + # Should have company cards/list + page.wait_for_selector('.company-card, .company-item, [data-company]', timeout=10000) + + def test_company_detail_accessible(self, page: Page, base_url): + """Company detail page should be accessible.""" + page.goto(f'{base_url}/companies') + + # Click first company + page.locator('.company-card, .company-item, [data-company]').first.click() + + # Should navigate to detail page + page.wait_for_url('**/company/**', timeout=10000) + + +class TestChat: + """Tests for NordaGPT chat functionality.""" + + def test_chat_responds_to_question(self, logged_in_page: Page, base_url): + """Chat should respond to user questions.""" + logged_in_page.goto(f'{base_url}/chat') + + # Find chat input + chat_input = logged_in_page.locator('#chat-input, input[name="message"], textarea') + expect(chat_input).to_be_visible() + + # Send a question + chat_input.fill('Kto w Izbie zajmuje się IT?') + logged_in_page.click('#send-button, button[type="submit"]') + + # Wait for response + logged_in_page.wait_for_selector('.chat-response, .message-ai, [data-role="assistant"]', timeout=30000) + + # Response should contain something + response = logged_in_page.locator('.chat-response, .message-ai, [data-role="assistant"]').last + expect(response).to_be_visible() diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..d585a6f --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Integration tests - requires database connection.""" diff --git a/tests/integration/test_auth_flow.py b/tests/integration/test_auth_flow.py new file mode 100644 index 0000000..409b0b5 --- /dev/null +++ b/tests/integration/test_auth_flow.py @@ -0,0 +1,101 @@ +""" +Integration tests for authentication flow +========================================== + +Tests login, logout, and session management. +""" + +import pytest + +pytestmark = pytest.mark.integration + + +class TestLogin: + """Tests for login functionality.""" + + def test_login_page_accessible(self, client): + """Login page should be accessible.""" + response = client.get('/login') + assert response.status_code == 200 + text = response.data.decode('utf-8').lower() + assert 'login' in text or 'zaloguj' in text + + def test_login_with_valid_credentials(self, client): + """Login should succeed with valid credentials.""" + from tests.conftest import TEST_USER_EMAIL, TEST_USER_PASSWORD + + response = client.post('/login', data={ + 'email': TEST_USER_EMAIL, + 'password': TEST_USER_PASSWORD, + }, follow_redirects=True) + + assert response.status_code == 200 + text = response.data.decode('utf-8').lower() + # Should redirect to dashboard or home + assert 'dashboard' in text or 'witaj' in text + + def test_login_with_invalid_credentials(self, client): + """Login should fail with invalid credentials.""" + response = client.post('/login', data={ + 'email': 'invalid@test.pl', + 'password': 'wrongpassword', + }, follow_redirects=True) + + assert response.status_code == 200 + text = response.data.decode('utf-8').lower() + # Should show error message or stay on login page + assert 'error' in text or 'login' in text or 'email' in text + + def test_login_with_empty_fields(self, client): + """Login should fail with empty fields.""" + response = client.post('/login', data={ + 'email': '', + 'password': '', + }, follow_redirects=True) + + assert response.status_code == 200 + + +class TestLogout: + """Tests for logout functionality.""" + + def test_logout(self, auth_client): + """Logout should clear session.""" + response = auth_client.get('/logout', follow_redirects=True) + + assert response.status_code == 200 + + # After logout, accessing protected page should redirect to login + response = auth_client.get('/dashboard') + assert response.status_code in [302, 401, 403] + + +class TestProtectedRoutes: + """Tests for protected route access.""" + + def test_dashboard_requires_login(self, client): + """Dashboard should require login.""" + response = client.get('/dashboard') + + # Should redirect to login + assert response.status_code == 302 + assert '/login' in response.location or 'login' in response.location.lower() + + def test_dashboard_accessible_when_logged_in(self, auth_client): + """Dashboard should be accessible when logged in.""" + response = auth_client.get('/dashboard') + + assert response.status_code == 200 + + def test_admin_requires_admin_role(self, auth_client): + """Admin routes should require admin role.""" + response = auth_client.get('/admin/companies') + + # Regular user should get 403 Forbidden + assert response.status_code in [403, 302] + + def test_admin_accessible_for_admin(self, admin_client): + """Admin routes should be accessible for admin.""" + response = admin_client.get('/admin/companies') + + assert response.status_code == 200 diff --git a/tests/migration/__init__.py b/tests/migration/__init__.py new file mode 100644 index 0000000..0e196d2 --- /dev/null +++ b/tests/migration/__init__.py @@ -0,0 +1 @@ +"""Database migration tests - verify migrations preserve data.""" diff --git a/tests/migration/test_migrations.py b/tests/migration/test_migrations.py new file mode 100644 index 0000000..8b07319 --- /dev/null +++ b/tests/migration/test_migrations.py @@ -0,0 +1,108 @@ +""" +Database migration tests +========================= + +Verify that SQL migrations preserve data integrity. +""" + +import os +import subprocess +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.migration + +# Project paths +PROJECT_ROOT = Path(__file__).parent.parent.parent +MIGRATIONS_DIR = PROJECT_ROOT / 'database' / 'migrations' + + +class TestMigrationFiles: + """Tests for migration file structure.""" + + def test_migrations_directory_exists(self): + """Migrations directory should exist.""" + assert MIGRATIONS_DIR.exists(), f"Migrations directory not found: {MIGRATIONS_DIR}" + + def test_migration_files_have_sequence(self): + """Migration files should have sequence numbers.""" + if not MIGRATIONS_DIR.exists(): + pytest.skip("Migrations directory not found") + + migration_files = list(MIGRATIONS_DIR.glob('*.sql')) + + for f in migration_files: + # Files should start with number like 001_ or 20240101_ + name = f.stem + assert name[0].isdigit(), f"Migration {f.name} should start with sequence number" + + def test_migration_files_are_valid_sql(self): + """Migration files should be valid SQL.""" + if not MIGRATIONS_DIR.exists(): + pytest.skip("Migrations directory not found") + + migration_files = list(MIGRATIONS_DIR.glob('*.sql')) + + for f in migration_files: + content = f.read_text() + + # Should not be empty + assert content.strip(), f"Migration {f.name} is empty" + + # Should not have obvious syntax errors + assert content.count('(') == content.count(')'), f"Unbalanced parentheses in {f.name}" + + +class TestMigrationRunner: + """Tests for migration runner script.""" + + def test_migration_runner_exists(self): + """Migration runner script should exist.""" + runner = PROJECT_ROOT / 'scripts' / 'run_migration.py' + assert runner.exists(), f"Migration runner not found: {runner}" + + def test_migration_runner_has_dry_run(self): + """Migration runner should have dry-run option.""" + runner = PROJECT_ROOT / 'scripts' / 'run_migration.py' + + if not runner.exists(): + pytest.skip("Migration runner not found") + + content = runner.read_text() + assert 'dry' in content.lower() or 'preview' in content.lower(), \ + "Migration runner should have dry-run option" + + +class TestMigrationIntegrity: + """Integration tests for migration integrity.""" + + @pytest.mark.slow + def test_migration_preserves_company_count(self, app, db): + """Migration should not change company count.""" + # This test would need a staging database + + with app.app_context(): + # Get current count + from database import Company + count_before = db.session.query(Company).count() + + # Here you would run migration + # run_migration("test_migration.sql") + + # Verify count unchanged + count_after = db.session.query(Company).count() + + assert count_before == count_after, "Migration changed company count" + + @pytest.mark.slow + def test_migration_preserves_user_count(self, app, db): + """Migration should not change user count.""" + with app.app_context(): + from database import User + count_before = db.session.query(User).count() + + # Run migration here + + count_after = db.session.query(User).count() + assert count_before == count_after diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 0000000..058780b --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,49 @@ +[pytest] +# NordaBiz Test Configuration +# ============================ + +# Test discovery +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* + +# Coverage settings (minimum 80%) +addopts = + -v + --tb=short + --cov=. + --cov-report=html:tests/coverage_html + --cov-report=term-missing + --cov-fail-under=80 + +# Ignore patterns +norecursedirs = + .git + venv + __pycache__ + .auto-claude + node_modules + +# Markers for test categories +markers = + unit: Unit tests (fast, no external dependencies) + integration: Integration tests (requires database) + e2e: End-to-end tests (requires staging/production) + smoke: Smoke tests (quick production health checks) + security: Security tests (OWASP Top 10) + migration: Database migration tests + dr: Disaster recovery tests + slow: Tests that take more than 5 seconds + +# Logging +log_cli = true +log_cli_level = INFO + +# Timeout for tests (seconds) +timeout = 30 + +# Filter warnings +filterwarnings = + ignore::DeprecationWarning + ignore::PendingDeprecationWarning diff --git a/tests/security/__init__.py b/tests/security/__init__.py new file mode 100644 index 0000000..85c6279 --- /dev/null +++ b/tests/security/__init__.py @@ -0,0 +1 @@ +"""Security tests - OWASP Top 10 vulnerability checks.""" diff --git a/tests/security/test_owasp.py b/tests/security/test_owasp.py new file mode 100644 index 0000000..6e4f4ec --- /dev/null +++ b/tests/security/test_owasp.py @@ -0,0 +1,199 @@ +""" +Security tests for OWASP Top 10 vulnerabilities +================================================= + +Tests for common web application security vulnerabilities. +""" + +import pytest + +pytestmark = pytest.mark.security + + +class TestSQLInjection: + """Tests for SQL injection vulnerabilities.""" + + def test_search_sql_injection(self, client): + """Search should be safe from SQL injection.""" + payloads = [ + "'; DROP TABLE companies; --", + "1' OR '1'='1", + "1; DELETE FROM users WHERE '1'='1", + "' UNION SELECT * FROM users --", + "admin'--", + ] + + for payload in payloads: + response = client.get(f'/search?q={payload}') + # Should not crash - 200, 302 (redirect), or 400 are acceptable + assert response.status_code in [200, 302, 400], f"Unexpected status for SQL injection test: {response.status_code}" + + def test_login_sql_injection(self, client): + """Login should be safe from SQL injection.""" + payloads = [ + ("admin' OR '1'='1", "anything"), + ("admin'--", "anything"), + ("' OR 1=1--", "' OR 1=1--"), + ] + + for email, password in payloads: + response = client.post('/login', data={ + 'email': email, + 'password': password, + }) + # Should not log in with injection + assert response.status_code in [200, 302, 400] + # If 200, should show login page (not dashboard) + + +class TestXSS: + """Tests for Cross-Site Scripting vulnerabilities.""" + + def test_search_xss_escaped(self, client): + """Search results should escape XSS payloads.""" + payloads = [ + '', + '', + '">', + "javascript:alert('xss')", + ] + + for payload in payloads: + response = client.get(f'/search?q={payload}', follow_redirects=True) + + assert response.status_code == 200 + # Payload should be escaped, not raw + assert b'") + assert isinstance(results, list) + + def test_search_polish_characters(self, app, db): + """Search should handle Polish characters.""" + from search_service import SearchService + + with app.app_context(): + service = SearchService(db.session) + + results = service.search("usługi") + assert isinstance(results, list) + + results = service.search("żółć") + assert isinstance(results, list) + + def test_search_case_insensitive(self, app, db): + """Search should be case insensitive.""" + from search_service import SearchService + + with app.app_context(): + service = SearchService(db.session) + + results_lower = service.search("it") + results_upper = service.search("IT") + results_mixed = service.search("It") + + # All should return results (may not be identical due to ranking) + assert isinstance(results_lower, list) + assert isinstance(results_upper, list) + assert isinstance(results_mixed, list) + + +class TestSearchSynonyms: + """Tests for search synonym handling.""" + + def test_synonym_expansion(self, app, db): + """Search should expand synonyms.""" + from search_service import SearchService + + with app.app_context(): + service = SearchService(db.session) + + # "informatyka" should match "IT" companies + results = service.search("informatyka") + assert isinstance(results, list)