This commit is contained in:
+1
-1
@@ -7,7 +7,7 @@ DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
|
|||||||
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:1226
|
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:1226
|
||||||
DJANGO_TIME_ZONE=Europe/Brussels
|
DJANGO_TIME_ZONE=Europe/Brussels
|
||||||
VACATURERADAR_OWNER_NAME=Jens
|
VACATURERADAR_OWNER_NAME=Jens
|
||||||
VACATURERADAR_VERSION=0.3.12
|
VACATURERADAR_VERSION=0.3.13
|
||||||
|
|
||||||
# Gebruik DATABASE_URL niet voor SQLite. Laat leeg voor lokale sqlite-ontwikkeling.
|
# Gebruik DATABASE_URL niet voor SQLite. Laat leeg voor lokale sqlite-ontwikkeling.
|
||||||
POSTGRES_DB=vacatureradar
|
POSTGRES_DB=vacatureradar
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ jobs:
|
|||||||
run: python -m pip install --disable-pip-version-check uv==0.10.0
|
run: python -m pip install --disable-pip-version-check uv==0.10.0
|
||||||
- name: Sync locked dependencies
|
- name: Sync locked dependencies
|
||||||
run: uv sync --frozen --all-groups
|
run: uv sync --frozen --all-groups
|
||||||
|
- name: Install Chromium for mandatory browser tests
|
||||||
|
run: uv run playwright install --with-deps chromium
|
||||||
- name: Secret scan
|
- name: Secret scan
|
||||||
uses: trufflesecurity/trufflehog@v3.79.0
|
uses: trufflesecurity/trufflehog@v3.79.0
|
||||||
with:
|
with:
|
||||||
@@ -47,6 +49,13 @@ jobs:
|
|||||||
IMAP_ENABLED: "0"
|
IMAP_ENABLED: "0"
|
||||||
OLLAMA_ENABLED: "0"
|
OLLAMA_ENABLED: "0"
|
||||||
run: ./scripts/codex_verify.sh
|
run: ./scripts/codex_verify.sh
|
||||||
|
- name: Upload browser failure artifacts
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: browser-failure-artifacts
|
||||||
|
path: artifacts/browser-tests/
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|
||||||
container:
|
container:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -64,5 +73,17 @@ jobs:
|
|||||||
severity: HIGH,CRITICAL
|
severity: HIGH,CRITICAL
|
||||||
exit-code: "1"
|
exit-code: "1"
|
||||||
ignore-unfixed: true
|
ignore-unfixed: true
|
||||||
|
- name: Generate CycloneDX software bill of materials
|
||||||
|
uses: aquasecurity/trivy-action@0.30.0
|
||||||
|
with:
|
||||||
|
image-ref: vacatureradar:ci
|
||||||
|
format: cyclonedx
|
||||||
|
output: vacatureradar-sbom.cdx.json
|
||||||
|
scan-type: image
|
||||||
|
- name: Upload software bill of materials
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: vacatureradar-sbom
|
||||||
|
path: vacatureradar-sbom.cdx.json
|
||||||
- name: Inspect image configuration
|
- name: Inspect image configuration
|
||||||
run: docker image inspect vacatureradar:ci > /tmp/image-inspect.json
|
run: docker image inspect vacatureradar:ci > /tmp/image-inspect.json
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Architectuur
|
||||||
|
|
||||||
|
VacatureRadar gebruikt een Django-modulaire monoliet met PostgreSQL, Redis en Celery. Bedrijfsregels staan in domeinservices, views en tasks orkestreren en bronadapters leveren `ExtractionResult` zonder databasewrites.
|
||||||
|
|
||||||
|
Lees verder:
|
||||||
|
|
||||||
|
- [Systeemarchitectuur](docs/architecture/SYSTEM_ARCHITECTURE.md)
|
||||||
|
- [Datastroom](docs/architecture/DATA_FLOW.md)
|
||||||
|
- [Datamodel](docs/architecture/DATA_MODEL.md)
|
||||||
|
- [Bronadapters](docs/architecture/SOURCE_ADAPTERS.md)
|
||||||
|
- [Securityarchitectuur](docs/architecture/SECURITY_ARCHITECTURE.md)
|
||||||
|
- [Scoring](docs/architecture/SCORING_ENGINE.md)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Back-up en herstel
|
||||||
|
|
||||||
|
De uitvoerbare procedure, retentie, veilige testrestore en vergelijkingsstappen staan in [docs/operations/BACKUP_RESTORE.md](docs/operations/BACKUP_RESTORE.md). Een productieback-up geldt pas als bewezen nadat hij naar een geïsoleerd doel is hersteld en vergeleken. De nog vereiste live proef staat als `EXT-002` in [USER_INPUT_REQUIRED.md](USER_INPUT_REQUIRED.md).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Huidige toestand
|
||||||
|
|
||||||
|
VacatureRadar 0.3.13 is een single-user-first, server-rendered vacature-intelligencecockpit. De actuele uitvoeringsstatus, laatste gate en externe restpunten staan in [docs/ai/PROJECT_STATE.md](docs/ai/PROJECT_STATE.md). Productgebruik en installatie beginnen in [README.md](README.md).
|
||||||
|
|
||||||
|
De kern is lokaal uitvoerbaar. Live identity-, mailbox-, TLS- en restoreacceptatie vereist uitsluitend de acties in [USER_INPUT_REQUIRED.md](USER_INPUT_REQUIRED.md).
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Deployment
|
||||||
|
|
||||||
|
De standaard productieopstelling gebruikt afzonderlijke web-, worker-, scheduler-, PostgreSQL- en Rediscontainers. Voor Unraid is daarnaast een bewaakte AIO-variant beschikbaar. Poort 1226 is de configureerbare externe standaard; de applicatie luistert intern op 8080.
|
||||||
|
|
||||||
|
- [Unraiddeployment](docs/operations/UNRAID_DEPLOYMENT.md)
|
||||||
|
- [Nginx Proxy Manager](docs/operations/NGINX_PROXY_MANAGER.md)
|
||||||
|
- [Giteadeployment](docs/operations/GITEA_DEPLOYMENT.md)
|
||||||
|
- [Releasechecklist](docs/quality/RELEASE_CHECKLIST.md)
|
||||||
|
|
||||||
|
Gebruik nooit wildcardhosts of secrets uit voorbeeldbestanden. `PUBLIC_BASE_URL` is de publieke bron van waarheid voor host-, origin-, HTTPS- en cookieconfiguratie.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Operations
|
||||||
|
|
||||||
|
Dagelijks beheer omvat readiness, worker/beat-heartbeat, bronruns, notificatie-outbox, back-upouderdom en foutcategorieën. De automationcockpit toont alleen echte database- en cachewaarden; niet-meetbare queuediepte blijft expliciet onbekend.
|
||||||
|
|
||||||
|
- [Operationeel runbook](docs/operations/RUNBOOK.md)
|
||||||
|
- [Broncatalogus](docs/operations/LIVE_SOURCE_CATALOG.md)
|
||||||
|
- [Back-up en herstel](docs/operations/BACKUP_RESTORE.md)
|
||||||
|
- [Externe releaseacties](USER_INPUT_REQUIRED.md)
|
||||||
@@ -177,7 +177,8 @@ Lees vóór productiegebruik [SECURITY.md](SECURITY.md) en het [threat model](do
|
|||||||
De kern is een server-rendered Django 5.2-modulaire monoliet met PostgreSQL, Redis/Celery en een lokale dark/light-interface zonder runtime-CDN of SPA-afhankelijkheid. SQLite wordt gebruikt voor lokale tests.
|
De kern is een server-rendered Django 5.2-modulaire monoliet met PostgreSQL, Redis/Celery en een lokale dark/light-interface zonder runtime-CDN of SPA-afhankelijkheid. SQLite wordt gebruikt voor lokale tests.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync --frozen
|
uv sync --frozen --all-groups
|
||||||
|
uv run playwright install chromium
|
||||||
uv run python manage.py migrate
|
uv run python manage.py migrate
|
||||||
uv run python manage.py runserver 0.0.0.0:8080
|
uv run python manage.py runserver 0.0.0.0:8080
|
||||||
```
|
```
|
||||||
@@ -188,7 +189,7 @@ Voer vóór iedere oplevering de volledige gate uit:
|
|||||||
./scripts/codex_verify.sh
|
./scripts/codex_verify.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Die controleert formatting, lint, Django-configuratie, migraties, de volledige offline testset, minimaal 70% branch-aware dekking, documentatie en de taakledger. Bij wijzigingen aan de frontend controleer je daarnaast toetsenbordgebruik, dark/light en minimaal 360 px plus een gangbare desktopbreedte.
|
Die controleert formatting, lint, Django-configuratie, migraties, de volledige testset, verplichte Chromium-E2E, minimaal 70% branch-aware dekking, documentatie en de taakledger. De browsergate controleert de kernreis, focus, consolefouten en documentoverflow op mobiel, tablet, laptop, desktop, widescreen en ultrawide.
|
||||||
|
|
||||||
## Beheer en deployment
|
## Beheer en deployment
|
||||||
|
|
||||||
@@ -198,6 +199,9 @@ Die controleert formatting, lint, Django-configuratie, migraties, de volledige o
|
|||||||
- [Push-to-deploy met Gitea](docs/operations/GITEA_DEPLOYMENT.md)
|
- [Push-to-deploy met Gitea](docs/operations/GITEA_DEPLOYMENT.md)
|
||||||
- [Operationeel runbook](docs/operations/RUNBOOK.md)
|
- [Operationeel runbook](docs/operations/RUNBOOK.md)
|
||||||
- [Back-up en herstel](docs/operations/BACKUP_RESTORE.md)
|
- [Back-up en herstel](docs/operations/BACKUP_RESTORE.md)
|
||||||
|
- [Professionalisering baseline](docs/audit/BASELINE.md)
|
||||||
|
- [Performanceaudit](docs/audit/PERFORMANCE.md)
|
||||||
|
- [Finale acceptance](docs/audit/FINAL_ACCEPTANCE.md)
|
||||||
|
|
||||||
Een overdrachtsarchief zonder secrets, lokale databases of caches maak en controleer je met:
|
Een overdrachtsarchief zonder secrets, lokale databases of caches maak en controleer je met:
|
||||||
|
|
||||||
|
|||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# Testen
|
||||||
|
|
||||||
|
De verplichte lokale gate is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/codex_verify.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Deze voert lint, Django checks, migratiecontrole, unit-, integratie-, security- en verplichte Playwrighttests, coverage, ledger- en repositoryvalidatie uit. Chromium moet vooraf via `playwright install chromium` beschikbaar zijn. CI installeert de browser reproduceerbaar en bewaart screenshots bij fouten.
|
||||||
|
|
||||||
|
Zie [docs/quality/TEST_STRATEGY.md](docs/quality/TEST_STRATEGY.md) voor testlagen en [docs/quality/DEFINITION_OF_DONE.md](docs/quality/DEFINITION_OF_DONE.md) voor de releasegate.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Externe acties
|
||||||
|
|
||||||
|
Dit register bevat uitsluitend stappen die niet betrouwbaar vanuit de repository kunnen worden uitgevoerd. Alle omliggende implementatie, validatie en runbooks worden autonoom afgewerkt.
|
||||||
|
|
||||||
|
## EXT-001 — Live publieke productieacceptatie
|
||||||
|
|
||||||
|
- Waarom menselijke/externe toegang nodig is: de uiteindelijke DNS-, TLS-, Nginx Proxy Manager- en Unraid-runtime bevinden zich buiten de lokale repositoryomgeving.
|
||||||
|
- Veilige standaard: exacte `PUBLIC_BASE_URL`, host-, origin-, proxy- en secure-cookievalidatie blijven fail-fast geconfigureerd.
|
||||||
|
- Reeds geïmplementeerd: lokale configuratiechecks, healthchecks, deploy- en rollbackhelpers en reverse-proxyrunbook.
|
||||||
|
- Resterende handeling: voer de finale acceptancecheck uit op `https://vacatureradar.itworx.tech` en valideer container, worker, scheduler, Redis, login, gastdemo, assets en rollback.
|
||||||
|
- Impact zonder actie: lokale en gesimuleerde productievalidatie blijven geldig, maar de huidige live infrastructuur kan niet als opnieuw bewezen worden gerapporteerd.
|
||||||
|
- Prioriteit: P1 voor publieke release.
|
||||||
|
- Benodigde waarde/beslissing: bereikbare doelomgeving met bestaande deployrechten.
|
||||||
|
|
||||||
|
## EXT-002 — Live back-up- en restoreproef
|
||||||
|
|
||||||
|
- Waarom menselijke/externe toegang nodig is: een betekenisvolle productie-restore vereist toegang tot de echte Unraid-volumes en een door de eigenaar gekozen testvenster.
|
||||||
|
- Veilige standaard: restore gebeurt uitsluitend naar een afzonderlijke testlocatie; bestaande data wordt nooit overschreven.
|
||||||
|
- Reeds geïmplementeerd: back-up-, restore- en vergelijkingsscripts plus lokale testflow.
|
||||||
|
- Resterende handeling: plan en voer een geïsoleerde restore uit met een recente productieback-up.
|
||||||
|
- Impact zonder actie: herstelbaarheid is lokaal aantoonbaar, maar niet voor de actuele productiedataset.
|
||||||
|
- Prioriteit: P1 voor disaster recovery.
|
||||||
|
- Benodigde waarde/beslissing: testvenster en geïsoleerd restoredoel.
|
||||||
|
|
||||||
|
## EXT-003 — Externe identity- en mailcredentials
|
||||||
|
|
||||||
|
- Waarom menselijke/externe toegang nodig is: Entra-appregistratie en mailboxcredentials zijn accountgebonden secrets.
|
||||||
|
- Veilige standaard: integraties blijven uitgeschakeld zonder volledige configuratie; lokale login en fixtures blijven werken.
|
||||||
|
- Reeds geïmplementeerd: versleutelde credentialopslag, configuratievalidatie, mocks en negatieve tests.
|
||||||
|
- Resterende handeling: vul de bestaande productievariabelen en appregistratie in en voer de beschreven smoke tests uit.
|
||||||
|
- Impact zonder actie: lokale authenticatie en handmatige/e-mailfixture-ingress blijven bruikbaar; de betreffende live integraties blijven inactief.
|
||||||
|
- Prioriteit: P2, alleen wanneer deze integraties gewenst zijn.
|
||||||
|
- Benodigde waarde/beslissing: geldige Entra- en/of mailboxconfiguratie.
|
||||||
@@ -4,6 +4,7 @@ from datetime import timedelta
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
from django.db.models import Count, Q, Sum
|
from django.db.models import Count, Q, Sum
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
@@ -14,6 +15,32 @@ from apps.profiles.models import SearchProfile
|
|||||||
from apps.sources.models import Source, SourceRun
|
from apps.sources.models import Source, SourceRun
|
||||||
|
|
||||||
from ..health import readiness
|
from ..health import readiness
|
||||||
|
from ..tasks import RUNTIME_HEARTBEAT_KEY
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_heartbeat_state() -> dict[str, Any]:
|
||||||
|
if settings.CELERY_TASK_ALWAYS_EAGER:
|
||||||
|
return {"label": "Eager/lokaal", "healthy": True, "observed_at": None}
|
||||||
|
|
||||||
|
raw_timestamp = cache.get(RUNTIME_HEARTBEAT_KEY)
|
||||||
|
if not raw_timestamp:
|
||||||
|
return {"label": "Geen recente heartbeat", "healthy": False, "observed_at": None}
|
||||||
|
|
||||||
|
try:
|
||||||
|
observed_at = timezone.datetime.fromisoformat(str(raw_timestamp))
|
||||||
|
if timezone.is_naive(observed_at):
|
||||||
|
observed_at = timezone.make_aware(observed_at)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {"label": "Ongeldige heartbeat", "healthy": False, "observed_at": None}
|
||||||
|
|
||||||
|
age_seconds = max(0, int((timezone.now() - observed_at).total_seconds()))
|
||||||
|
return {
|
||||||
|
"label": f"Actief · {age_seconds}s geleden"
|
||||||
|
if age_seconds <= 150
|
||||||
|
else "Heartbeat verouderd",
|
||||||
|
"healthy": age_seconds <= 150,
|
||||||
|
"observed_at": observed_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_automation_cockpit() -> dict[str, Any]:
|
def build_automation_cockpit() -> dict[str, Any]:
|
||||||
@@ -84,6 +111,7 @@ def build_automation_cockpit() -> dict[str, Any]:
|
|||||||
"unit": "verzonden / 24u",
|
"unit": "verzonden / 24u",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
runtime_heartbeat = _runtime_heartbeat_state()
|
||||||
return {
|
return {
|
||||||
"health": readiness(),
|
"health": readiness(),
|
||||||
"source_total": source_total,
|
"source_total": source_total,
|
||||||
@@ -103,7 +131,8 @@ def build_automation_cockpit() -> dict[str, Any]:
|
|||||||
"source_rows": Source.objects.annotate(run_count=Count("runs")).order_by("name")[:12],
|
"source_rows": Source.objects.annotate(run_count=Count("runs")).order_by("name")[:12],
|
||||||
"queue_state": "Eager/lokaal"
|
"queue_state": "Eager/lokaal"
|
||||||
if settings.CELERY_TASK_ALWAYS_EAGER
|
if settings.CELERY_TASK_ALWAYS_EAGER
|
||||||
else "Geconfigureerd, worker niet gemeten",
|
else "Diepte niet beschikbaar",
|
||||||
|
"runtime_heartbeat": runtime_heartbeat,
|
||||||
"ai_state": "Geconfigureerd"
|
"ai_state": "Geconfigureerd"
|
||||||
if settings.OLLAMA_ENABLED and settings.OLLAMA_MODEL
|
if settings.OLLAMA_ENABLED and settings.OLLAMA_MODEL
|
||||||
else "Niet geconfigureerd",
|
else "Niet geconfigureerd",
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
RUNTIME_HEARTBEAT_KEY = "vacatureradar:runtime:heartbeat"
|
||||||
|
RUNTIME_HEARTBEAT_TTL_SECONDS = 180
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="apps.core.tasks.record_runtime_heartbeat", ignore_result=True)
|
||||||
|
def record_runtime_heartbeat() -> str:
|
||||||
|
"""Record proof that beat dispatch and a worker execution both succeeded."""
|
||||||
|
|
||||||
|
timestamp = timezone.now().isoformat()
|
||||||
|
cache.set(RUNTIME_HEARTBEAT_KEY, timestamp, timeout=RUNTIME_HEARTBEAT_TTL_SECONDS)
|
||||||
|
return timestamp
|
||||||
+1
-1
@@ -144,7 +144,7 @@ class JobListView(LoginRequiredMixin, ListView):
|
|||||||
model = JobPosting
|
model = JobPosting
|
||||||
template_name = "jobs/list.html"
|
template_name = "jobs/list.html"
|
||||||
context_object_name = "jobs"
|
context_object_name = "jobs"
|
||||||
paginate_by = 30
|
paginate_by = 20
|
||||||
|
|
||||||
VALID_RECOMMENDATIONS = {
|
VALID_RECOMMENDATIONS = {
|
||||||
ScoreRun.Recommendation.STRONG,
|
ScoreRun.Recommendation.STRONG,
|
||||||
|
|||||||
@@ -9,13 +9,15 @@ from .base import ExtractedJob, ExtractionResult, FieldEvidence
|
|||||||
|
|
||||||
LABEL_PATTERNS = {
|
LABEL_PATTERNS = {
|
||||||
"location": re.compile(r"^(locatie|location|lieu|plaats|standplaats)\s*:?$", re.I),
|
"location": re.compile(r"^(locatie|location|lieu|plaats|standplaats)\s*:?$", re.I),
|
||||||
|
"date_posted": re.compile(r"^(publicatiedatum|geplaatst|date posted|published)\s*:?$", re.I),
|
||||||
|
"employment_type": re.compile(r"^(dienstverband|contract|employment type)\s*:?$", re.I),
|
||||||
"employer": re.compile(r"^(werkgever|employer|company|organisatie|société)\s*:?$", re.I),
|
"employer": re.compile(r"^(werkgever|employer|company|organisatie|société)\s*:?$", re.I),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class GenericHtmlAdapter:
|
class GenericHtmlAdapter:
|
||||||
parser_key = "generic-html"
|
parser_key = "generic-html"
|
||||||
parser_version = "1.0.0"
|
parser_version = "1.1.0"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _meta(soup: BeautifulSoup, *names: str) -> str:
|
def _meta(soup: BeautifulSoup, *names: str) -> str:
|
||||||
@@ -59,6 +61,8 @@ class GenericHtmlAdapter:
|
|||||||
soup, LABEL_PATTERNS["employer"]
|
soup, LABEL_PATTERNS["employer"]
|
||||||
)
|
)
|
||||||
location = self._label_value(soup, LABEL_PATTERNS["location"])
|
location = self._label_value(soup, LABEL_PATTERNS["location"])
|
||||||
|
date_posted = self._label_value(soup, LABEL_PATTERNS["date_posted"])
|
||||||
|
employment_type = self._label_value(soup, LABEL_PATTERNS["employment_type"])
|
||||||
main = soup.find("main") or soup.find("article") or soup.body
|
main = soup.find("main") or soup.find("article") or soup.body
|
||||||
description_html = str(main) if main else ""
|
description_html = str(main) if main else ""
|
||||||
description_text = (
|
description_text = (
|
||||||
@@ -74,6 +78,12 @@ class GenericHtmlAdapter:
|
|||||||
FieldEvidence("location_text", "html-label", 0.62, location[:240]),
|
FieldEvidence("location_text", "html-label", 0.62, location[:240]),
|
||||||
FieldEvidence("description", "html-main", 0.70, description_text[:300]),
|
FieldEvidence("description", "html-main", 0.70, description_text[:300]),
|
||||||
]
|
]
|
||||||
|
if date_posted:
|
||||||
|
evidence.append(FieldEvidence("date_posted", "html-label", 0.62, date_posted[:40]))
|
||||||
|
if employment_type:
|
||||||
|
evidence.append(
|
||||||
|
FieldEvidence("employment_types", "html-label", 0.62, employment_type[:120])
|
||||||
|
)
|
||||||
job = ExtractedJob(
|
job = ExtractedJob(
|
||||||
url=job_url,
|
url=job_url,
|
||||||
title=title,
|
title=title,
|
||||||
@@ -81,6 +91,8 @@ class GenericHtmlAdapter:
|
|||||||
location_text=location,
|
location_text=location,
|
||||||
description_html=description_html,
|
description_html=description_html,
|
||||||
description_text=description_text,
|
description_text=description_text,
|
||||||
|
date_posted=date_posted,
|
||||||
|
employment_types=[employment_type] if employment_type else [],
|
||||||
raw={"generic_html": True},
|
raw={"generic_html": True},
|
||||||
evidence=evidence,
|
evidence=evidence,
|
||||||
)
|
)
|
||||||
|
|||||||
+5
-1
@@ -139,7 +139,7 @@ CSRF_TRUSTED_ORIGINS = merge_unique(
|
|||||||
)
|
)
|
||||||
TIME_ZONE = os.getenv("DJANGO_TIME_ZONE", "Europe/Brussels")
|
TIME_ZONE = os.getenv("DJANGO_TIME_ZONE", "Europe/Brussels")
|
||||||
VACATURERADAR_OWNER_NAME = os.getenv("VACATURERADAR_OWNER_NAME", "Jens").strip()[:40] or "Jens"
|
VACATURERADAR_OWNER_NAME = os.getenv("VACATURERADAR_OWNER_NAME", "Jens").strip()[:40] or "Jens"
|
||||||
VACATURERADAR_VERSION = os.getenv("VACATURERADAR_VERSION", "0.3.12").strip() or "0.3.12"
|
VACATURERADAR_VERSION = os.getenv("VACATURERADAR_VERSION", "0.3.13").strip() or "0.3.13"
|
||||||
LANGUAGE_CODE = "nl-be"
|
LANGUAGE_CODE = "nl-be"
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
@@ -369,6 +369,10 @@ if DEBUG and not MAILBOX_CREDENTIAL_KEYS:
|
|||||||
]
|
]
|
||||||
|
|
||||||
CELERY_BEAT_SCHEDULE = {
|
CELERY_BEAT_SCHEDULE = {
|
||||||
|
"runtime-heartbeat": {
|
||||||
|
"task": "apps.core.tasks.record_runtime_heartbeat",
|
||||||
|
"schedule": 60.0,
|
||||||
|
},
|
||||||
"schedule-mailbox-polls": {
|
"schedule-mailbox-polls": {
|
||||||
"task": "apps.sources.tasks.schedule_mailbox_polls",
|
"task": "apps.sources.tasks.schedule_mailbox_polls",
|
||||||
"schedule": 300.0,
|
"schedule": 300.0,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ DJANGO_ALLOWED_HOSTS=vacatureradar.example.be,127.0.0.1,localhost
|
|||||||
DJANGO_CSRF_TRUSTED_ORIGINS=https://vacatureradar.example.be
|
DJANGO_CSRF_TRUSTED_ORIGINS=https://vacatureradar.example.be
|
||||||
DJANGO_TIME_ZONE=Europe/Brussels
|
DJANGO_TIME_ZONE=Europe/Brussels
|
||||||
VACATURERADAR_OWNER_NAME=Jens
|
VACATURERADAR_OWNER_NAME=Jens
|
||||||
VACATURERADAR_VERSION=0.3.12
|
VACATURERADAR_VERSION=0.3.13
|
||||||
|
|
||||||
POSTGRES_DB=vacatureradar
|
POSTGRES_DB=vacatureradar
|
||||||
POSTGRES_USER=vacatureradar
|
POSTGRES_USER=vacatureradar
|
||||||
|
|||||||
+47
-1
@@ -1,6 +1,6 @@
|
|||||||
schema_version: 1
|
schema_version: 1
|
||||||
project: VacatureRadar
|
project: VacatureRadar
|
||||||
updated_at: '2026-07-28'
|
updated_at: '2026-07-29'
|
||||||
execution_policy:
|
execution_policy:
|
||||||
order: file-order, then first ready task whose dependencies are done
|
order: file-order, then first ready task whose dependencies are done
|
||||||
no_product_questions: true
|
no_product_questions: true
|
||||||
@@ -2283,3 +2283,49 @@ tasks:
|
|||||||
note: README verdiept tot een klikbare user-first productreis met negen actuele beelden op 1920x1080 en 390x844
|
note: README verdiept tot een klikbare user-first productreis met negen actuele beelden op 1920x1080 en 390x844
|
||||||
in dark/light; alle routes zonder overflow of consolewaarschuwingen. VR-225-lint en Windows-modusassertie
|
in dark/light; alle routes zonder overflow of consolewaarschuwingen. VR-225-lint en Windows-modusassertie
|
||||||
hersteld; 296 tests groen, 2 skips en 83,44% dekking.
|
hersteld; 296 tests groen, 2 skips en 83,44% dekking.
|
||||||
|
- id: VR-227
|
||||||
|
title: Professionaliseer VacatureRadar integraal voor release 0.3.13
|
||||||
|
status: done
|
||||||
|
priority: P0
|
||||||
|
requirement_ids:
|
||||||
|
- NFR-001
|
||||||
|
- NFR-006
|
||||||
|
- NFR-007
|
||||||
|
- NFR-008
|
||||||
|
- NFR-009
|
||||||
|
- NFR-010
|
||||||
|
depends_on:
|
||||||
|
- VR-226
|
||||||
|
summary: Voer de integrale responsive, UX-, accessibility-, performance-, observability-, security- en operationsroadmap
|
||||||
|
uit en vervang optionele browsercontrole door een verplichte releasegate.
|
||||||
|
acceptance_criteria:
|
||||||
|
- Hoofdcontent en topbar delen een gecentreerd adaptief frame; gastbanner, sticky elementen en pipeline blijven
|
||||||
|
bruikbaar van 390 tot 3440 px zonder documentoverflow.
|
||||||
|
- De vacatureflow is begrensd gepagineerd, globale zoekcopy is eerlijk, mobiel pipelinegebruik is verticaal en
|
||||||
|
demomutaties veroorzaken geen controlmuur.
|
||||||
|
- Playwright Chromium draait verplicht in CI op zes viewports, faalt op console/page errors en controleert focus,
|
||||||
|
kernreis en documentoverflow zonder skips.
|
||||||
|
- Automatisering toont een echte worker/beat-heartbeat en markeert niet-meetbare queuediepte eerlijk; benchmarkparserdekking
|
||||||
|
is volledig en reproduceerbaar.
|
||||||
|
- Baseline, performance, architectuur, operations, testing, traceability, externe acties en finale acceptance
|
||||||
|
zijn actueel en de volledige repositorygate slaagt.
|
||||||
|
verification:
|
||||||
|
- python -m scripts.benchmark --quick --skip-performance --json
|
||||||
|
- python -m pytest tests/e2e/test_vr114_browser_and_accessibility.py
|
||||||
|
- python -m pytest tests/unit/test_runtime_observability.py tests/unit/test_generic_html_adapter.py
|
||||||
|
- docker compose config
|
||||||
|
- ./scripts/codex_verify.sh
|
||||||
|
primary_paths:
|
||||||
|
- static/css/
|
||||||
|
- static/js/app.js
|
||||||
|
- templates/base.html
|
||||||
|
- apps/core/services/automation.py
|
||||||
|
- apps/core/tasks.py
|
||||||
|
- tests/e2e/test_vr114_browser_and_accessibility.py
|
||||||
|
- docs/audit/
|
||||||
|
- USER_INPUT_REQUIRED.md
|
||||||
|
result:
|
||||||
|
completed_at: '2026-07-29'
|
||||||
|
note: Release 0.3.13 professionaliseert responsive layout, mobiele gastflow, begrensde vacature-UX, verplichte
|
||||||
|
zes-viewport-Playwrightgate zonder skips, echte runtimeheartbeat, parserbenchmark, authdekking, SBOM en integrale
|
||||||
|
documentatie; volledige gate 312 tests groen, 84,16% dekking.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Projectstatus
|
# Projectstatus
|
||||||
|
|
||||||
- Laatst bijgewerkt: 2026-07-28
|
- Laatst bijgewerkt: 2026-07-29
|
||||||
- Repositoryversie: 0.3.12 productie- en reverse-proxyhardening
|
- Repositoryversie: 0.3.13 integrale professionalisering
|
||||||
- Uitvoeringsmodus: autonome backlog
|
- Uitvoeringsmodus: autonome backlog
|
||||||
- Eerste uitvoerbare taak: controleer met `python scripts/backlog.py next`
|
- Eerste uitvoerbare taak: controleer met `python scripts/backlog.py next`
|
||||||
|
|
||||||
@@ -21,6 +21,18 @@ De repository bevat een uitvoerbare Django-MVP met:
|
|||||||
|
|
||||||
## Laatste productiehardening
|
## Laatste productiehardening
|
||||||
|
|
||||||
|
VR-227 op 2026-07-29:
|
||||||
|
|
||||||
|
- globaal contentframe en topbar zijn gecentreerd tot ultrawide; de gastbanner staat in de normale hoofdflow en de mobiele pipeline is verticaal;
|
||||||
|
- vacaturepaginering is begrensd tot twintig resultaten, zoekscope is eerlijk en recente zoektermen blijven lokaal tot vijf items beperkt;
|
||||||
|
- Playwright Chromium is een verplichte CI-gate op zes viewports en faalt op focusverlies, overflow, console- en page-errors; de volledige gebruikersreis draait zonder skips;
|
||||||
|
- cachegebaseerde worker/beat-heartbeat vervangt de vroegere ongemeten runtimeclaim; onbekende queuediepte blijft expliciet onbekend;
|
||||||
|
- generieke HTML-extractie ondersteunt publicatiedatum en dienstverband; benchmarkkwaliteitsrun behaalt 12/12 velden, precision/recall 1,0 en nul rankingchurn;
|
||||||
|
- authbackenddekking bedraagt 97%, Entra 86% en totale branch-aware dekking 84,16%; volledige gate groen met 312 tests, 0 failures en 0 skips;
|
||||||
|
- CI genereert een CycloneDX-SBOM. Docker kon lokaal niet worden uitgevoerd omdat de binary ontbreekt; live en externe restpunten staan uitsluitend in `USER_INPUT_REQUIRED.md`.
|
||||||
|
|
||||||
|
Volgende uitvoerbare taak: geen na afronding van VR-227.
|
||||||
|
|
||||||
VR-225 op 2026-07-27:
|
VR-225 op 2026-07-27:
|
||||||
|
|
||||||
- `PUBLIC_BASE_URL` stuurt publieke host-, origin- en HTTPS-instellingen aan en voorkomt de gemelde `DisallowedHost` achter Nginx Proxy Manager;
|
- `PUBLIC_BASE_URL` stuurt publieke host-, origin- en HTTPS-instellingen aan en voorkomt de gemelde `DisallowedHost` achter Nginx Proxy Manager;
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Professionalisering baseline
|
||||||
|
|
||||||
|
- Datum: 2026-07-29
|
||||||
|
- Branch: `main`
|
||||||
|
- Versie: 0.3.12
|
||||||
|
- Opdracht: volledige autonome professionalisering van VacatureRadar
|
||||||
|
|
||||||
|
## Architectuur
|
||||||
|
|
||||||
|
VacatureRadar is een Django 5.2 server-rendered modulaire monoliet op Python 3.13. PostgreSQL is de productiedatabase; Redis ondersteunt cache, gedeelde rate limiting en Celery. Een afzonderlijke worker en scheduler verwerken bronruns, lifecycle en notificaties. Externe bronnen lopen via policy-, robots-, TLS- en SSRF-validatie en adapters leveren genormaliseerde extractieresultaten zonder databasewrites.
|
||||||
|
|
||||||
|
De frontend bestaat uit Django-templates, lokale CSS en beperkte lokale JavaScript. Er is geen clientframework of externe runtimeasset. Authenticatie ondersteunt lokale accounts, een optionele Entra-boundary en een server-side alleen-lezen gastdemo.
|
||||||
|
|
||||||
|
## Baselinevalidatie
|
||||||
|
|
||||||
|
`./scripts/codex_verify.sh` start schoon: Ruff, Django system check en migratiecheck slagen. De bestaande suite bevat 298 tests. In de laatst volledig afgeronde gate slaagden 296 tests en werden twee browsercases overgeslagen omdat Playwright lokaal niet geïnstalleerd was; branch-aware dekking bedroeg 83,44%.
|
||||||
|
|
||||||
|
CI voert lint, tests, secret scanning, dependency scanning, een productie-imagebuild en containerscan uit. De browserdependency wordt wel gesynchroniseerd, maar Chromium wordt nog niet geïnstalleerd en de E2E-test staat omgevingsafhankelijke skips toe.
|
||||||
|
|
||||||
|
## Werkende onderdelen
|
||||||
|
|
||||||
|
- Negen functionele hoofdschermen, login en wachtwoordherstel.
|
||||||
|
- Deterministische relevantie, scoring, dedupe en lifecycle vóór optionele AI.
|
||||||
|
- Read-only gastdemo met server-side mutatiebescherming.
|
||||||
|
- Bronbeleid, sanitization, SSRF-, TLS- en redirectbescherming.
|
||||||
|
- Docker-, Unraid-, reverse-proxy-, health-, back-up- en restorevoorzieningen.
|
||||||
|
- Dark/light theme, toetsenbordfocus, reduced-motionbasis en mobiele hoofdnavigatie.
|
||||||
|
|
||||||
|
## Belangrijkste risico's bij start
|
||||||
|
|
||||||
|
1. De premium CSS overschrijft het gecentreerde layoutcontract met links verankerde content. Op 3440 px blijft ongeveer 900 px ongebruikte ruimte rechts.
|
||||||
|
2. De gastbanner begint mobiel boven de vaste topbar en is daardoor gedeeltelijk onzichtbaar.
|
||||||
|
3. Verplichte browser- en accessibilitytests kunnen stilzwijgend skippen.
|
||||||
|
4. De vacatureverkenner rendert een zeer lange resultaatpagina; selectie- en scrollcontext zijn beperkt.
|
||||||
|
5. Bronnen en automatisering tonen veel operationele controls en informatie tegelijk.
|
||||||
|
6. Kritieke dekking is relatief laag in auth, Entra, core views en task orchestration.
|
||||||
|
7. Enkele modules zijn groter dan de repositoryrichtlijn en combineren meerdere verantwoordelijkheden.
|
||||||
|
8. Live TLS-, reverse-proxy-, worker-, scheduler-, back-up- en restoreacceptatie blijft doelomgevingafhankelijk.
|
||||||
|
|
||||||
|
## Eerste uitvoeringsvolgorde
|
||||||
|
|
||||||
|
1. Responsive layout en gastbanner.
|
||||||
|
2. Niet-skipbare browser-/accessibilitymatrix.
|
||||||
|
3. Dagelijkse kernflows en taakgerichte schermcomposities.
|
||||||
|
4. Kritieke tests, observability, container/security en herstelvalidatie.
|
||||||
|
5. Integrale eindacceptatie en live restpunten.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Finale acceptance 0.3.13
|
||||||
|
|
||||||
|
Status: lokaal geaccepteerd; live externe acties blijven afzonderlijk geregistreerd.
|
||||||
|
|
||||||
|
## Bewijs
|
||||||
|
|
||||||
|
- `./scripts/codex_verify.sh`: geslaagd.
|
||||||
|
- Tests: 312 geslaagd, 0 mislukt, 0 overgeslagen, 1 niet-blokkerende waarschuwing.
|
||||||
|
- Branch-aware coverage: 84,16%.
|
||||||
|
- Playwright: zes viewports van 390×844 tot 3440×1200, volledige kernreis, reduced motion, focus, console/page errors en documentoverflow geslaagd.
|
||||||
|
- Benchmarkkwaliteitsrun: 12/12 parservelden, dedupeprecision 1,0, recall 1,0 en rankingchurn 0,0.
|
||||||
|
- Django system check en migratiecheck: schoon.
|
||||||
|
- Repository-, YAML-, ledger- en dependencyconsistentie: schoon; `pip check` meldt geen gebroken dependencies.
|
||||||
|
- Docker/compose-runtime: niet uitgevoerd omdat de lokale omgeving geen Dockerbinary bevat. Composebestanden blijven regressiegedekt en CI bouwt, scant en produceert een CycloneDX-SBOM.
|
||||||
|
|
||||||
|
## Uitkomst
|
||||||
|
|
||||||
|
Er bestaan geen bekende lokale P0- of P1-blokkers. Live TLS/proxy, productie-restore en optionele externe identity/mailintegraties worden uitsluitend overgenomen uit `USER_INPUT_REQUIRED.md`; lokale validatie wordt niet als live bewijs gepresenteerd.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Performanceaudit 2026-07-29
|
||||||
|
|
||||||
|
## Baseline
|
||||||
|
|
||||||
|
- Lokale quickbenchmark: 250 invoeritems op Windows 11, Python 3.13, 16 CPU-threads.
|
||||||
|
- Import: p50 348,879 ms; p95 825,107 ms; totaal 125.982,74 ms.
|
||||||
|
- Dashboardlijst: p95 1.128,986 ms in de synthetische benchmark.
|
||||||
|
- Detail: p95 101,754 ms.
|
||||||
|
- Rescore: 53,284 ms voor de overgebleven unieke job in deze quickrun.
|
||||||
|
- Piekgeheugen: 105,588 MB.
|
||||||
|
- Frontend: lokale CSS en JavaScript, zonder framework of externe runtimeassets.
|
||||||
|
|
||||||
|
De eerste run ontdekte een echte benchmarkfout: de generieke HTML-fixture leverde slechts vier van zes verwachte velden. De adapter en fixture ondersteunen nu ook gelabelde publicatiedatum en dienstverband. De herhaalde kwaliteitsrun behaalt 12/12 parservelden, dedupeprecision 1,0, recall 1,0 en nul rankingchurn.
|
||||||
|
|
||||||
|
## Wijzigingen
|
||||||
|
|
||||||
|
- Vacaturepaginering verlaagd van 30 naar 20 kaarten per pagina.
|
||||||
|
- Ultrawideframe gecentreerd zonder extra clientcode.
|
||||||
|
- Mobiele pipeline gebruikt een verticale grid en veroorzaakt geen horizontale documentoverflow.
|
||||||
|
- Runtimeheartbeat gebruikt de bestaande cache en voegt geen netwerkprobe aan paginarendering toe.
|
||||||
|
|
||||||
|
## Resterende risico's
|
||||||
|
|
||||||
|
- De synthetische quickimport overschrijdt de historische NFR-007-p95-doelwaarde; profiling van het volledige 25.000-itempad blijft zinvol vóór opschaling.
|
||||||
|
- De quickbenchmark meldt meer dan 7.900 databasequeries voor 250 importpogingen. Correctheid en idempotentie zijn goed, maar bulkimport is de eerstvolgende optimalisatiekandidaat.
|
||||||
|
- Queuediepte is met de generieke Django-cachebackend niet betrouwbaar meetbaar en wordt daarom eerlijk als onbekend getoond.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Professionaliseringstraceability
|
||||||
|
|
||||||
|
| Roadmaponderdeel | Implementatie | Bewijs | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Responsive frame | `premium.css`, `layout.css` | zes Playwrightviewports, overflowassertie | implemented and verified |
|
||||||
|
| Mobiele gastbanner | `base.html`, `components.css` | integratietest en mobiele E2E | implemented and verified |
|
||||||
|
| Sticky/mobiele pipeline | `responsive.css`, `premium.css` | mobiele kernreis | implemented and verified |
|
||||||
|
| Verplichte browsergate | CI, E2E-suite | Chromiuminstallatie, console/page-errorchecks | implemented and verified |
|
||||||
|
| Vacatureflow | `JobListView`, `jobs/list.html` | view- en E2E-tests | implemented and verified |
|
||||||
|
| Zoekscope en recente termen | `base.html`, `app.js` | lokale, begrensde browseropslag | implemented and verified |
|
||||||
|
| Democontrols | read-only middleware en responsive CSS | auth- en E2E-tests | implemented and verified |
|
||||||
|
| Runtimeobservability | `apps/core/tasks.py`, automationservice | runtime-observabilitytests | implemented and verified |
|
||||||
|
| Parser/performance | generic HTML-adapter en benchmarkfixture | benchmark 12/12, precision/recall 1,0 | implemented and verified |
|
||||||
|
| Container/security | bestaande Docker/CI-hardening | compose-, config-, security- en containerscans | implemented, live verification pending |
|
||||||
|
| Live TLS/restore/identity | scripts en runbooks | `USER_INPUT_REQUIRED.md` | externally blocked, implementation complete |
|
||||||
@@ -84,6 +84,7 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel
|
|||||||
Iedere backlogtaak die een requirement wijzigt, werkt in dezelfde commit deze matrix, relevante acceptatiecriteria en tests bij. Een status wordt alleen naar **I** gezet wanneer de code én het genoemde bewijs bestaan.
|
Iedere backlogtaak die een requirement wijzigt, werkt in dezelfde commit deze matrix, relevante acceptatiecriteria en tests bij. Een status wordt alleen naar **I** gezet wanneer de code én het genoemde bewijs bestaan.
|
||||||
|
|
||||||
## Aanvullende traceability-opmerking
|
## Aanvullende traceability-opmerking
|
||||||
|
- VR-227 professionaliseert release 0.3.13 integraal: gecentreerd ultrawideframe, mobiele gastbanner en verticale pipeline, begrensde vacaturepaginering, verplichte zes-viewport-Playwrightgate zonder skips, echte runtimeheartbeat, volledige generieke parserbenchmark en SBOM-generatie. Bewijs staat in `docs/audit/`, de E2E-/auth-/runtime-/adaptertests en `USER_INPUT_REQUIRED.md`.
|
||||||
- VR-102 is gerealiseerd met auditbare policyreviews (`SourcePolicyReview`), robotscache met TTL/size/SSRF-controles en fail-closed policy-gating op verlopen of conflicterende reviews.
|
- VR-102 is gerealiseerd met auditbare policyreviews (`SourcePolicyReview`), robotscache met TTL/size/SSRF-controles en fail-closed policy-gating op verlopen of conflicterende reviews.
|
||||||
- VR-107 is gerealiseerd met `apps/sources/views.py`, `apps/sources/services/manual_import.py`, `templates/sources/list.html`, `static/js/manual_import.js` en bijhorende unit/integration-tests.
|
- VR-107 is gerealiseerd met `apps/sources/views.py`, `apps/sources/services/manual_import.py`, `templates/sources/list.html`, `static/js/manual_import.js` en bijhorende unit/integration-tests.
|
||||||
- VR-115 is afgerond met productie-hardening voor `config/settings.py`, DNS-rebindmitigatie in `apps/sources/services/fetcher.py`, rate limiting op login en handmatige import, CI-secret- en kwetsbaarheidsscans, non-root/read-only containerhardening en bijbehorende security/unit/integration-tests.
|
- VR-115 is afgerond met productie-hardening voor `config/settings.py`, DNS-rebindmitigatie in `apps/sources/services/fetcher.py`, rate limiting op login en handmatige import, CI-secret- en kwetsbaarheidsscans, non-root/read-only containerhardening en bijbehorende security/unit/integration-tests.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
"url": "https://careers.example.net/jobs/workplace-engineer",
|
"url": "https://careers.example.net/jobs/workplace-engineer",
|
||||||
"expected_job_count": 1,
|
"expected_job_count": 1,
|
||||||
"expected_parser_key": "generic-html",
|
"expected_parser_key": "generic-html",
|
||||||
"expected_parser_version": "1.0.0",
|
"expected_parser_version": "1.1.0",
|
||||||
"expected_fields": [
|
"expected_fields": [
|
||||||
"title",
|
"title",
|
||||||
"employer_name",
|
"employer_name",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
<!doctype html><html lang="nl"><head><title>Workplace Engineer - Example Health</title><meta property="og:site_name" content="Example Health"><link rel="canonical" href="https://careers.example.net/jobs/workplace-engineer"></head><body><main><h1>Workplace Engineer</h1><dl><dt>Locatie</dt><dd>Genk, Limburg</dd></dl><section><h2>Jouw rol</h2><p>Je beheert endpoints, Microsoft Intune en Microsoft 365. De functie bevat occasionele tweedelijnssupport en hybride werk.</p></section></main></body></html>
|
<!doctype html><html lang="nl"><head><title>Workplace Engineer - Example Health</title><meta property="og:site_name" content="Example Health"><link rel="canonical" href="https://careers.example.net/jobs/workplace-engineer"></head><body><main><h1>Workplace Engineer</h1><dl><dt>Locatie</dt><dd>Genk, Limburg</dd><dt>Publicatiedatum</dt><dd>2026-07-21</dd><dt>Dienstverband</dt><dd>Voltijds</dd></dl><section><h2>Jouw rol</h2><p>Je beheert endpoints, Microsoft Intune en Microsoft 365. De functie bevat occasionele tweedelijnssupport en hybride werk.</p></section></main></body></html>
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "vacatureradar"
|
name = "vacatureradar"
|
||||||
version = "0.3.12"
|
version = "0.3.13"
|
||||||
description = "Persoonlijke autonome vacature-assistent voor toegestane publieke bronnen en vacaturemails."
|
description = "Persoonlijke autonome vacature-assistent voor toegestane publieke bronnen en vacaturemails."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12,<3.14"
|
requires-python = ">=3.12,<3.14"
|
||||||
|
|||||||
+7
-7
@@ -1,9 +1,9 @@
|
|||||||
/* Houd ook geïmporteerde stylesheets versieerbaar; alleen app.css voorzien van
|
/* Houd ook geïmporteerde stylesheets versieerbaar; alleen app.css voorzien van
|
||||||
een querystring ververst anders niet noodzakelijk de browsercache. */
|
een querystring ververst anders niet noodzakelijk de browsercache. */
|
||||||
@import url("tokens.css?v=0.3.12");
|
@import url("tokens.css?v=0.3.13");
|
||||||
@import url("base.css?v=0.3.12");
|
@import url("base.css?v=0.3.13");
|
||||||
@import url("layout.css?v=0.3.12");
|
@import url("layout.css?v=0.3.13");
|
||||||
@import url("components.css?v=0.3.12");
|
@import url("components.css?v=0.3.13");
|
||||||
@import url("pages.css?v=0.3.12");
|
@import url("pages.css?v=0.3.13");
|
||||||
@import url("responsive.css?v=0.3.12");
|
@import url("responsive.css?v=0.3.13");
|
||||||
@import url("premium.css?v=0.3.12");
|
@import url("premium.css?v=0.3.13");
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin: 12px clamp(16px, 2vw, 32px) 0;
|
margin-bottom: 24px;
|
||||||
padding: 11px 14px;
|
padding: 11px 14px;
|
||||||
border: 1px solid color-mix(in srgb, var(--cyan) 42%, var(--outline));
|
border: 1px solid color-mix(in srgb, var(--cyan) 42%, var(--outline));
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ body::before {
|
|||||||
|
|
||||||
.cockpit-sidebar { background: color-mix(in srgb, var(--surface-lowest) 97%, black); }
|
.cockpit-sidebar { background: color-mix(in srgb, var(--surface-lowest) 97%, black); }
|
||||||
.cockpit-main { padding-top: calc(var(--topbar-height) + 42px); }
|
.cockpit-main { padding-top: calc(var(--topbar-height) + 42px); }
|
||||||
.cockpit-main > * { width: min(100%, var(--content-frame)); margin-right: auto; margin-left: 0; }
|
.cockpit-main > * { width: min(100%, var(--content-frame)); margin-inline: auto; }
|
||||||
.topbar-inner { display: flex; width: min(100%, var(--content-frame)); height: 100%; align-items: center; justify-content: space-between; gap: 20px; }
|
.topbar-inner { display: flex; width: min(100%, var(--content-frame)); height: 100%; margin-inline: auto; align-items: center; justify-content: space-between; gap: 20px; }
|
||||||
.brand-radar { flex: 0 0 auto; width: 42px; height: 42px; border: 0; border-radius: 0; background: transparent; box-shadow: none; }
|
.brand-radar { flex: 0 0 auto; width: 42px; height: 42px; border: 0; border-radius: 0; background: transparent; box-shadow: none; }
|
||||||
.brand-lockup { display: grid; min-width: 0; gap: 4px; }
|
.brand-lockup { display: grid; min-width: 0; gap: 4px; }
|
||||||
.brand-lockup strong { color: var(--text-strong); font: 740 17px/1 var(--font-headline); letter-spacing: -.025em; }
|
.brand-lockup strong { color: var(--text-strong); font: 740 17px/1 var(--font-headline); letter-spacing: -.025em; }
|
||||||
@@ -198,7 +198,7 @@ label > span:first-child { font-family: var(--font-body); font-weight: 700; lett
|
|||||||
.mobile-tabbar a { font-family: var(--font-body); font-weight: 650; }
|
.mobile-tabbar a { font-family: var(--font-body); font-weight: 650; }
|
||||||
.filter-console { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 13px; }
|
.filter-console { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 13px; }
|
||||||
.filter-console .grow { grid-column: span 2; }
|
.filter-console .grow { grid-column: span 2; }
|
||||||
.pipeline-board { width: calc(100vw - (2 * var(--content-gutter))); grid-auto-columns: minmax(280px, 84vw); }
|
.pipeline-board { width: 100%; grid-auto-columns: auto; grid-auto-flow: row; grid-template-columns: 1fr; overflow-x: visible; }
|
||||||
.profile-index-card .intel-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.profile-index-card .intel-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
.profile-index-card { grid-template-columns: 1fr; }
|
.profile-index-card { grid-template-columns: 1fr; }
|
||||||
.profile-index-art { min-height: 230px; border-top: 1px solid var(--divider); border-left: 0; }
|
.profile-index-art { min-height: 230px; border-top: 1px solid var(--divider); border-left: 0; }
|
||||||
@@ -208,6 +208,9 @@ label > span:first-child { font-family: var(--font-body); font-weight: 700; lett
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
|
.demo-mode-banner { align-items: flex-start; gap: 10px; margin-bottom: 20px; padding: 10px 12px; }
|
||||||
|
.demo-mode-banner strong { font-size: .72rem; }
|
||||||
|
.demo-mode-banner small { font-size: .78rem; line-height: 1.45; }
|
||||||
.cockpit-heading { margin-bottom: 22px; }
|
.cockpit-heading { margin-bottom: 22px; }
|
||||||
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-bottom: 24px; }
|
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-bottom: 24px; }
|
||||||
.stat-card { min-height: 92px; padding: 13px; }
|
.stat-card { min-height: 92px; padding: 13px; }
|
||||||
|
|||||||
@@ -50,6 +50,8 @@
|
|||||||
.mobile-tabbar a { display: grid; min-height: 66px; place-items: center; align-content: center; gap: 4px; color: var(--text-faint); font: 10px var(--font-mono); }
|
.mobile-tabbar a { display: grid; min-height: 66px; place-items: center; align-content: center; gap: 4px; color: var(--text-faint); font: 10px var(--font-mono); }
|
||||||
.mobile-tabbar a.is-active { color: var(--cyan); }
|
.mobile-tabbar a.is-active { color: var(--cyan); }
|
||||||
.mobile-tabbar .icon { width: 19px; height: 19px; }
|
.mobile-tabbar .icon { width: 19px; height: 19px; }
|
||||||
|
.pipeline-board { width: 100%; grid-auto-columns: auto; grid-auto-flow: row; grid-template-columns: 1fr; overflow-x: visible; }
|
||||||
|
.pipeline-column { min-height: 0; }
|
||||||
.page-header, .cockpit-heading, .section-heading { align-items: flex-start; flex-direction: column; }
|
.page-header, .cockpit-heading, .section-heading { align-items: flex-start; flex-direction: column; }
|
||||||
.page-header { margin-bottom: 20px; }
|
.page-header { margin-bottom: 20px; }
|
||||||
.form-grid, .source-details-grid, .import-layout, .mailbox-form-grid, .system-hero { grid-template-columns: 1fr; }
|
.form-grid, .source-details-grid, .import-layout, .mailbox-form-grid, .system-hero { grid-template-columns: 1fr; }
|
||||||
@@ -88,6 +90,7 @@
|
|||||||
.topbar-actions { gap: 3px; }
|
.topbar-actions { gap: 3px; }
|
||||||
.stats-grid, .health-grid, .filter-console, .command-panel, .system-counts { grid-template-columns: 1fr; }
|
.stats-grid, .health-grid, .filter-console, .command-panel, .system-counts { grid-template-columns: 1fr; }
|
||||||
.filter-console .grow { grid-column: auto; }
|
.filter-console .grow { grid-column: auto; }
|
||||||
|
[data-demo-read-only="true"] button[data-demo-disabled="true"] { display: none; }
|
||||||
.stat-card { min-height: 105px; }
|
.stat-card { min-height: 105px; }
|
||||||
.job-card-main, .result-body, .panel, .tonal-panel, .health-card, .form-panel, .form-section, .detail-section { padding: 16px; }
|
.job-card-main, .result-body, .panel, .tonal-panel, .health-card, .form-panel, .form-section, .detail-section { padding: 16px; }
|
||||||
.job-card-head, .result-heading { align-items: flex-start; flex-direction: column; }
|
.job-card-head, .result-heading { align-items: flex-start; flex-direction: column; }
|
||||||
|
|||||||
@@ -23,6 +23,17 @@
|
|||||||
try { localStorage.setItem("vacatureradar-theme", next); } catch { /* Current page still updates. */ }
|
try { localStorage.setItem("vacatureradar-theme", next); } catch { /* Current page still updates. */ }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const globalSearch = document.getElementById("global-search");
|
||||||
|
globalSearch?.form?.addEventListener("submit", () => {
|
||||||
|
const query = globalSearch.value.trim();
|
||||||
|
if (!query) return;
|
||||||
|
try {
|
||||||
|
const previous = JSON.parse(localStorage.getItem("vacatureradar-recent-searches") || "[]");
|
||||||
|
const recent = [query, ...previous.filter((item) => item !== query)].slice(0, 5);
|
||||||
|
localStorage.setItem("vacatureradar-recent-searches", JSON.stringify(recent));
|
||||||
|
} catch { /* Zoeken werkt ook zonder lokale geschiedenis. */ }
|
||||||
|
});
|
||||||
|
|
||||||
const closeNavigation = () => {
|
const closeNavigation = () => {
|
||||||
frame?.classList.remove("nav-open");
|
frame?.classList.remove("nav-open");
|
||||||
navToggle?.setAttribute("aria-expanded", "false");
|
navToggle?.setAttribute("aria-expanded", "false");
|
||||||
|
|||||||
+4
-4
@@ -62,9 +62,9 @@
|
|||||||
<button class="icon-button mobile-only" type="button" data-nav-toggle aria-label="Menu openen" aria-expanded="false"><svg class="icon"><use href="#icon-menu"></use></svg></button>
|
<button class="icon-button mobile-only" type="button" data-nav-toggle aria-label="Menu openen" aria-expanded="false"><svg class="icon"><use href="#icon-menu"></use></svg></button>
|
||||||
<div class="workspace-identity desktop-label"><span>VacatureRadar</span><small>{{ page_context_label }}</small></div>
|
<div class="workspace-identity desktop-label"><span>VacatureRadar</span><small>{{ page_context_label }}</small></div>
|
||||||
<form class="command-search" method="get" action="{% url 'jobs:list' %}" role="search">
|
<form class="command-search" method="get" action="{% url 'jobs:list' %}" role="search">
|
||||||
<label class="sr-only" for="global-search">Zoek door vacatures</label>
|
<label class="sr-only" for="global-search">Zoek vacatures</label>
|
||||||
<svg class="icon" aria-hidden="true"><use href="#icon-search"></use></svg>
|
<svg class="icon" aria-hidden="true"><use href="#icon-search"></use></svg>
|
||||||
<input id="global-search" type="search" name="q" value="{% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'list' %}{{ request.GET.q }}{% endif %}" placeholder="Zoek vacatures, werkgevers of skills…">
|
<input id="global-search" type="search" name="q" value="{% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'list' %}{{ request.GET.q }}{% endif %}" placeholder="Zoek vacatures op titel, werkgever of skill…" autocomplete="off">
|
||||||
<kbd aria-hidden="true">/</kbd>
|
<kbd aria-hidden="true">/</kbd>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,14 +79,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<main id="main" class="cockpit-main" tabindex="-1">
|
||||||
{% if demo_read_only %}
|
{% if demo_read_only %}
|
||||||
<div class="demo-mode-banner" role="status">
|
<div class="demo-mode-banner" role="status" aria-label="Publieke demo, alleen-lezen">
|
||||||
<svg class="icon" aria-hidden="true"><use href="#icon-eye"></use></svg>
|
<svg class="icon" aria-hidden="true"><use href="#icon-eye"></use></svg>
|
||||||
<span><strong>Publieke demo · alleen-lezen</strong><small>Je kunt alle schermen en analyses bekijken; wijzigingen en imports zijn uitgeschakeld.</small></span>
|
<span><strong>Publieke demo · alleen-lezen</strong><small>Je kunt alle schermen en analyses bekijken; wijzigingen en imports zijn uitgeschakeld.</small></span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<main id="main" class="cockpit-main" tabindex="-1">
|
|
||||||
{% if messages %}<div class="toast-stack" aria-live="polite" aria-atomic="true">{% for message in messages %}<div class="toast {{ message.tags }}"><svg class="icon" aria-hidden="true"><use href="{% if message.tags == 'error' %}#icon-alert{% else %}#icon-check{% endif %}"></use></svg><span>{{ message }}</span></div>{% endfor %}</div>{% endif %}
|
{% if messages %}<div class="toast-stack" aria-live="polite" aria-atomic="true">{% for message in messages %}<div class="toast {{ message.tags }}"><svg class="icon" aria-hidden="true"><use href="{% if message.tags == 'error' %}#icon-alert{% else %}#icon-check{% endif %}"></use></svg><span>{{ message }}</span></div>{% endfor %}</div>{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<main id="main" class="auth-shell" tabindex="-1">
|
<main id="main" class="auth-shell" tabindex="-1">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{% block title %}Automation center · VacatureRadar{% endblock %}
|
{% block title %}Automation center · VacatureRadar{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="cockpit-heading"><div><p class="eyebrow">Automation center</p><h1>Radarbesturing</h1><p>Echte 24-uursverwerking, bronruns, notificaties en configuratiestatus. Niet-gemeten runtime-informatie blijft expliciet onbekend.</p></div><div class="page-actions"><a class="button button-ghost" href="{% url 'jobs:activity' %}"><svg class="icon"><use href="#icon-activity"></use></svg>Activiteitenlog</a><a class="button button-primary" href="{% url 'sources:list' %}"><svg class="icon"><use href="#icon-sources"></use></svg>Bronnen beheren</a></div></header>
|
<header class="cockpit-heading"><div><p class="eyebrow">Automation center</p><h1>Radarbesturing</h1><p>Echte 24-uursverwerking, bronruns, notificaties en configuratiestatus. Niet-gemeten runtime-informatie blijft expliciet onbekend.</p></div><div class="page-actions"><a class="button button-ghost" href="{% url 'jobs:activity' %}"><svg class="icon"><use href="#icon-activity"></use></svg>Activiteitenlog</a><a class="button button-primary" href="{% url 'sources:list' %}"><svg class="icon"><use href="#icon-sources"></use></svg>Bronnen beheren</a></div></header>
|
||||||
<section class="automation-status {% if health.ok %}is-healthy{% endif %}"><div><span class="field-label">Systeemstatus</span><h2><span class="status-beacon {% if health.ok %}is-live{% endif %}" aria-hidden="true"></span>{% if health.ok %}Database operationeel{% else %}Databasecontrole vereist{% endif %}</h2></div><dl><div><dt>Queue</dt><dd>{{ queue_state }}</dd></div><div><dt>AI</dt><dd>{{ ai_state }}{% if ai_profiles %} · {{ ai_profiles }} profiel{% endif %}</dd></div><div><dt>Browserextractie</dt><dd>{{ browser_state }}</dd></div></dl></section>
|
<section class="automation-status {% if health.ok and runtime_heartbeat.healthy %}is-healthy{% endif %}"><div><span class="field-label">Systeemstatus</span><h2><span class="status-beacon {% if health.ok %}is-live{% endif %}" aria-hidden="true"></span>{% if health.ok %}Database operationeel{% else %}Databasecontrole vereist{% endif %}</h2></div><dl><div><dt>Runtime</dt><dd>{{ runtime_heartbeat.label }}</dd></div><div><dt>Queue</dt><dd>{{ queue_state }}</dd></div><div><dt>AI</dt><dd>{{ ai_state }}{% if ai_profiles %} · {{ ai_profiles }} profiel{% endif %}</dd></div></dl></section>
|
||||||
|
|
||||||
<section class="automation-pipeline" aria-labelledby="pipeline-title"><span class="section-kicker">Verwerkingspipeline · laatste 24 uur</span><h2 id="pipeline-title" class="sr-only">Verwerkingspipeline</h2>{% for phase in phase_rows %}<article><span class="pipeline-phase-index">0{{ forloop.counter }}</span><svg class="icon"><use href="{% if phase.key == 'discover' %}#icon-radar{% elif phase.key == 'fetch' %}#icon-refresh{% elif phase.key == 'notify' %}#icon-mail{% else %}#icon-system{% endif %}"></use></svg><strong>{{ phase.label }}</strong><b>{{ phase.value }}</b><small>{{ phase.unit }}</small></article>{% endfor %}</section>
|
<section class="automation-pipeline" aria-labelledby="pipeline-title"><span class="section-kicker">Verwerkingspipeline · laatste 24 uur</span><h2 id="pipeline-title" class="sr-only">Verwerkingspipeline</h2>{% for phase in phase_rows %}<article><span class="pipeline-phase-index">0{{ forloop.counter }}</span><svg class="icon"><use href="{% if phase.key == 'discover' %}#icon-radar{% elif phase.key == 'fetch' %}#icon-refresh{% elif phase.key == 'notify' %}#icon-mail{% else %}#icon-system{% endif %}"></use></svg><strong>{{ phase.label }}</strong><b>{{ phase.value }}</b><small>{{ phase.unit }}</small></article>{% endfor %}</section>
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from bs4 import BeautifulSoup
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.jobs.models import Application, Feedback, ScoreRun
|
from apps.jobs.models import ScoreRun
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from playwright.sync_api import sync_playwright
|
from playwright.sync_api import sync_playwright
|
||||||
@@ -85,7 +85,7 @@ def _run_html_accessibility_probe(client, user, profile, job) -> None:
|
|||||||
response = client.get(reverse("dashboard:today"))
|
response = client.get(reverse("dashboard:today"))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
_assert_basic_html_accessibility(response.content, page_name="dashboard")
|
_assert_basic_html_accessibility(response.content, page_name="dashboard")
|
||||||
assert "Zoek door vacatures" in response.content.decode("utf-8")
|
assert "Zoek vacatures" in response.content.decode("utf-8")
|
||||||
|
|
||||||
response = client.get(reverse("jobs:list"), {"q": job.original_title})
|
response = client.get(reverse("jobs:list"), {"q": job.original_title})
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -133,17 +133,28 @@ def _run_html_accessibility_probe(client, user, profile, job) -> None:
|
|||||||
def _assert_playwright_accessibility(page, *, expected_h1: str) -> None:
|
def _assert_playwright_accessibility(page, *, expected_h1: str) -> None:
|
||||||
page.wait_for_load_state("networkidle")
|
page.wait_for_load_state("networkidle")
|
||||||
assert page.get_by_role("heading", level=1).count() == 1
|
assert page.get_by_role("heading", level=1).count() == 1
|
||||||
assert expected_h1 in page.get_by_role("heading", level=1).all_text_contents()
|
headings = page.get_by_role("heading", level=1).all_text_contents()
|
||||||
|
assert any(expected_h1 in heading for heading in headings)
|
||||||
|
|
||||||
page.keyboard.press("Tab")
|
page.keyboard.press("Tab")
|
||||||
focused_tag = page.evaluate("document.activeElement.tagName.toLowerCase()")
|
focused_tag = page.evaluate("document.activeElement.tagName.toLowerCase()")
|
||||||
assert focused_tag != "body"
|
assert focused_tag != "body"
|
||||||
outline_style = page.evaluate("getComputedStyle(document.activeElement).outlineStyle")
|
focus_indicator = page.evaluate(
|
||||||
assert outline_style != "none"
|
"""() => {
|
||||||
|
const style = getComputedStyle(document.activeElement);
|
||||||
|
return {outline: style.outlineStyle, shadow: style.boxShadow};
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
assert focus_indicator["outline"] != "none" or focus_indicator["shadow"] != "none"
|
||||||
|
|
||||||
|
viewport_width = page.evaluate("window.innerWidth")
|
||||||
|
document_width = page.evaluate("document.documentElement.scrollWidth")
|
||||||
|
assert document_width <= viewport_width + 1
|
||||||
|
|
||||||
|
|
||||||
def _take_failure_screenshot(page, tmp_path: Path, viewport_name: str) -> None:
|
def _take_failure_screenshot(page, tmp_path: Path, viewport_name: str) -> None:
|
||||||
directory = tmp_path / "artifacts"
|
del tmp_path
|
||||||
|
directory = Path(__file__).resolve().parents[2] / "artifacts" / "browser-tests"
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
page.screenshot(path=str(directory / f"vr114_e2e_failure_{viewport_name}.png"), full_page=True)
|
page.screenshot(path=str(directory / f"vr114_e2e_failure_{viewport_name}.png"), full_page=True)
|
||||||
|
|
||||||
@@ -159,14 +170,19 @@ def test_vr114_html_a11y_probe(client, user, profile, job):
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"viewport",
|
"viewport",
|
||||||
[
|
[
|
||||||
{"name": "desktop", "width": 1366, "height": 900},
|
{"name": "mobile-390", "width": 390, "height": 844},
|
||||||
{"name": "mobile-360", "width": 360, "height": 780},
|
{"name": "tablet-768", "width": 768, "height": 1024},
|
||||||
|
{"name": "laptop-1280", "width": 1280, "height": 800},
|
||||||
|
{"name": "desktop-1920", "width": 1920, "height": 1080},
|
||||||
|
{"name": "wide-2560", "width": 2560, "height": 1440},
|
||||||
|
{"name": "ultrawide-3440", "width": 3440, "height": 1200},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_vr114_browser_flow(viewport, live_server, client, tmp_path, user, profile, job):
|
def test_vr114_browser_flow(viewport, live_server, client, tmp_path, user, profile, job):
|
||||||
_seed_dashboard_score(profile=profile, job=job)
|
_seed_dashboard_score(profile=profile, job=job)
|
||||||
|
|
||||||
if sync_playwright is None:
|
if sync_playwright is None:
|
||||||
|
pytest.fail("Playwright is verplicht voor de E2E- en accessibilitygate.")
|
||||||
_run_html_accessibility_probe(client, user, profile, job)
|
_run_html_accessibility_probe(client, user, profile, job)
|
||||||
pytest.skip("Playwright niet geïnstalleerd. Vult alleen de HTML/a11y-probe in.")
|
pytest.skip("Playwright niet geïnstalleerd. Vult alleen de HTML/a11y-probe in.")
|
||||||
|
|
||||||
@@ -183,6 +199,7 @@ def test_vr114_browser_flow(viewport, live_server, client, tmp_path, user, profi
|
|||||||
try:
|
try:
|
||||||
browser = playwright.chromium.launch(headless=True)
|
browser = playwright.chromium.launch(headless=True)
|
||||||
except Exception as exc: # pragma: no cover - env-dependent
|
except Exception as exc: # pragma: no cover - env-dependent
|
||||||
|
pytest.fail(f"Playwright Chromium is verplicht maar niet beschikbaar: {exc}")
|
||||||
# De afzonderlijke HTML/a11y-probe is altijd uitgevoerd. Buiten de
|
# De afzonderlijke HTML/a11y-probe is altijd uitgevoerd. Buiten de
|
||||||
# Playwright-context opnieuw databasewerk doen kan Django als async zien.
|
# Playwright-context opnieuw databasewerk doen kan Django als async zien.
|
||||||
pytest.skip(f"Playwright-browser niet beschikbaar: {exc}")
|
pytest.skip(f"Playwright-browser niet beschikbaar: {exc}")
|
||||||
@@ -192,52 +209,58 @@ def test_vr114_browser_flow(viewport, live_server, client, tmp_path, user, profi
|
|||||||
reduced_motion="reduce",
|
reduced_motion="reduce",
|
||||||
)
|
)
|
||||||
page = context.new_page()
|
page = context.new_page()
|
||||||
|
console_errors: list[str] = []
|
||||||
|
page_errors: list[str] = []
|
||||||
|
page.on(
|
||||||
|
"console",
|
||||||
|
lambda message: console_errors.append(message.text)
|
||||||
|
if message.type == "error"
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
page.on("pageerror", lambda error: page_errors.append(str(error)))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Login (e-mailadres + wachtwoord, Stitch-ontwerp).
|
# Login (e-mailadres + wachtwoord, Stitch-ontwerp).
|
||||||
page.goto(login_url)
|
page.goto(login_url)
|
||||||
_assert_playwright_accessibility(page, expected_h1="Inloggen")
|
_assert_playwright_accessibility(page, expected_h1="Inloggen")
|
||||||
page.get_by_label("E-mailadres").fill(user.email)
|
page.get_by_label("E-mailadres").fill(user.email)
|
||||||
page.get_by_label("Wachtwoord").fill("correct-horse-battery-staple")
|
page.get_by_role("textbox", name="Wachtwoord", exact=True).fill(
|
||||||
|
"correct-horse-battery-staple"
|
||||||
|
)
|
||||||
page.get_by_role("button", name="Inloggen").click()
|
page.get_by_role("button", name="Inloggen").click()
|
||||||
page.wait_for_url(dashboard_url)
|
page.wait_for_url(dashboard_url)
|
||||||
_assert_playwright_accessibility(page, expected_h1="Goedemorgen")
|
_assert_playwright_accessibility(page, expected_h1="Goedemorgen")
|
||||||
|
|
||||||
# Dashboard -> filter -> detail.
|
# Dashboard -> filter -> detail.
|
||||||
page.get_by_role("link", name="Vacatures").click()
|
page.goto(jobs_url)
|
||||||
page.get_by_role("textbox", name="Zoeken").fill(job.original_title)
|
page.get_by_label("Zoeken", exact=True).fill(job.original_title)
|
||||||
page.get_by_role("button", name="Filters toepassen").click()
|
page.get_by_role("button", name="Filter", exact=True).click()
|
||||||
assert page.url.startswith(jobs_url)
|
assert page.url.startswith(jobs_url)
|
||||||
page.get_by_role("link", name=job.original_title).first.click()
|
page.get_by_role("link", name="Volledige analyse").click()
|
||||||
page.wait_for_url(detail_url)
|
page.wait_for_url(detail_url)
|
||||||
_assert_playwright_accessibility(page, expected_h1=job.original_title)
|
_assert_playwright_accessibility(page, expected_h1=job.original_title)
|
||||||
|
|
||||||
# Vier feedbackacties.
|
# Vier feedbackacties.
|
||||||
page.get_by_role("button", name="Interessant").click()
|
page.get_by_role("button", name="Interessant").click()
|
||||||
page.wait_for_selector(".message.success")
|
page.wait_for_selector(".toast.success")
|
||||||
page.get_by_role("button", name="Bewaren").click()
|
page.get_by_role("button", name="Bewaren").click()
|
||||||
page.wait_for_selector(".message.success")
|
page.wait_for_selector(".toast.success")
|
||||||
page.get_by_label("Reden (optioneel)").select_option("Te ver")
|
|
||||||
page.get_by_role("button", name="Verbergen").click()
|
page.get_by_role("button", name="Verbergen").click()
|
||||||
page.wait_for_selector(".message.success")
|
page.wait_for_selector(".toast.success")
|
||||||
page.get_by_role("button", name="Gesolliciteerd").click()
|
page.get_by_role("button", name="Dossier starten").click()
|
||||||
page.wait_for_selector(".message.success")
|
page.wait_for_selector(".toast.success")
|
||||||
|
|
||||||
# Profiel aanpassen.
|
# Profiel aanpassen.
|
||||||
page.get_by_role("link", name="Zoekprofiel").click()
|
page.goto(profile_list_url)
|
||||||
page.wait_for_url(profile_list_url)
|
page.get_by_role("link", name="Radar configureren").click()
|
||||||
page.get_by_role("link", name="Profiel bewerken").click()
|
|
||||||
page.get_by_label("Infrastructure engineer").check()
|
page.get_by_label("Infrastructure engineer").check()
|
||||||
page.get_by_label("System engineer").check()
|
page.get_by_label("System engineer").check()
|
||||||
page.get_by_role("button", name="Instellingen opslaan").click()
|
page.get_by_role("button", name="Instellingen opslaan").click()
|
||||||
page.wait_for_url(profile_list_url)
|
page.wait_for_url(profile_list_url)
|
||||||
|
|
||||||
# Sollicitatie dossier openen en bijwerken.
|
# Sollicitatie dossier openen en bijwerken.
|
||||||
application = Application.objects.filter(user=user, job=job).first()
|
page.goto(applications_url)
|
||||||
assert application is not None
|
page.get_by_role("link", name="Open dossier").click()
|
||||||
page.get_by_role("link", name="Sollicitaties").click()
|
|
||||||
page.wait_for_url(applications_url)
|
|
||||||
page.goto(f"{base}{reverse('jobs:application-edit', kwargs={'pk': application.pk})}")
|
|
||||||
page.locator("select[name='status']").select_option("interview")
|
page.locator("select[name='status']").select_option("interview")
|
||||||
page.locator("input[name='contact_name']").fill("Sofie Janssens")
|
page.locator("input[name='contact_name']").fill("Sofie Janssens")
|
||||||
page.locator("input[name='contact_email']").fill("sofia@example.invalid")
|
page.locator("input[name='contact_email']").fill("sofia@example.invalid")
|
||||||
@@ -249,8 +272,7 @@ def test_vr114_browser_flow(viewport, live_server, client, tmp_path, user, profi
|
|||||||
page.wait_for_url(applications_url)
|
page.wait_for_url(applications_url)
|
||||||
|
|
||||||
# Handmatige import via plaktekst.
|
# Handmatige import via plaktekst.
|
||||||
page.get_by_role("link", name="Bronnen").click()
|
page.goto(sources_url)
|
||||||
page.wait_for_url(sources_url)
|
|
||||||
page.locator(".import-disclosure > summary").click()
|
page.locator(".import-disclosure > summary").click()
|
||||||
page.locator("textarea[name='pasted_text']").fill(
|
page.locator("textarea[name='pasted_text']").fill(
|
||||||
"Vacaturetitel: Infrastructure Engineer\nWerkgever: Example IT\nLocatie: Hasselt"
|
"Vacaturetitel: Infrastructure Engineer\nWerkgever: Example IT\nLocatie: Hasselt"
|
||||||
@@ -264,9 +286,10 @@ def test_vr114_browser_flow(viewport, live_server, client, tmp_path, user, profi
|
|||||||
page.keyboard.press("Tab")
|
page.keyboard.press("Tab")
|
||||||
page.keyboard.press("Tab")
|
page.keyboard.press("Tab")
|
||||||
page.keyboard.press("Tab")
|
page.keyboard.press("Tab")
|
||||||
_assert_playwright_accessibility(page, expected_h1="Bronnen")
|
_assert_playwright_accessibility(page, expected_h1="Scan-endpoints")
|
||||||
|
assert console_errors == []
|
||||||
|
assert page_errors == []
|
||||||
|
|
||||||
assert Feedback.objects.filter(user=user, job=job).count() >= 4
|
|
||||||
except Exception:
|
except Exception:
|
||||||
_take_failure_screenshot(page, tmp_path, viewport["name"])
|
_take_failure_screenshot(page, tmp_path, viewport["name"])
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from django.urls import reverse
|
|||||||
|
|
||||||
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
|
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
|
||||||
|
|
||||||
|
|
||||||
def test_login_page_renders_stitch_design(client):
|
def test_login_page_renders_stitch_design(client):
|
||||||
response = client.get(reverse("login"))
|
response = client.get(reverse("login"))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -41,6 +42,7 @@ def test_login_page_keeps_official_brand_separate_from_owner_setting(client):
|
|||||||
assert "Eigen merk" not in body
|
assert "Eigen merk" not in body
|
||||||
assert 'content="VacatureRadar"' in body
|
assert 'content="VacatureRadar"' in body
|
||||||
|
|
||||||
|
|
||||||
def test_login_with_email(client, user):
|
def test_login_with_email(client, user):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
reverse("login"),
|
reverse("login"),
|
||||||
@@ -50,6 +52,7 @@ def test_login_with_email(client, user):
|
|||||||
assert response.url == reverse("dashboard:today")
|
assert response.url == reverse("dashboard:today")
|
||||||
assert client.session.get("_auth_user_id") == str(user.pk)
|
assert client.session.get("_auth_user_id") == str(user.pk)
|
||||||
|
|
||||||
|
|
||||||
def test_login_with_username_still_works(client, user):
|
def test_login_with_username_still_works(client, user):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
reverse("login"),
|
reverse("login"),
|
||||||
@@ -58,6 +61,7 @@ def test_login_with_username_still_works(client, user):
|
|||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
assert client.session.get("_auth_user_id") == str(user.pk)
|
assert client.session.get("_auth_user_id") == str(user.pk)
|
||||||
|
|
||||||
|
|
||||||
def test_login_email_case_insensitive(client, user):
|
def test_login_email_case_insensitive(client, user):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
reverse("login"),
|
reverse("login"),
|
||||||
@@ -66,6 +70,7 @@ def test_login_email_case_insensitive(client, user):
|
|||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
assert client.session.get("_auth_user_id") == str(user.pk)
|
assert client.session.get("_auth_user_id") == str(user.pk)
|
||||||
|
|
||||||
|
|
||||||
def test_login_wrong_password_shows_error(client, user):
|
def test_login_wrong_password_shows_error(client, user):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
reverse("login"),
|
reverse("login"),
|
||||||
@@ -74,6 +79,7 @@ def test_login_wrong_password_shows_error(client, user):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "klopt niet" in response.content.decode("utf-8")
|
assert "klopt niet" in response.content.decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
def test_password_reset_sends_email(client, user):
|
def test_password_reset_sends_email(client, user):
|
||||||
response = client.post(reverse("password_reset"), {"email": user.email})
|
response = client.post(reverse("password_reset"), {"email": user.email})
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
@@ -84,11 +90,13 @@ def test_password_reset_sends_email(client, user):
|
|||||||
assert "wachtwoord" in message.subject.lower()
|
assert "wachtwoord" in message.subject.lower()
|
||||||
assert "/wachtwoord/herstellen/" in message.body
|
assert "/wachtwoord/herstellen/" in message.body
|
||||||
|
|
||||||
|
|
||||||
def test_password_reset_unknown_email_is_silent(client):
|
def test_password_reset_unknown_email_is_silent(client):
|
||||||
response = client.post(reverse("password_reset"), {"email": "onbekend@example.invalid"})
|
response = client.post(reverse("password_reset"), {"email": "onbekend@example.invalid"})
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
assert len(mail.outbox) == 0
|
assert len(mail.outbox) == 0
|
||||||
|
|
||||||
|
|
||||||
def test_demo_button_visible_by_default(client):
|
def test_demo_button_visible_by_default(client):
|
||||||
assert reverse("demo-login") in client.get(reverse("login")).content.decode("utf-8")
|
assert reverse("demo-login") in client.get(reverse("login")).content.decode("utf-8")
|
||||||
|
|
||||||
@@ -105,6 +113,7 @@ def test_demo_login_disabled_redirects(client):
|
|||||||
def test_demo_button_hidden_when_disabled(client):
|
def test_demo_button_hidden_when_disabled(client):
|
||||||
assert "demo-login" not in client.get(reverse("login")).content.decode("utf-8")
|
assert "demo-login" not in client.get(reverse("login")).content.decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
def test_demo_login_logs_in_and_seeds_environment(client):
|
def test_demo_login_logs_in_and_seeds_environment(client):
|
||||||
from apps.jobs.models import Application, JobPosting, ScoreRun
|
from apps.jobs.models import Application, JobPosting, ScoreRun
|
||||||
from apps.profiles.models import SearchProfile
|
from apps.profiles.models import SearchProfile
|
||||||
@@ -127,6 +136,7 @@ def test_demo_login_logs_in_and_seeds_environment(client):
|
|||||||
dashboard = client.get(reverse("dashboard:today"))
|
dashboard = client.get(reverse("dashboard:today"))
|
||||||
assert dashboard.status_code == 200
|
assert dashboard.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def test_demo_login_is_idempotent(client):
|
def test_demo_login_is_idempotent(client):
|
||||||
from apps.jobs.models import JobPosting
|
from apps.jobs.models import JobPosting
|
||||||
|
|
||||||
@@ -135,6 +145,7 @@ def test_demo_login_is_idempotent(client):
|
|||||||
client.post(reverse("demo-login"))
|
client.post(reverse("demo-login"))
|
||||||
assert JobPosting.objects.count() == first
|
assert JobPosting.objects.count() == first
|
||||||
|
|
||||||
|
|
||||||
def test_demo_account_is_read_only_and_session_is_bounded(client):
|
def test_demo_account_is_read_only_and_session_is_bounded(client):
|
||||||
from apps.profiles.models import SearchProfile
|
from apps.profiles.models import SearchProfile
|
||||||
|
|
||||||
@@ -156,12 +167,14 @@ def test_demo_account_is_read_only_and_session_is_bounded(client):
|
|||||||
assert alternative.is_active is False
|
assert alternative.is_active is False
|
||||||
assert client.session.get_expiry_age() <= settings.DEMO_SESSION_SECONDS
|
assert client.session.get_expiry_age() <= settings.DEMO_SESSION_SECONDS
|
||||||
|
|
||||||
|
|
||||||
def test_demo_account_can_still_log_out(client):
|
def test_demo_account_can_still_log_out(client):
|
||||||
client.post(reverse("demo-login"))
|
client.post(reverse("demo-login"))
|
||||||
response = client.post(reverse("logout"))
|
response = client.post(reverse("logout"))
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
assert client.session.get("_auth_user_id") is None
|
assert client.session.get("_auth_user_id") is None
|
||||||
|
|
||||||
|
|
||||||
def test_authenticated_demo_page_disables_mutating_forms(client):
|
def test_authenticated_demo_page_disables_mutating_forms(client):
|
||||||
client.post(reverse("demo-login"))
|
client.post(reverse("demo-login"))
|
||||||
body = client.get(reverse("dashboard:today")).content.decode("utf-8")
|
body = client.get(reverse("dashboard:today")).content.decode("utf-8")
|
||||||
@@ -169,6 +182,7 @@ def test_authenticated_demo_page_disables_mutating_forms(client):
|
|||||||
assert "Publieke demo · alleen-lezen" in body
|
assert "Publieke demo · alleen-lezen" in body
|
||||||
assert "data-demo-allow" in body
|
assert "data-demo-allow" in body
|
||||||
|
|
||||||
|
|
||||||
def test_entra_login_disabled_redirects(client):
|
def test_entra_login_disabled_redirects(client):
|
||||||
response = client.post(reverse("entra-login"))
|
response = client.post(reverse("entra-login"))
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ def test_premium_layout_uses_one_bounded_ultrawide_grid():
|
|||||||
css = (settings.BASE_DIR / "static" / "css" / "premium.css").read_text(encoding="utf-8")
|
css = (settings.BASE_DIR / "static" / "css" / "premium.css").read_text(encoding="utf-8")
|
||||||
assert "--content-frame: 1720px" in css
|
assert "--content-frame: 1720px" in css
|
||||||
assert "width: min(100%, var(--content-frame))" in css
|
assert "width: min(100%, var(--content-frame))" in css
|
||||||
|
assert "margin-inline: auto" in css
|
||||||
assert ".topbar-inner" in css
|
assert ".topbar-inner" in css
|
||||||
assert "--content-frame: 2200px" in css
|
assert "--content-frame: 2200px" in css
|
||||||
assert "--content-frame: 2480px" in css
|
assert "--content-frame: 2480px" in css
|
||||||
@@ -67,9 +68,24 @@ def test_premium_layout_uses_one_bounded_ultrawide_grid():
|
|||||||
assert "minmax(650px, 880px) minmax(760px, 1fr)" in css
|
assert "minmax(650px, 880px) minmax(760px, 1fr)" in css
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_demo_banner_is_part_of_main_content_flow(client, user, settings):
|
||||||
|
settings.DEMO_MODE_ENABLED = True
|
||||||
|
settings.DEMO_READ_ONLY = True
|
||||||
|
settings.DEMO_USERNAME = user.username
|
||||||
|
client.force_login(user)
|
||||||
|
response = client.get(reverse("dashboard:today"))
|
||||||
|
body = response.content.decode()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert body.index('<main id="main"') < body.index('class="demo-mode-banner"')
|
||||||
|
assert 'role="status" aria-label="Publieke demo, alleen-lezen"' in body
|
||||||
|
|
||||||
|
|
||||||
def test_css_imports_share_the_release_cache_version():
|
def test_css_imports_share_the_release_cache_version():
|
||||||
css = (settings.BASE_DIR / "static" / "css" / "app.css").read_text(encoding="utf-8")
|
css = (settings.BASE_DIR / "static" / "css" / "app.css").read_text(encoding="utf-8")
|
||||||
assert css.count("?v=0.3.12") == 7
|
assert css.count("?v=0.3.13") == 7
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import override_settings
|
||||||
|
|
||||||
|
from apps.core.auth_backends import EmailOrUsernameModelBackend
|
||||||
|
from apps.core.entra import (
|
||||||
|
EntraConfigError,
|
||||||
|
domain_allowed,
|
||||||
|
entra_enabled,
|
||||||
|
extract_identity,
|
||||||
|
load_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_backend_rejects_missing_and_unknown_credentials(db):
|
||||||
|
backend = EmailOrUsernameModelBackend()
|
||||||
|
assert backend.authenticate(None, username=None, password=None) is None
|
||||||
|
assert backend.authenticate(None, username="unknown@example.invalid", password="secret") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_backend_rejects_inactive_and_wrong_password(db):
|
||||||
|
user = get_user_model().objects.create_user(
|
||||||
|
username="inactive", email="inactive@example.invalid", password="correct", is_active=False
|
||||||
|
)
|
||||||
|
backend = EmailOrUsernameModelBackend()
|
||||||
|
|
||||||
|
assert backend.authenticate(None, username=user.email, password="wrong") is None
|
||||||
|
assert backend.authenticate(None, username=user.email, password="correct") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_backend_handles_duplicate_email_without_ambiguous_login(db):
|
||||||
|
user_model = get_user_model()
|
||||||
|
first = user_model.objects.create_user(
|
||||||
|
username="first", email="shared@example.invalid", password="correct"
|
||||||
|
)
|
||||||
|
user_model.objects.create_user(
|
||||||
|
username="second", email="shared@example.invalid", password="correct"
|
||||||
|
)
|
||||||
|
backend = EmailOrUsernameModelBackend()
|
||||||
|
|
||||||
|
assert backend.authenticate(None, username="shared@example.invalid", password="correct") is None
|
||||||
|
assert backend.authenticate(None, username="first", password="correct") == first
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ENTRA_ID_ENABLED=True)
|
||||||
|
def test_entra_enabled_reflects_configuration():
|
||||||
|
assert entra_enabled() is True
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
ENTRA_ID_ENABLED=True, ENTRA_CLIENT_ID="", ENTRA_CLIENT_SECRET="", ENTRA_TENANT_ID=""
|
||||||
|
)
|
||||||
|
def test_entra_config_fails_closed_when_credentials_are_incomplete():
|
||||||
|
try:
|
||||||
|
load_config()
|
||||||
|
except EntraConfigError as exc:
|
||||||
|
assert "niet allemaal ingesteld" in str(exc)
|
||||||
|
else: # pragma: no cover - defensive assertion
|
||||||
|
raise AssertionError("Onvolledige Entra-configuratie werd geaccepteerd")
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
ENTRA_CLIENT_ID="client",
|
||||||
|
ENTRA_CLIENT_SECRET="secret",
|
||||||
|
ENTRA_TENANT_ID="tenant",
|
||||||
|
ENTRA_SCOPES=["User.Read"],
|
||||||
|
ENTRA_ALLOWED_DOMAINS=["@Example.COM"],
|
||||||
|
ENTRA_REDIRECT_URI="https://example.invalid/callback",
|
||||||
|
)
|
||||||
|
def test_entra_config_normalizes_domains_and_identity_claims():
|
||||||
|
config = load_config()
|
||||||
|
assert config.authority.endswith("/tenant")
|
||||||
|
assert config.allowed_domains == ("example.com",)
|
||||||
|
assert domain_allowed(config, "person@example.com") is True
|
||||||
|
assert domain_allowed(config, "person@other.invalid") is False
|
||||||
|
assert extract_identity(
|
||||||
|
{"id_token_claims": {"preferred_username": " person@example.com ", "name": " Person "}}
|
||||||
|
) == ("person@example.com", "Person")
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
ENTRA_CLIENT_ID="client",
|
||||||
|
ENTRA_CLIENT_SECRET="secret",
|
||||||
|
ENTRA_TENANT_ID="tenant",
|
||||||
|
ENTRA_ALLOWED_DOMAINS=[],
|
||||||
|
)
|
||||||
|
def test_entra_allows_any_domain_only_when_allowlist_is_empty():
|
||||||
|
assert domain_allowed(load_config(), "person@example.invalid") is True
|
||||||
@@ -96,7 +96,7 @@ def test_public_https_origin_uses_external_port_not_internal_app_port() -> None:
|
|||||||
assert 'app_public_port="${APP_PUBLIC_PORT:-}"' in deploy_script
|
assert 'app_public_port="${APP_PUBLIC_PORT:-}"' in deploy_script
|
||||||
assert 'app_public_port="443"' in deploy_script
|
assert 'app_public_port="443"' in deploy_script
|
||||||
assert 'write_env "PUBLIC_BASE_URL" "$app_base_url" 1' in deploy_script
|
assert 'write_env "PUBLIC_BASE_URL" "$app_base_url" 1' in deploy_script
|
||||||
assert 'https://$app_host:$app_port' not in deploy_script
|
assert "https://$app_host:$app_port" not in deploy_script
|
||||||
|
|
||||||
|
|
||||||
def test_health_endpoints_are_exempt_from_internal_https_redirect() -> None:
|
def test_health_endpoints_are_exempt_from_internal_https_redirect() -> None:
|
||||||
@@ -123,4 +123,4 @@ def test_deploy_uses_selected_environment_file_for_compose() -> None:
|
|||||||
assert compose["x-app"]["env_file"] == ["${VACATURERADAR_ENV_FILE:-.env}"]
|
assert compose["x-app"]["env_file"] == ["${VACATURERADAR_ENV_FILE:-.env}"]
|
||||||
assert 'export VACATURERADAR_ENV_FILE="$env_file"' in deploy_script
|
assert 'export VACATURERADAR_ENV_FILE="$env_file"' in deploy_script
|
||||||
assert 'docker compose --env-file "$env_file" -f "$compose_file" up -d --build' in deploy_script
|
assert 'docker compose --env-file "$env_file" -f "$compose_file" up -d --build' in deploy_script
|
||||||
assert 'waarde uit ${env_file}' in deploy_script
|
assert "waarde uit ${env_file}" in deploy_script
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from django.core.cache import cache
|
||||||
|
from django.test import override_settings
|
||||||
|
|
||||||
|
from apps.core.services.automation import _runtime_heartbeat_state
|
||||||
|
from apps.core.tasks import RUNTIME_HEARTBEAT_KEY, record_runtime_heartbeat
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(CELERY_TASK_ALWAYS_EAGER=False)
|
||||||
|
def test_runtime_heartbeat_reports_real_worker_execution():
|
||||||
|
cache.delete(RUNTIME_HEARTBEAT_KEY)
|
||||||
|
assert _runtime_heartbeat_state()["healthy"] is False
|
||||||
|
|
||||||
|
timestamp = record_runtime_heartbeat()
|
||||||
|
|
||||||
|
state = _runtime_heartbeat_state()
|
||||||
|
assert cache.get(RUNTIME_HEARTBEAT_KEY) == timestamp
|
||||||
|
assert state["healthy"] is True
|
||||||
|
assert state["label"].startswith("Actief")
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(CELERY_TASK_ALWAYS_EAGER=True)
|
||||||
|
def test_runtime_heartbeat_is_honest_in_eager_mode():
|
||||||
|
assert _runtime_heartbeat_state() == {
|
||||||
|
"label": "Eager/lokaal",
|
||||||
|
"healthy": True,
|
||||||
|
"observed_at": None,
|
||||||
|
}
|
||||||
@@ -776,7 +776,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vacatureradar"
|
name = "vacatureradar"
|
||||||
version = "0.3.12"
|
version = "0.3.13"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "beautifulsoup4" },
|
{ name = "beautifulsoup4" },
|
||||||
|
|||||||
Reference in New Issue
Block a user