Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cfb2260cc | ||
|
|
b5045cf487 | ||
|
|
a6c8a79e31 | ||
|
|
2419e6bf01 | ||
|
|
3ab9127548 | ||
|
|
db9f340130 | ||
|
|
aa1e6e50c1 | ||
|
|
7991b04b35 | ||
|
|
8073533c16 | ||
|
|
db7d96a546 | ||
|
|
6da5d75acd | ||
|
|
67be350283 | ||
|
|
1397f085bd |
@@ -36,6 +36,10 @@ SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_USE_TLS=1
|
||||
M365_TENANT_ID=
|
||||
M365_CLIENT_ID=
|
||||
M365_CLIENT_SECRET=
|
||||
M365_MAILBOX_USER=
|
||||
|
||||
# Roteerbare Fernet-sleutels voor app-wachtwoorden; actieve sleutel eerst.
|
||||
# Genereer met: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
|
||||
+6
-2
@@ -36,8 +36,8 @@ CELERY_TASK_ALWAYS_EAGER=0
|
||||
|
||||
# Maakt bij een lege database eenmalig het beheerdersaccount aan.
|
||||
# Inloggen kan met de gebruikersnaam of het e-mailadres.
|
||||
VACATURERADAR_ADMIN_USERNAME=jens@itworx.tech
|
||||
VACATURERADAR_ADMIN_EMAIL=jens@itworx.tech
|
||||
VACATURERADAR_ADMIN_USERNAME=admin@example.invalid
|
||||
VACATURERADAR_ADMIN_EMAIL=admin@example.invalid
|
||||
VACATURERADAR_ADMIN_PASSWORD=CHANGE_ME_unique_admin_password
|
||||
VACATURERADAR_AUTO_BOOTSTRAP=1
|
||||
|
||||
@@ -74,6 +74,10 @@ SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_USE_TLS=1
|
||||
M365_TENANT_ID=
|
||||
M365_CLIENT_ID=
|
||||
M365_CLIENT_SECRET=
|
||||
M365_MAILBOX_USER=
|
||||
|
||||
# Genereer met: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
MAILBOX_CREDENTIAL_KEYS=
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
jobs:
|
||||
verify-publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pinned uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
version: "0.11.12"
|
||||
python-version: "3.13"
|
||||
- name: Install verification browser
|
||||
run: |
|
||||
uv sync --extra browser
|
||||
uv run playwright install --with-deps chromium
|
||||
- name: Verify release
|
||||
run: bash ./scripts/codex_verify.sh
|
||||
- name: Build immutable image
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
registry="gitea.itworx.tech"
|
||||
image="${registry}/jens/vacatureradar:${version}"
|
||||
printf '%s' "$REGISTRY_TOKEN" | docker login "$registry" --username "$REGISTRY_USER" --password-stdin
|
||||
docker build --file Dockerfile.unraid \
|
||||
--build-arg "VCS_REF=${GITHUB_SHA}" \
|
||||
--build-arg "APP_VERSION=${version}" \
|
||||
--tag "$image" .
|
||||
docker push "$image"
|
||||
docker image inspect "$image" --format '{{index .RepoDigests 0}}' > image-digest.txt
|
||||
docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
anchore/syft:v1.33.0 \
|
||||
"docker:$image" -o spdx-json > sbom.spdx.json
|
||||
cat > RELEASE_NOTES.md <<EOF
|
||||
# VacatureRadar ${version}
|
||||
|
||||
Immutable release image built from commit ${GITHUB_SHA}.
|
||||
The accompanying digest, SPDX SBOM and checksums are release evidence.
|
||||
EOF
|
||||
sha256sum image-digest.txt sbom.spdx.json RELEASE_NOTES.md > SHA256SUMS
|
||||
- name: Publish release evidence
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: release-evidence-${{ github.ref_name }}
|
||||
path: |
|
||||
image-digest.txt
|
||||
sbom.spdx.json
|
||||
SHA256SUMS
|
||||
RELEASE_NOTES.md
|
||||
@@ -2,6 +2,23 @@
|
||||
|
||||
Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorlopig een pre-1.0 semantische versieaanpak.
|
||||
|
||||
## 0.3.17 - 2026-08-12
|
||||
|
||||
### Hersteld
|
||||
|
||||
- De Gitea-releasebootstrap gebruikt een immutable `setup-uv`-commit en een exacte
|
||||
uv-versie; downloads lopen via de Astral-mirror en zijn niet langer afhankelijk
|
||||
van de gelimiteerde GitHub Releases-API op het gedeelde runner-IP.
|
||||
|
||||
## 0.3.16 - 2026-08-12
|
||||
|
||||
### Hersteld
|
||||
|
||||
- De publieke demo herstelt bij iedere nieuwe demosessie zes actuele fictieve vacatures, hun laatste scores en twee toekomstige opvolgmomenten zonder duplicaten of onbeperkte scorehistoriek.
|
||||
- Negen fictieve `example.test`-platformmailboxen maken de mailboxflow zichtbaar zonder echte accounts, credentials of externe polls.
|
||||
- Mailboxingress wordt uitsluitend door de mailboxscheduler verwerkt; e-mailbronnen komen niet meer in URL-fetchplanning, dashboardscanstatus of de algemene bronrunactiviteit terecht.
|
||||
- Statuscopy onderscheidt een actief zoekprofiel van operationele brongezondheid en het lege loginwachtwoordveld lijkt niet langer vooraf ingevuld.
|
||||
|
||||
## 0.3.14 - 2026-07-29
|
||||
|
||||
### Gewijzigd
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@
|
||||
|
||||
VacatureRadar 0.3.14 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).
|
||||
|
||||
Release 0.3.14 verlaagt voor de vaste 250-itemimport de gemiddelde duur met 44,74% en het queryvolume met minstens 63,73%, met behoud van parser-, dedupe-, ranking-, provenance- en scorehistoriekcontracten. Het actuele productie-image en livebewijs worden na deployment in [docs/ai/PROJECT_STATE.md](docs/ai/PROJECT_STATE.md) geregistreerd. Alleen optionele externe identity- en mailboxcredentials staan nog in [USER_INPUT_REQUIRED.md](USER_INPUT_REQUIRED.md).
|
||||
Release 0.3.14 draait publiek als image `sha256:1ee84241c2f4ecef751f45df760054309843e1e98192035bc27476ae5a9477df`. Voor de vaste 250-itemimport daalt de gemiddelde duur met 44,74% en het queryvolume met minstens 63,73%, met behoud van parser-, dedupe-, ranking-, provenance- en scorehistoriekcontracten. Alleen optionele externe identity- en mailboxcredentials staan nog in [USER_INPUT_REQUIRED.md](USER_INPUT_REQUIRED.md).
|
||||
|
||||
@@ -9,6 +9,13 @@ De standaard productieopstelling gebruikt afzonderlijke web-, worker-, scheduler
|
||||
|
||||
Gebruik nooit wildcardhosts of secrets uit voorbeeldbestanden. `PUBLIC_BASE_URL` is de publieke bron van waarheid voor host-, origin-, HTTPS- en cookieconfiguratie.
|
||||
|
||||
## Productiebewijs 0.3.14
|
||||
|
||||
- Actieve image: `sha256:1ee84241c2f4ecef751f45df760054309843e1e98192035bc27476ae5a9477df`; container healthy, nul restarts en publieke readiness HTTP 200.
|
||||
- De versievariabele is 0.3.14, alle migrations zijn toegepast en de gastkernroutes leveren HTTP 200.
|
||||
- Back-up: `backups/release-0.3.14-20260729T164500Z`; rollback: tag `vacatureradar:rollback-0.3.13`, image `sha256:34aef8853f77a3f311b119bd67d9f1160a97fd4a21e62c9776222be289bf3b88`.
|
||||
- Rollback vereist geen datamigratie: retag de rollbackimage naar `vacatureradar:unraid` en recreate alleen service `app` met `--no-build`.
|
||||
|
||||
## Productiebewijs 0.3.13
|
||||
|
||||
- Doel: `/mnt/user/appdata/VacatureRadar`, composeproject `vacatureradar`, hostpoort 1226 naar containerpoort 8080.
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM python:3.13-slim-trixie
|
||||
|
||||
ARG VCS_REF=unknown
|
||||
ARG APP_VERSION=dev
|
||||
LABEL org.opencontainers.image.source="https://gitea.itworx.tech/Jens/VacatureRadar" \
|
||||
org.opencontainers.image.revision=$VCS_REF \
|
||||
org.opencontainers.image.version=$APP_VERSION
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
|
||||
@@ -9,4 +9,6 @@ Dagelijks beheer omvat readiness, worker/beat-heartbeat, bronruns, notificatie-o
|
||||
|
||||
## Live observability 2026-07-29
|
||||
|
||||
Release 0.3.14 draait healthy op image `sha256:1ee84241c2f4ecef751f45df760054309843e1e98192035bc27476ae5a9477df`, zonder containerrestarts. Publieke readiness en zeven gastkernroutes antwoorden 200. De begrensde productie-importsmoke is volledig teruggerold. Performancebewaking kan worden herhaald met `scripts/run_import_benchmark.sh`; de runner weigert productiedatabases en externe hosts.
|
||||
|
||||
Na build, deploy en een afzonderlijke restart waren PostgreSQL, Redis, Gunicorn, Celery worker en Celery beat actief. `/health/ready/` rapporteerde database en cache `ok`. De automationpagina werd via het publieke domein geladen; queuediepte blijft bewust onbekend wanneer de cachebackend die niet betrouwbaar kan meten. De gecombineerde runtimeheartbeat bewijst beat-dispatch en workeruitvoering.
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
|
||||
VacatureRadar is een persoonlijke vacature-assistent voor België. De applicatie volgt toegestane werkgeversbronnen, ATS-pagina’s, feeds en vacaturemails, verwijdert dubbels en irrelevante resultaten en rangschikt de overblijvende kansen tegen jouw zoekprofiel. Jij behoudt altijd de controle: VacatureRadar helpt zoeken en opvolgen, maar solliciteert nooit automatisch.
|
||||
|
||||
**Actuele release: 0.3.17.** De volledige P0/P1-releasebasis en de publieke
|
||||
demo-hardening zijn afgerond: 327 tests zijn groen, Microsoft 365 verwerkt platformmails via
|
||||
app-only OAuth/XOAUTH2. Release 0.3.17 herstelt bovendien de reproduceerbare
|
||||
private-registrypublicatie zonder afhankelijkheid van de GitHub Releases-API.
|
||||
Het controleerbare acceptatiebewijs staat in
|
||||
[Finale acceptance](docs/audit/FINAL_ACCEPTANCE.md).
|
||||
|
||||

|
||||
|
||||
## Voor wie is dit?
|
||||
@@ -37,12 +44,15 @@ VacatureRadar is bedoeld voor één persoon of een eigen, afgeschermde installat
|
||||
| Vacatureverkenner | Zoeken en filteren met een uitlegbare detailanalyse naast de resultaten |
|
||||
| Skillsradar | Actuele marktvraag naast de skills uit je eigen profiel |
|
||||
| Sollicitatiepipeline | Dossiers, status, notities, deadlines en een auditbare tijdlijn |
|
||||
| Bronnenbeheer | Veilige bronconfiguratie, gezondheid, handmatige import en mailboxkoppelingen |
|
||||
| Bronnenbeheer | Veilige bronconfiguratie, gezondheid, handmatige import en afzonderlijke mailboxkoppelingen voor negen jobplatformen |
|
||||
| Automatisering | Eerlijke 24-uursmetingen; niet-gemeten informatie blijft expliciet onbekend |
|
||||
|
||||
## Bekijk de volledige productflow
|
||||
|
||||
De beelden komen uit de lokale demo-omgeving. De getoonde cijfers zijn omgevingsdata, geen marketingclaims. Klik op een beeld voor de volledige resolutie.
|
||||
De beelden komen uit een gesaneerde lokale demo-omgeving. De getoonde aantallen
|
||||
zijn een momentopname van die demo en bewijzen geen productie-status; daarvoor
|
||||
geldt uitsluitend de gedateerde [finale acceptance](docs/audit/FINAL_ACCEPTANCE.md).
|
||||
Klik op een beeld voor de volledige resolutie.
|
||||
|
||||
### 1. Vind de relevante kansen
|
||||
|
||||
@@ -78,7 +88,7 @@ Een analyse maakt score, datakwaliteit, beslissignalen, aandachtspunten en broni
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><strong>Sollicitatiepipeline</strong><br><sub>Bewaar status, deadlines, notities en tijdlijn per dossier; de uiteindelijke sollicitatie blijft altijd handmatig.</sub></td>
|
||||
<td width="50%"><strong>Bronnenbeheer</strong><br><sub>Zie policyreview, laatste scans, volgende runs en brongezondheid zonder technische logs te moeten doorzoeken.</sub></td>
|
||||
<td width="50%"><strong>Bronnenbeheer</strong><br><sub>Zie policyreview, laatste scans, volgende runs, brongezondheid en de status van iedere platformmailbox zonder technische logs te moeten doorzoeken.</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="docs/screenshots/readme/applications-dark.png"><img src="docs/screenshots/readme/applications-dark.png" alt="Sollicitatiepipeline met persoonlijke dossiers"></a></td>
|
||||
@@ -141,7 +151,7 @@ Na de eerste login:
|
||||
|
||||
1. Open **Zoekprofiel** en stel regio, radius, rollen, skills en werkvoorkeuren in.
|
||||
2. Open **Bronnen** en activeer alleen bronnen waarvan beleid en voorwaarden beoordeeld zijn.
|
||||
3. Configureer optioneel een eigen vacaturemailbox; credentials worden niet in de repository opgeslagen.
|
||||
3. Koppel optioneel afzonderlijke vacaturemailboxen voor VDAB, Indeed, LinkedIn, ictjob.be, Jobat, StepStone, Careerjet, Randstad en Robert Half. Credentials worden niet in de repository opgeslagen.
|
||||
4. Controleer **Automatisering** voordat je op onbemande verwerking vertrouwt.
|
||||
5. Verifieer in productie altijd HTTPS, back-ups, herstel en healthchecks.
|
||||
|
||||
@@ -158,6 +168,12 @@ De publieke URL wordt vervolgens gebruikt voor `ALLOWED_HOSTS`, CSRF, secure coo
|
||||
|
||||
Publiceer poort `1226` niet rechtstreeks op internet. Gebruik een beheerde reverse proxy met TLS en volg [Unraid-deployment](docs/operations/UNRAID_DEPLOYMENT.md) en de concrete [Nginx Proxy Manager-handleiding](docs/operations/NGINX_PROXY_MANAGER.md). Een volledig productievoorbeeld staat in [`deployment/production.env.example`](deployment/production.env.example).
|
||||
|
||||
Voor Microsoft 365 gebruikt VacatureRadar uitsluitend app-only OAuth/XOAUTH2
|
||||
voor IMAP en SMTP; Basic Auth en Microsoft 365-app-wachtwoorden worden niet
|
||||
ondersteund. Beperk de Entra-serviceprincipal tot de bedoelde mailbox en geef
|
||||
alleen `IMAP.AccessAsApp` en `SMTP.SendAsApp`. De exacte configuratie- en
|
||||
rotatiestappen staan in het [operationele runbook](docs/operations/RUNBOOK.md).
|
||||
|
||||
## Eerlijke grenzen
|
||||
|
||||
VacatureRadar:
|
||||
@@ -193,6 +209,11 @@ Die controleert formatting, lint, Django-configuratie, migraties, de volledige t
|
||||
|
||||
## Beheer en deployment
|
||||
|
||||
Productiereleases worden door Gitea Actions gebouwd, gescand en als private
|
||||
containerimage gepubliceerd. Pin Unraid op `versie@sha256:digest`, bewaar de
|
||||
vorige digest voor rollback en gebruik nooit alleen een veranderlijke tag als
|
||||
productiereferentie.
|
||||
|
||||
- [Lokaal ontwikkelen](docs/operations/LOCAL_DEVELOPMENT.md)
|
||||
- [Docker en Unraid uitrollen](docs/operations/UNRAID_DEPLOYMENT.md)
|
||||
- [Nginx Proxy Manager configureren](docs/operations/NGINX_PROXY_MANAGER.md)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Microsoft365OAuthError(RuntimeError):
|
||||
"""Safe OAuth boundary error that never includes tokens or client secrets."""
|
||||
|
||||
|
||||
def acquire_exchange_access_token(*, client_factory=None) -> str:
|
||||
tenant_id = settings.M365_TENANT_ID.strip()
|
||||
client_id = settings.M365_CLIENT_ID.strip()
|
||||
client_secret = settings.M365_CLIENT_SECRET.strip()
|
||||
if not all((tenant_id, client_id, client_secret)):
|
||||
raise Microsoft365OAuthError("Microsoft 365 OAuth is niet volledig geconfigureerd.")
|
||||
|
||||
if client_factory is None:
|
||||
import msal
|
||||
|
||||
client_factory = msal.ConfidentialClientApplication
|
||||
factory = client_factory
|
||||
client = factory(
|
||||
client_id,
|
||||
authority=f"https://login.microsoftonline.com/{tenant_id}",
|
||||
client_credential=client_secret,
|
||||
)
|
||||
result = client.acquire_token_for_client(scopes=[settings.M365_EXCHANGE_SCOPE])
|
||||
token = result.get("access_token") if isinstance(result, dict) else None
|
||||
if not isinstance(token, str) or not token:
|
||||
category = "unknown"
|
||||
if isinstance(result, dict):
|
||||
category = str(result.get("error") or "unknown")[:80]
|
||||
raise Microsoft365OAuthError(
|
||||
f"Microsoft 365 OAuth-token kon niet worden verkregen ({category})."
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def xoauth2_bytes(username: str, access_token: str) -> bytes:
|
||||
if not username or "\x00" in username or "\r" in username or "\n" in username:
|
||||
raise Microsoft365OAuthError("Ongeldig Microsoft 365-mailboxadres.")
|
||||
return f"user={username}\x01auth=Bearer {access_token}\x01\x01".encode()
|
||||
|
||||
|
||||
def xoauth2_b64(username: str, access_token: str) -> str:
|
||||
return base64.b64encode(xoauth2_bytes(username, access_token)).decode("ascii")
|
||||
@@ -8,7 +8,7 @@ from django.urls import reverse
|
||||
|
||||
from apps.notifications.models import DigestOutbox, ReminderOutbox
|
||||
from apps.profiles.models import ProfileRevision
|
||||
from apps.sources.models import SourceRun
|
||||
from apps.sources.models import Source, SourceRun
|
||||
|
||||
from ..models import ApplicationTimelineEvent, Feedback, JobVersion
|
||||
|
||||
@@ -37,7 +37,8 @@ ACTIVITY_FILTERS = (
|
||||
|
||||
def _source_events() -> list[ActivityEvent]:
|
||||
events = []
|
||||
for run in SourceRun.objects.select_related("source").order_by("-started_at")[:100]:
|
||||
runs = SourceRun.objects.exclude(source__source_type=Source.Type.EMAIL)
|
||||
for run in runs.select_related("source").order_by("-started_at")[:100]:
|
||||
detail = (
|
||||
f"{run.extracted_count} geëxtraheerd · {run.created_count} nieuw · "
|
||||
f"{run.duplicate_count} duplicaat"
|
||||
|
||||
@@ -57,7 +57,8 @@ def annotate_latest_profile_score(
|
||||
|
||||
def _recent_dashboard_activity(user: User) -> list[dict[str, Any]]:
|
||||
activity: list[dict[str, Any]] = []
|
||||
for run in SourceRun.objects.select_related("source").order_by("-started_at")[:4]:
|
||||
source_runs = SourceRun.objects.exclude(source__source_type=Source.Type.EMAIL)
|
||||
for run in source_runs.select_related("source").order_by("-started_at")[:4]:
|
||||
activity.append(
|
||||
{
|
||||
"occurred_at": run.started_at,
|
||||
@@ -154,7 +155,9 @@ def build_dashboard_cockpit(user: User, profile: SearchProfile | None) -> dict[s
|
||||
"source_counts": source_counts,
|
||||
"recent_activity": _recent_dashboard_activity(user),
|
||||
"day_conclusion": day_conclusion,
|
||||
"latest_scan": SourceRun.objects.order_by("-started_at").first(),
|
||||
"latest_scan": SourceRun.objects.exclude(source__source_type=Source.Type.EMAIL)
|
||||
.order_by("-started_at")
|
||||
.first(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -122,11 +122,10 @@ def demo_environment_seeded(user) -> bool:
|
||||
|
||||
|
||||
def seed_demo_environment(user) -> None:
|
||||
"""Maak (idempotent) een fictieve demo-omgeving voor ``user``."""
|
||||
if demo_environment_seeded(user):
|
||||
return
|
||||
|
||||
"""Maak of herstel een fictieve, tijdsbestendige demo-omgeving voor ``user``."""
|
||||
from apps.jobs.models import Application, Employer, JobPosting, ScoreRun
|
||||
from apps.sources.models import MailboxConnection, Source, SourceRun
|
||||
from apps.sources.platforms import PLATFORM_ALERTS
|
||||
|
||||
profile, _ = SearchProfile.objects.get_or_create(
|
||||
user=user,
|
||||
@@ -185,7 +184,7 @@ def seed_demo_environment(user) -> None:
|
||||
},
|
||||
)
|
||||
slug = _digest("demo-job", str(index), title, employer_name)
|
||||
job, job_created = JobPosting.objects.get_or_create(
|
||||
job, _ = JobPosting.objects.update_or_create(
|
||||
canonical_key=slug,
|
||||
defaults={
|
||||
"employer": employer,
|
||||
@@ -221,6 +220,11 @@ def seed_demo_environment(user) -> None:
|
||||
)
|
||||
created_jobs.append(job)
|
||||
|
||||
ScoreRun.objects.filter(
|
||||
job=job,
|
||||
profile=profile,
|
||||
model_version="demo-seed",
|
||||
).delete()
|
||||
ScoreRun.objects.create(
|
||||
job=job,
|
||||
profile=profile,
|
||||
@@ -244,7 +248,7 @@ def seed_demo_environment(user) -> None:
|
||||
|
||||
# Een kleine sollicitatiepijplijn voor de demo.
|
||||
if created_jobs:
|
||||
Application.objects.get_or_create(
|
||||
Application.objects.update_or_create(
|
||||
user=user,
|
||||
job=created_jobs[0],
|
||||
defaults={
|
||||
@@ -257,7 +261,7 @@ def seed_demo_environment(user) -> None:
|
||||
},
|
||||
)
|
||||
if len(created_jobs) > 3:
|
||||
Application.objects.get_or_create(
|
||||
Application.objects.update_or_create(
|
||||
user=user,
|
||||
job=created_jobs[3],
|
||||
defaults={
|
||||
@@ -267,3 +271,42 @@ def seed_demo_environment(user) -> None:
|
||||
"notes": "Sollicitatie verstuurd. Demo-data.",
|
||||
},
|
||||
)
|
||||
|
||||
# Platformmailboxen zijn zichtbare ingressconfiguratie, geen crawlbare URL-bronnen.
|
||||
# De demo gebruikt uitsluitend fictieve adressen en wordt door de scheduler overgeslagen.
|
||||
for platform in PLATFORM_ALERTS:
|
||||
MailboxConnection.objects.update_or_create(
|
||||
user=user,
|
||||
platform=platform,
|
||||
defaults={
|
||||
"provider": MailboxConnection.Provider.OUTLOOK,
|
||||
"username": "alerts@example.test",
|
||||
"mailbox": f"VacatureRadar/{platform}",
|
||||
"enabled": True,
|
||||
"poll_interval_minutes": MailboxConnection.PollInterval.THIRTY_MINUTES,
|
||||
"next_poll_at": now + timedelta(minutes=30),
|
||||
"last_polled_at": now - timedelta(minutes=5),
|
||||
"last_success_at": now - timedelta(minutes=5),
|
||||
"last_error_category": "",
|
||||
"last_error_message": "",
|
||||
"lease_token": "",
|
||||
"lease_expires_at": None,
|
||||
},
|
||||
)
|
||||
source, _ = Source.objects.update_or_create(
|
||||
source_type=Source.Type.EMAIL,
|
||||
domain=f"{platform}.mailbox.local",
|
||||
defaults={
|
||||
"name": f"{PLATFORM_ALERTS[platform].label} vacaturemailbox",
|
||||
"base_url": "",
|
||||
"status": Source.Status.ACTIVE,
|
||||
"policy": Source.Policy.ALLOW,
|
||||
"policy_reason": "Mailboxingress; niet via URL-fetch gepland.",
|
||||
"parser_key": "email-alert",
|
||||
"failure_count": 0,
|
||||
"last_failure_at": None,
|
||||
"next_run_at": None,
|
||||
"metadata": {"demo": True, "mailbox_ingress": True},
|
||||
},
|
||||
)
|
||||
SourceRun.objects.filter(source=source).delete()
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import smtplib
|
||||
from email.utils import parseaddr
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail.backends.base import BaseEmailBackend
|
||||
|
||||
from apps.core.services.microsoft365 import (
|
||||
Microsoft365OAuthError,
|
||||
acquire_exchange_access_token,
|
||||
xoauth2_b64,
|
||||
)
|
||||
from apps.sources.services.tls import trusted_tls_context
|
||||
|
||||
|
||||
class Microsoft365OAuthEmailBackend(BaseEmailBackend):
|
||||
"""Send mail through Exchange Online with app-only SASL XOAUTH2."""
|
||||
|
||||
def send_messages(self, email_messages) -> int:
|
||||
if not email_messages:
|
||||
return 0
|
||||
sent = 0
|
||||
try:
|
||||
token = acquire_exchange_access_token()
|
||||
username = settings.M365_MAILBOX_USER.strip()
|
||||
if not username:
|
||||
raise Microsoft365OAuthError(
|
||||
"M365_MAILBOX_USER is verplicht voor Microsoft 365-mailuitvoer."
|
||||
)
|
||||
with smtplib.SMTP(
|
||||
settings.EMAIL_HOST or "smtp.office365.com",
|
||||
settings.EMAIL_PORT,
|
||||
timeout=settings.M365_SMTP_TIMEOUT_SECONDS,
|
||||
) as client:
|
||||
client.ehlo()
|
||||
client.starttls(context=trusted_tls_context())
|
||||
client.ehlo()
|
||||
code, _ = client.docmd("AUTH", f"XOAUTH2 {xoauth2_b64(username, token)}")
|
||||
if code != 235:
|
||||
raise Microsoft365OAuthError("Microsoft 365 SMTP OAuth geweigerd.")
|
||||
for message in email_messages:
|
||||
recipients = message.recipients()
|
||||
if not recipients:
|
||||
continue
|
||||
sender = parseaddr(message.from_email or settings.DEFAULT_FROM_EMAIL)[1]
|
||||
client.sendmail(
|
||||
sender or username,
|
||||
recipients,
|
||||
message.message().as_bytes(linesep="\r\n"),
|
||||
)
|
||||
sent += 1
|
||||
except Exception:
|
||||
if self.fail_silently:
|
||||
return sent
|
||||
raise
|
||||
return sent
|
||||
@@ -9,7 +9,10 @@ class MailboxConnectionForm(forms.ModelForm):
|
||||
password = forms.CharField(
|
||||
required=False,
|
||||
label="App-wachtwoord",
|
||||
help_text="Laat leeg bij wijzigen om het bestaande app-wachtwoord te behouden.",
|
||||
help_text=(
|
||||
"Alleen voor Gmail of een aangepaste provider. Microsoft 365 gebruikt "
|
||||
"de centrale OAuth-configuratie."
|
||||
),
|
||||
widget=forms.PasswordInput(
|
||||
attrs={"autocomplete": "new-password", "placeholder": "App-wachtwoord"},
|
||||
render_value=False,
|
||||
@@ -50,7 +53,8 @@ class MailboxConnectionForm(forms.ModelForm):
|
||||
data = super().clean()
|
||||
if data.get("provider") != MailboxConnection.Provider.CUSTOM:
|
||||
data["custom_host"] = ""
|
||||
if not self.instance.pk and not data.get("password"):
|
||||
uses_oauth = data.get("provider") == MailboxConnection.Provider.OUTLOOK
|
||||
if not self.instance.pk and not uses_oauth and not data.get("password"):
|
||||
self.add_error("password", "Een app-wachtwoord is verplicht voor een nieuwe koppeling.")
|
||||
return data
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("sources", "0007_alter_mailboxconnection_platform")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="mailboxconnection",
|
||||
name="encrypted_password",
|
||||
field=models.TextField(blank=True),
|
||||
)
|
||||
]
|
||||
@@ -357,7 +357,7 @@ class MailboxConnection(TimeStampedModel):
|
||||
custom_host = models.CharField(max_length=255, blank=True)
|
||||
port = models.PositiveIntegerField(default=993)
|
||||
username = models.CharField(max_length=320)
|
||||
encrypted_password = models.TextField()
|
||||
encrypted_password = models.TextField(blank=True)
|
||||
mailbox = models.CharField(max_length=255, default="INBOX")
|
||||
enabled = models.BooleanField(default=True)
|
||||
poll_interval_minutes = models.PositiveIntegerField(
|
||||
|
||||
@@ -74,14 +74,19 @@ def _validate_connected_peer(
|
||||
*,
|
||||
before: ValidatedUrl,
|
||||
after: ValidatedUrl,
|
||||
require_peer: bool,
|
||||
) -> None:
|
||||
peer_ip = _connected_peer_ip(response)
|
||||
if peer_ip is not None:
|
||||
if not peer_ip.is_global:
|
||||
raise FetchError(f"Niet-publiek verbonden IP-adres geblokkeerd: {peer_ip}")
|
||||
return
|
||||
if require_peer:
|
||||
raise FetchError(
|
||||
"Verbonden IP-adres kon niet veilig worden vastgesteld; aanvraag geblokkeerd."
|
||||
)
|
||||
# Mock/custom transports do not always expose the peer socket. Keep the conservative
|
||||
# DNS-overlap fallback for those transports.
|
||||
# DNS-overlap fallback only for explicitly injected test/custom clients.
|
||||
if not set(before.addresses).intersection(after.addresses):
|
||||
raise FetchError("DNS-rebindcontrole faalde bij het benaderen van bron.")
|
||||
|
||||
@@ -175,6 +180,7 @@ def fetch_url(
|
||||
response,
|
||||
before=validation,
|
||||
after=post_validation,
|
||||
require_peer=own_client,
|
||||
)
|
||||
if response.status_code in {301, 302, 303, 307, 308}:
|
||||
location = response.headers.get("location")
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from apps.core.services.microsoft365 import acquire_exchange_access_token, xoauth2_bytes
|
||||
from apps.sources.models import EmailMessageRecord, MailboxConnection
|
||||
from apps.sources.services.email_import import ingest_email, message_identity
|
||||
from apps.sources.services.mailbox_connections import decrypt_mailbox_password
|
||||
@@ -30,7 +31,11 @@ def poll_imap_mailbox(
|
||||
if client_factory is None:
|
||||
client_factory = imaplib.IMAP4_SSL
|
||||
host_validator(f"https://{connection.imap_host}")
|
||||
password = decrypt_mailbox_password(connection)
|
||||
password = (
|
||||
""
|
||||
if connection.provider == MailboxConnection.Provider.OUTLOOK
|
||||
else decrypt_mailbox_password(connection)
|
||||
)
|
||||
client_options = {"timeout": settings.IMAP_CONNECT_TIMEOUT_SECONDS}
|
||||
if client_factory is imaplib.IMAP4_SSL:
|
||||
client_options["ssl_context"] = trusted_tls_context()
|
||||
@@ -42,7 +47,13 @@ def poll_imap_mailbox(
|
||||
"failed": 0,
|
||||
}
|
||||
try:
|
||||
client.login(connection.username, password)
|
||||
if connection.provider == MailboxConnection.Provider.OUTLOOK:
|
||||
access_token = acquire_exchange_access_token()
|
||||
client.authenticate(
|
||||
"XOAUTH2", lambda _challenge: xoauth2_bytes(connection.username, access_token)
|
||||
)
|
||||
else:
|
||||
client.login(connection.username, password)
|
||||
status, _ = client.select(connection.mailbox, readonly=True)
|
||||
if status != "OK":
|
||||
raise RuntimeError("IMAP-mailbox kon niet worden geopend")
|
||||
|
||||
@@ -48,7 +48,9 @@ def decrypt_mailbox_password(connection: MailboxConnection) -> str:
|
||||
def rotate_mailbox_credentials() -> int:
|
||||
"""Re-encrypt every mailbox password with the first configured key."""
|
||||
rotated = 0
|
||||
for connection in MailboxConnection.objects.select_for_update().all():
|
||||
for connection in MailboxConnection.objects.select_for_update().exclude(
|
||||
provider=MailboxConnection.Provider.OUTLOOK
|
||||
):
|
||||
password = decrypt_mailbox_password(connection)
|
||||
connection.encrypted_password = encrypt_mailbox_password(password)
|
||||
connection.save(update_fields=["encrypted_password", "updated_at"])
|
||||
@@ -79,7 +81,10 @@ def save_mailbox_connection(
|
||||
"poll_interval_minutes",
|
||||
):
|
||||
setattr(connection, field, cleaned_data[field])
|
||||
if password:
|
||||
uses_oauth = connection.provider == MailboxConnection.Provider.OUTLOOK
|
||||
if uses_oauth:
|
||||
connection.encrypted_password = ""
|
||||
elif password:
|
||||
connection.encrypted_password = encrypt_mailbox_password(password)
|
||||
elif not connection.encrypted_password:
|
||||
raise MailboxCredentialError("Een app-wachtwoord is verplicht.")
|
||||
|
||||
@@ -188,7 +188,8 @@ def schedule_due_sources() -> dict[str, int]:
|
||||
now = timezone.now()
|
||||
due = Source.objects.filter(
|
||||
status__in=[Source.Status.ACTIVE, Source.Status.TRIAL],
|
||||
).filter(Q(next_run_at__isnull=True) | Q(next_run_at__lte=now))
|
||||
).exclude(source_type=Source.Type.EMAIL)
|
||||
due = due.filter(Q(next_run_at__isnull=True) | Q(next_run_at__lte=now))
|
||||
ids = list(due.values_list("id", flat=True).distinct()[:200])
|
||||
for source_id in ids:
|
||||
fetch_source.delay(source_id)
|
||||
@@ -200,6 +201,7 @@ def schedule_mailbox_polls() -> dict[str, int]:
|
||||
now = timezone.now()
|
||||
due_ids = list(
|
||||
MailboxConnection.objects.filter(enabled=True, next_poll_at__lte=now)
|
||||
.exclude(user__username=settings.DEMO_USERNAME)
|
||||
.filter(Q(lease_expires_at__isnull=True) | Q(lease_expires_at__lte=now))
|
||||
.values_list("id", flat=True)[:100]
|
||||
)
|
||||
|
||||
+7
-1
@@ -139,7 +139,7 @@ CSRF_TRUSTED_ORIGINS = merge_unique(
|
||||
)
|
||||
TIME_ZONE = os.getenv("DJANGO_TIME_ZONE", "Europe/Brussels")
|
||||
VACATURERADAR_OWNER_NAME = os.getenv("VACATURERADAR_OWNER_NAME", "Jens").strip()[:40] or "Jens"
|
||||
VACATURERADAR_VERSION = os.getenv("VACATURERADAR_VERSION", "0.3.14").strip() or "0.3.14"
|
||||
VACATURERADAR_VERSION = os.getenv("VACATURERADAR_VERSION", "0.3.17").strip() or "0.3.17"
|
||||
LANGUAGE_CODE = "nl-be"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
@@ -298,6 +298,12 @@ EMAIL_PORT = int(os.getenv("SMTP_PORT", "587"))
|
||||
EMAIL_HOST_USER = os.getenv("SMTP_USER", "")
|
||||
EMAIL_HOST_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
||||
EMAIL_USE_TLS = env_bool("SMTP_USE_TLS", True)
|
||||
M365_TENANT_ID = os.getenv("M365_TENANT_ID", "")
|
||||
M365_CLIENT_ID = os.getenv("M365_CLIENT_ID", "")
|
||||
M365_CLIENT_SECRET = os.getenv("M365_CLIENT_SECRET", "")
|
||||
M365_MAILBOX_USER = os.getenv("M365_MAILBOX_USER", EMAIL_HOST_USER)
|
||||
M365_EXCHANGE_SCOPE = "https://outlook.office365.com/.default"
|
||||
M365_SMTP_TIMEOUT_SECONDS = env_int_range("M365_SMTP_TIMEOUT_SECONDS", 20, minimum=3, maximum=120)
|
||||
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = "Lax"
|
||||
|
||||
@@ -9,7 +9,7 @@ DJANGO_ALLOWED_HOSTS=vacatureradar.example.be,127.0.0.1,localhost
|
||||
DJANGO_CSRF_TRUSTED_ORIGINS=https://vacatureradar.example.be
|
||||
DJANGO_TIME_ZONE=Europe/Brussels
|
||||
VACATURERADAR_OWNER_NAME=Jens
|
||||
VACATURERADAR_VERSION=0.3.14
|
||||
VACATURERADAR_VERSION=0.3.17
|
||||
|
||||
POSTGRES_DB=vacatureradar
|
||||
POSTGRES_USER=vacatureradar
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.unraid
|
||||
image: vacatureradar:unraid
|
||||
image: ${VACATURERADAR_IMAGE:-vacatureradar:unraid}
|
||||
container_name: VacatureRadar
|
||||
env_file:
|
||||
- ${VACATURERADAR_ENV_FILE:-.env}
|
||||
|
||||
+96
-15
@@ -1,6 +1,6 @@
|
||||
schema_version: 1
|
||||
project: VacatureRadar
|
||||
updated_at: '2026-07-29'
|
||||
updated_at: '2026-08-12'
|
||||
execution_policy:
|
||||
order: file-order, then first ready task whose dependencies are done
|
||||
no_product_questions: true
|
||||
@@ -1341,7 +1341,7 @@ tasks:
|
||||
gebruikt strikt de gepinde certifi-bundel zonder bypass.
|
||||
- id: VR-202
|
||||
title: Live IMAP- en SMTP-credentials configureren
|
||||
status: blocked-external
|
||||
status: done
|
||||
priority: P0
|
||||
requirement_ids:
|
||||
- PR-016
|
||||
@@ -1356,22 +1356,27 @@ tasks:
|
||||
summary: Configureer de gewenste afzonderlijke platformmailboxen via de beveiligde UI, de Fernet-secretkey en
|
||||
digestrecipient; test read-only import en mailverzending zonder credentials in Git/logs.
|
||||
acceptance_criteria:
|
||||
- Iedere platformmailbox gebruikt TLS, een dedicated account/app-wachtwoord en read-only mailboxselectie.
|
||||
- Iedere platformmailbox gebruikt TLS, een dedicated alias en read-only mailboxselectie; Microsoft 365 gebruikt
|
||||
app-only OAuth/XOAUTH2 en geen Basic Auth of app-wachtwoord.
|
||||
- Dezelfde live testmail importeert per mailbox idempotent en alle geconfigureerde intervallen worden door de
|
||||
scheduler gerespecteerd.
|
||||
- SMTP verstuurt één testdigest en outboxstatus klopt.
|
||||
- De encryptiesleutel staat alleen in de productie-secretstore/.env; app-wachtwoorden zijn als ciphertext in de
|
||||
database aanwezig en sleutelrotatie is live getest.
|
||||
- OAuth-clientcredentials en de encryptiesleutel staan alleen in de productie-secretstore/.env; eventuele Gmail/custom-app-wachtwoorden
|
||||
zijn als ciphertext opgeslagen en sleutelrotatie is live getest.
|
||||
verification:
|
||||
- Volg Unraid mailboxchecklist
|
||||
- Inspecteer logs op afwezigheid van secret/payload
|
||||
primary_paths:
|
||||
- /mnt/user/appdata/vacatureradar/config/.env
|
||||
- /mnt/user/appdata/VacatureRadar/.env
|
||||
- Bronnen -> Platformmailboxen
|
||||
external_blocker: Vereist mailbox-/SMTP-account en app-password van de gebruiker.
|
||||
result:
|
||||
completed_at: '2026-07-30'
|
||||
note: M365 app-only OAuth/XOAUTH2 live ingericht met dedicated alias/map en Exchange-regel; negen platformmailboxen
|
||||
importeerden dezelfde live testmail idempotent, schedulerstatus was correct, testdigest stond sent en logs
|
||||
bleven payload-/secretvrij.
|
||||
- id: VR-203
|
||||
title: Productiedomein, TLS en reverse proxy activeren
|
||||
status: blocked-external
|
||||
status: done
|
||||
priority: P0
|
||||
requirement_ids:
|
||||
- NFR-001
|
||||
@@ -1393,10 +1398,14 @@ tasks:
|
||||
primary_paths:
|
||||
- Unraid reverse proxy
|
||||
- /mnt/user/appdata/vacatureradar/config/.env
|
||||
external_blocker: Vereist gekozen domein/DNS en toegang tot de Unraid reverse proxy.
|
||||
result:
|
||||
completed_at: '2026-07-29'
|
||||
note: Publiek domein, HTTP-naar-HTTPS, TLS/securityheaders, DEBUG=False, secure cookies, CSRF-gastlogin, kernroutes
|
||||
en readiness live bewezen in docs/audit/FINAL_ACCEPTANCE.md op 2026-07-29; interne diensten zijn uitsluitend
|
||||
containerintern bereikbaar.
|
||||
- id: VR-204
|
||||
title: Immutable containerimage publiceren
|
||||
status: blocked-external
|
||||
status: done
|
||||
priority: P1
|
||||
requirement_ids:
|
||||
- NFR-009
|
||||
@@ -1414,9 +1423,12 @@ tasks:
|
||||
- Pull image op schone host
|
||||
- Start smoke stack en rollback naar vorige digest
|
||||
primary_paths:
|
||||
- .github/workflows/release.yml
|
||||
- .gitea/workflows/release.yml
|
||||
- docker-compose.unraid.yml
|
||||
external_blocker: Vereist registrykeuze, account en credentials.
|
||||
result:
|
||||
completed_at: '2026-07-30'
|
||||
note: Gitea private registry publiceerde v0.3.15 als immutable digest met commitlabel, SPDX-SBOM, checksums
|
||||
en release notes; Unraid pull, digest-pin, healthy smoke en rollback naar 0.3.14 plus herstel zijn live geslaagd.
|
||||
- id: VR-205
|
||||
title: Gelicenseerde Belgische geodatabron laden
|
||||
status: done
|
||||
@@ -2331,7 +2343,7 @@ tasks:
|
||||
documentatie; volledige gate 312 tests groen, 84,16% dekking.
|
||||
- id: VR-228
|
||||
title: Optimaliseer de importpipeline voor release 0.3.14
|
||||
status: ready
|
||||
status: done
|
||||
priority: P0
|
||||
requirement_ids:
|
||||
- NFR-007
|
||||
@@ -2345,8 +2357,8 @@ tasks:
|
||||
- Een fail-closed runner meet een disposable PostgreSQL-database met vaste fixture, warm-up en drie herhalingen.
|
||||
- De geoptimaliseerde import reduceert queryvolume en doorlooptijd aantoonbaar tegenover 0.3.13 en rapporteert
|
||||
p50, p95, throughput, geheugen en queryfamilies.
|
||||
- Parserdekking blijft 12/12, dedupeprecision en -recall blijven 1,0, rankingchurn blijft nul en historische
|
||||
ScoreRuns plus veldprovenance blijven intact.
|
||||
- Parserdekking blijft 12/12, dedupeprecision en -recall blijven 1,0, rankingchurn blijft nul en historische ScoreRuns
|
||||
plus veldprovenance blijven intact.
|
||||
- Regressietests begrenzen het queryvolume; de volledige kwaliteitsgate slaagt tweemaal en productieacceptatie
|
||||
bewijst health, beperkte importsmoke, publieke kernflows en rollbackgereedheid.
|
||||
verification:
|
||||
@@ -2359,3 +2371,72 @@ tasks:
|
||||
- scripts/benchmark_import.py
|
||||
- tests/integration/test_pipeline.py
|
||||
- docs/audit/IMPORT_PERFORMANCE_FINAL.md
|
||||
result:
|
||||
completed_at: '2026-07-29'
|
||||
note: 'Release 0.3.14: geïsoleerde PostgreSQL-benchmark reduceert importduur 44,74% en queries minstens 63,73%;
|
||||
twee volledige gates met 318 tests groen; productie healthy op image sha256:1ee84241, transactionele importsmoke
|
||||
teruggerold en rollbackimage 0.3.13 beschikbaar.'
|
||||
- id: VR-229
|
||||
title: Herstel de publieke demo na de visuele 0.3.15-audit
|
||||
status: done
|
||||
priority: P1
|
||||
requirement_ids:
|
||||
- NFR-008
|
||||
- NFR-009
|
||||
depends_on:
|
||||
- VR-228
|
||||
summary: Maak de publieke demo opnieuw representatief en tijdsbestendig, scheid mailboxingress van URL-fetching
|
||||
en laat operationele statussen exact zeggen wat ze meten.
|
||||
acceptance_criteria:
|
||||
- Iedere nieuwe demosessie herstelt zes fictieve actuele vacatures, scores en toekomstige opvolgdatums zonder
|
||||
duplicaten of groeiende ScoreRun-historiek.
|
||||
- De demo toont negen fictieve, actieve platformmailboxkoppelingen zonder secrets of echte persoonsgegevens.
|
||||
- E-mailbronnen worden nooit door de generieke URL-fetchscheduler gepland en oude mailboxpolicyfouten vervuilen
|
||||
dashboard en activiteit niet meer.
|
||||
- Navigatie en dashboard noemen het zoekprofiel expliciet actief zonder brongezondheid te suggereren; het lege
|
||||
loginwachtwoordveld lijkt niet vooraf ingevuld.
|
||||
- De negen kernroutes blijven in dark/light en op mobiel, desktop en ultrawide zonder overflow, consolefouten
|
||||
of headingregressies bruikbaar; de volledige kwaliteitsgate slaagt.
|
||||
verification:
|
||||
- python -m pytest tests/integration/test_auth_login.py tests/integration/test_tasks_and_more_views.py
|
||||
- Browseraudit op 390x844, 1280x600, 1440x900 en 3440x1440
|
||||
- ./scripts/codex_verify.sh
|
||||
primary_paths:
|
||||
- apps/jobs/services/demo_seed.py
|
||||
- apps/jobs/services/activity.py
|
||||
- apps/jobs/services/cockpit.py
|
||||
- apps/sources/tasks.py
|
||||
- templates/base.html
|
||||
- templates/dashboard/today.html
|
||||
- templates/registration/login.html
|
||||
- tests/integration/test_auth_login.py
|
||||
- tests/integration/test_tasks_and_more_views.py
|
||||
result:
|
||||
completed_at: '2026-08-12'
|
||||
note: Publieke demo wordt bij iedere sessie idempotent hersteld met 6 actuele fictieve vacatures, tijdsbestendige
|
||||
opvolging en 9 fictieve mailboxkoppelingen; EMAIL-bronnen en demo-mailboxen zijn uit de verkeerde schedulers
|
||||
en activityfeeds uitgesloten, statuscopy en loginplaceholder zijn gecorrigeerd. Gerichte 51 tests en volledige
|
||||
gate met 327 tests groen, 84,04% dekking.
|
||||
- id: VR-230
|
||||
title: Herstel de immutable Gitea-releasepublicatie
|
||||
status: ready
|
||||
priority: P0
|
||||
requirement_ids:
|
||||
- NFR-009
|
||||
depends_on:
|
||||
- VR-229
|
||||
summary: Verwijder de externe GitHub-API-rate-limit uit de releasebootstrap en publiceer de actuele productiebuild
|
||||
opnieuw als aantoonbaar immutable private-registryimage.
|
||||
acceptance_criteria:
|
||||
- De uv-bootstrap is op een immutable actioncommit en een exacte uv-versie gepind.
|
||||
- De releaseworkflow gebruikt voor uv-downloads geen GitHub Releases-API die op een gedeeld runner-IP kan limiteren.
|
||||
- Gitea Actions publiceert een private registryimage met digest, SBOM, checksums en release notes.
|
||||
- Unraid is op de gepubliceerde digest gepind en live health/readiness slagen.
|
||||
verification:
|
||||
- uv run pytest tests/unit/test_deployment_configuration.py
|
||||
- ./scripts/codex_verify.sh
|
||||
- Gitea Actions releasebewijs en live Unraid health/readiness
|
||||
primary_paths:
|
||||
- .gitea/workflows/release.yml
|
||||
- tests/unit/test_deployment_configuration.py
|
||||
- docs/audit/FINAL_ACCEPTANCE.md
|
||||
|
||||
+112
-8
@@ -1,7 +1,7 @@
|
||||
# Projectstatus
|
||||
|
||||
- Laatst bijgewerkt: 2026-07-29
|
||||
- Repositoryversie: 0.3.13 integrale professionalisering
|
||||
- Laatst bijgewerkt: 2026-08-12
|
||||
- Repositoryversie: 0.3.16 publieke demo-hardening
|
||||
- Uitvoeringsmodus: autonome backlog
|
||||
- Eerste uitvoerbare taak: controleer met `python scripts/backlog.py next`
|
||||
|
||||
@@ -19,8 +19,110 @@ De repository bevat een uitvoerbare Django-MVP met:
|
||||
- Celeryworker/scheduler, healthchecks, Docker/Unraidconfiguratie en back-up-/herstelscripts;
|
||||
- offline unit-, integratie- en securitytests.
|
||||
|
||||
## Laatste documentatie-update
|
||||
|
||||
Visuele demo-hardening `VR-229` op 2026-08-12:
|
||||
|
||||
- iedere publieke demosessie herstelt idempotent zes actuele fictieve vacatures,
|
||||
de laatste scores, twee toekomstige opvolgmomenten en negen geïsoleerde
|
||||
`example.test`-platformmailboxkoppelingen;
|
||||
- mailboxbronnen worden niet meer door de URL-fetchscheduler gepland, de
|
||||
publieke demo wordt uitgesloten van echte mailboxpolling en historische
|
||||
mailboxpolicyfouten vervuilen dashboard en activiteit niet langer;
|
||||
- navigatie en dashboard onderscheiden nu expliciet een actief zoekprofiel van
|
||||
bron- of systeemgezondheid; het lege loginwachtwoordveld heeft geen misleidende
|
||||
mask-placeholder meer;
|
||||
- visuele audit: negen kernroutes groen in dark/light op 390×844, 1280×600,
|
||||
1440×900 en 3440×1440 zonder overflow, heading- of consolefouten;
|
||||
- gerichte regressies: 51 tests groen; volledige gate: 327 tests, 0 failures,
|
||||
0 skips en 84,04% branch-aware dekking;
|
||||
- commit `a6c8a79` en tag `v0.3.16` zijn naar Gitea gepusht; vóór uitrol is onder
|
||||
`/mnt/user/appdata/VacatureRadar/local/backups/release-0.3.16-20260812T094608Z`
|
||||
een checksum-geldige database- en configuratieback-up gemaakt;
|
||||
- Unraid draait healthy zonder restarts op lokaal gebouwde image
|
||||
`sha256:c98001c063e8358dc59dfc175c574187b36021dd5a81f44e3ba982a28c9f9341`;
|
||||
een live sessiesmoke bevestigde dashboard 200, zes demomatches, negen
|
||||
platformmailboxen, actieve ingress, nul oude policyfouten en geen
|
||||
wachtwoord-maskplaceholder;
|
||||
- de Gitea-releaseworkflow faalde tweemaal in stap `astral-sh/setup-uv@v6` en
|
||||
publiceerde daarom nog geen 0.3.16-registrymanifest. De bewezen immutable
|
||||
registryrelease blijft 0.3.15; runnerdiagnose en herpublicatie vereisen opnieuw
|
||||
bereikbare Unraid-beheertoegang. Volgende uitvoerbare backlogtaak: geen.
|
||||
|
||||
README-update op 2026-07-30:
|
||||
|
||||
- de repository-ingang benoemt release 0.3.15, de groene 324-testgate, de live
|
||||
M365 app-only OAuth/XOAUTH2-flow en de immutable private Gitea-registryrelease;
|
||||
- eerste configuratie noemt alle negen ondersteunde jobplatformen en verwijst
|
||||
expliciet naar het M365 least-privilege- en rotatiepad in het runbook;
|
||||
- de bestaande negen screenshots zijn visueel herbeoordeeld. De layout is nog
|
||||
actueel; de bronafbeelding toont bewust een gesaneerde demo zonder actieve
|
||||
ingress en is daarom als demo-momentopname verduidelijkt, niet vervangen door
|
||||
een productiebeeld met accountgegevens;
|
||||
- eindverificatie: volledige gate groen met 324 tests, 0 failures, 0 skips en
|
||||
84,07% branch-aware dekking.
|
||||
|
||||
## Laatste productiehardening
|
||||
|
||||
Productieoplevering 0.3.14 op 2026-07-29:
|
||||
|
||||
- VR-228 en commit `cbd7220` zijn via `origin/main` uitgerold als image `sha256:1ee84241c2f4ecef751f45df760054309843e1e98192035bc27476ae5a9477df`;
|
||||
- de geïsoleerde PostgreSQL-runner mat na één warm-up drie identieke queryprofielen: 3.264 importqueries, gemiddeld 9.040,123 ms, p50 32,900 ms, p95 52,296 ms, 28,031 documenten/s en 18,799 MB piekgeheugen;
|
||||
- tegenover 0.3.13 is de import 44,74% sneller, gebruikt hij minstens 63,73% minder queries en stijgt throughput 83,42%; parser 12/12, precision/recall 1,0 en rankingchurn 0 bleven behouden;
|
||||
- beide volledige gates waren groen met elk 318 tests, 0 failures, 0 skips en 84,20% dekking;
|
||||
- de pre-releasecustomdump, media en composeconfig staan checksum-geldig onder `/mnt/user/appdata/VacatureRadar/backups/release-0.3.14-20260729T164500Z`; rollbackimage `vacatureradar:rollback-0.3.13` wijst naar `sha256:34aef8853f77a3f311b119bd67d9f1160a97fd4a21e62c9776222be289bf3b88`;
|
||||
- productie is healthy zonder restarts, versie 0.3.14 is publiek zichtbaar, alle gastkernroutes antwoorden 200 en een echte importsmoke creëerde één job binnen een transactie waarna rollback expliciet bevestigd werd.
|
||||
|
||||
Productieoplevering 0.3.15 op 2026-07-30:
|
||||
|
||||
- `VR-202` is live afgerond met een dedicated alias, geïsoleerde Exchange-map en
|
||||
inboxregel, app-only OAuth/XOAUTH2 voor IMAP en SMTP en negen platformconnecties;
|
||||
- dezelfde echte testmail werd per connectie eenmaal geïmporteerd en bij herhaling
|
||||
als duplicate herkend; alle ingestelde intervallen zetten `next_poll_at` correct
|
||||
en de scheduler plande geen connectie te vroeg;
|
||||
- de productie-Fernet-sleutel werd live naar een nieuwe primaire MultiFernet-sleutel
|
||||
geroteerd; een tijdelijke versleutelde custom credential bleef ontsleutelbaar en
|
||||
is na de proef verwijderd;
|
||||
- één echte digest werd verzonden en de outbox eindigde op `sent`; productie-logs
|
||||
bevatten geen OAuth-secret, bearer-token of mailpayload;
|
||||
- `VR-204` publiceerde `v0.3.15` naar de private Gitea-registry en Unraid is gepind
|
||||
op `sha256:5a6dc253db7a60c42c0c806ae084ddc6ee7c98db5db71a857423b894a3662ba5`;
|
||||
- commitlabel, digest, SPDX-SBOM, checksums en release notes zijn checksum-geldig
|
||||
opgeslagen als Gitea Actions-artifact `release-evidence-v0.3.15`;
|
||||
- pull, interne en publieke readiness, rollback naar `0.3.14` en herstel naar de
|
||||
immutable `0.3.15`-digest zijn live geslaagd;
|
||||
- lokale en Gitea-releasegate zijn groen met 324 tests, 0 failures, 0 skips en
|
||||
84,05% branch-aware dekking.
|
||||
|
||||
Volgende uitvoerbare taak: geen; alle P0- en P1-releasepunten zijn afgerond.
|
||||
|
||||
## Laatste risicohardening
|
||||
|
||||
Live mailboxvoorbereiding op 2026-07-30:
|
||||
|
||||
- Exchange Online bevestigde dat de dedicated VacatureRadar-alias aan de productie-mailbox is toegevoegd;
|
||||
- de alias is niet primair gemaakt en bestaande adressen zijn ongewijzigd gebleven;
|
||||
- de Entra-appregistratie, admin consent en Exchange-mailboxscope zijn live ingericht
|
||||
met app-only OAuth; tijdelijke bootstraprechten en de bootstrap-app zijn na afloop verwijderd;
|
||||
- `VR-202` is volledig afgerond; Basic Auth is niet geactiveerd;
|
||||
- de volledige lokale gate is groen met 324 tests, 0 failures, 0 skips en 84,05%
|
||||
branch-aware dekking.
|
||||
|
||||
Risicohardening op 2026-07-29:
|
||||
|
||||
- de productiefetcher blokkeert nu fail-closed wanneer de HTTP-transportlaag het
|
||||
werkelijk verbonden peer-IP niet beschikbaar stelt; ieder niet-publiek peer-IP
|
||||
bleef al geblokkeerd;
|
||||
- alleen expliciet geïnjecteerde test/custom clients mogen nog de begrensde
|
||||
DNS-overlapfallback gebruiken; een negatieve regressietest dekt de
|
||||
productiegrens;
|
||||
- `VR-203` is administratief afgerond op basis van het bestaande live bewijs in
|
||||
`docs/audit/FINAL_ACCEPTANCE.md`: publieke HTTP→HTTPS, TLS/securityheaders,
|
||||
veilige cookies, CSRF-gastlogin, kernroutes en readiness zijn aantoonbaar
|
||||
geslaagd;
|
||||
- verificatie: Ruff groen, 8 gerichte fetchertests geslaagd en de volledige gate
|
||||
groen met 319 tests, 0 failures, 0 skips en 84,21% branch-aware dekking.
|
||||
|
||||
Productieoplevering 0.3.13 op 2026-07-29:
|
||||
|
||||
- basiscommit `5e2453c` en hardeningcommit `a4a71eb` naar `origin/main` gepusht en op Unraid werkelijk gebouwd als finale image `sha256:34aef8853f77a3f311b119bd67d9f1160a97fd4a21e62c9776222be289bf3b88`;
|
||||
@@ -535,21 +637,23 @@ Een toekomstige agent moet altijd de actuele waarheid opnieuw bepalen met:
|
||||
|
||||
## Nog te bouwen
|
||||
|
||||
Momenteel staan er geen `ready` taken meer over; het volgende te behandelen item is `VR-202` als `blocked-external` voor live mailbox-/SMTP-credentials en echte jobalertmails van de gekozen platformen.
|
||||
Momenteel staan er geen `ready` taken of P0/P1-releaseblokkers meer over.
|
||||
|
||||
## Externe blokkades
|
||||
|
||||
Deze blokkeren onafhankelijke code niet:
|
||||
|
||||
- verdere persoonlijke werkgevers-/bronnenkeuze en actuele menselijke review buiten de actieve VITO-/Deliverect-startercatalogus;
|
||||
- live IMAP/SMTP-account en app-password;
|
||||
- Unraid-domein, DNS, TLS en reverse proxy;
|
||||
- containerregistry en credentials;
|
||||
- actieve/voor deze repository geregistreerde Gitea Actions-runner;
|
||||
|
||||
## Bekende verificatiebeperking van de aangeleverde basis
|
||||
|
||||
De Pythonapplicatie, Dockerimage en PostgreSQL/Redis/Unraid-stack zijn geverifieerd. De optionele pytest-Playwrightvarianten vallen lokaal terug op de HTML/a11y-probe; VR-119 is aanvullend met de geïntegreerde browser op alle kernroutes uitgevoerd, VR-120 op het begeleide profiel en de live vacaturefeed en VR-124 op de acht platformmailboxen en alertkaarten. Een publieke HTTPS-smoke blijft afhankelijk van domein-, DNS- en reverse-proxyconfiguratie.
|
||||
De Pythonapplicatie, Dockerimage en PostgreSQL/Redis/Unraid-stack zijn
|
||||
geverifieerd. De verplichte Playwright-gate draait lokaal met Chromium; VR-119 is
|
||||
aanvullend met de geïntegreerde browser op alle kernroutes uitgevoerd, VR-120 op
|
||||
het begeleide profiel en de live vacaturefeed en VR-124 op de acht
|
||||
platformmailboxen en alertkaarten. Publieke HTTPS, proxyheaders en de gastflow
|
||||
zijn op 2026-07-29 live geaccepteerd en vastgelegd in
|
||||
`docs/audit/FINAL_ACCEPTANCE.md`.
|
||||
|
||||
## Hervatten
|
||||
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
# Finale acceptance 0.3.13
|
||||
# Finale acceptance 0.3.15
|
||||
|
||||
Status: productie geaccepteerd op 2026-07-29.
|
||||
Status: productie geaccepteerd op 2026-07-30.
|
||||
|
||||
## Bewijs
|
||||
|
||||
- `./scripts/codex_verify.sh`: 312 geslaagd, 0 mislukt, 0 overgeslagen, 84,16% branch-aware dekking.
|
||||
- `./scripts/codex_verify.sh` is tweemaal uitgevoerd: telkens 318 geslaagd, 0 mislukt, 0 overgeslagen en 84,20% branch-aware dekking.
|
||||
- Geïsoleerde importbenchmark: één warm-up plus drie PostgreSQL-runs; gemiddeld 3.264 queries, 9.040,123 ms, p50 32,900 ms, p95 52,296 ms, 28,031 documenten/s en 18,799 MB piekgeheugen.
|
||||
- Benchmarkkwaliteit: 12/12 parservelden, dedupeprecision 1,0, recall 1,0 en rankingchurn 0,0.
|
||||
- Unraid Docker 27.5.1 en Compose 2.40.3 bouwden image `sha256:34aef8853f77a3f311b119bd67d9f1160a97fd4a21e62c9776222be289bf3b88`; migrations, 148 static files, manifestgeneratie, healthcheck en restart slaagden.
|
||||
- Unraid bouwde image `sha256:1ee84241c2f4ecef751f45df760054309843e1e98192035bc27476ae5a9477df`; migrations, 148 static files, healthcheck en zero-restartstart slaagden.
|
||||
- HTTP redirect naar HTTPS, OpenResty/TLS, HSTS/securityheaders, `DEBUG=False`, secure session-/CSRF-cookies en geen `DisallowedHost` zijn publiek bewezen.
|
||||
- Login en gastknop, alleen-lezen gasttoegang, dashboard, zoek/filterlijst, detail, pipeline, bronnen en automatisering werkten live.
|
||||
- De live loginpagina is visueel en semantisch in Chrome gecontroleerd. De CSRF-beveiligde gastlogin en dashboard, vacaturelijst, skills, sollicitaties, profielen, bronnen en systeemstatus antwoordden daarna allemaal 200.
|
||||
- Dark/light en 390/1280/3440 px hadden nul horizontale overflow en nul browserconsole-errors; de mobiele gastbanner bleef in de flow.
|
||||
- PostgreSQL custom dump plus media/configbewijs is naar een geïsoleerde database hersteld; migrations, exacte recordtellingen en een aparte applicatiestart slaagden.
|
||||
- Vorige image `sha256:1851863be947b1e8b504e61e73b89d09c1dff704257b48856c775bb55f741459` is live healthy gestart en daarna opnieuw vervangen door 0.3.13, eveneens healthy.
|
||||
- Een productie-importsmoke extraheerde en creëerde één geldige job in een expliciete transactie; rollback werd bevestigd en liet geen smokedata achter.
|
||||
- De checksum-geldige pre-releaseback-up staat onder `backups/release-0.3.14-20260729T164500Z`; rollbacktag `vacatureradar:rollback-0.3.13` bewaart image `sha256:34aef8853f77a3f311b119bd67d9f1160a97fd4a21e62c9776222be289bf3b88`.
|
||||
- M365 IMAP en SMTP werken app-only via OAuth/XOAUTH2; negen geïsoleerde
|
||||
platformconnecties importeerden één live testmail idempotent en één testdigest
|
||||
eindigde met outboxstatus `sent`.
|
||||
- Gitea Actions publiceerde `v0.3.15` met immutable digest
|
||||
`sha256:5a6dc253db7a60c42c0c806ae084ddc6ee7c98db5db71a857423b894a3662ba5`.
|
||||
Het artifact bevat een geverifieerde SPDX-SBOM, checksums en release notes.
|
||||
- Unraid is op die digest gepind; pull, health/readiness, rollback naar `0.3.14`
|
||||
en herstel naar `0.3.15` zijn live geslaagd.
|
||||
|
||||
## Uitkomst
|
||||
|
||||
Er bestaan geen bekende P0- of P1-releaseblokkers. Alleen optionele Entra- en mailboxcredentials blijven extern. De importqueryoptimalisatie blijft een meetbaar performancevervolg en geen correctness- of releaseblokker.
|
||||
Er bestaan geen bekende P0- of P1-releaseblokkers.
|
||||
|
||||
@@ -77,6 +77,11 @@ celery -A config inspect reserved
|
||||
## Digest ontbreekt of dubbel
|
||||
|
||||
- Controleer actief profiel, digesttijd/zone en `DIGEST_RECIPIENT`.
|
||||
- Microsoft 365 gebruikt uitsluitend app-only OAuth. Configureer `M365_TENANT_ID`,
|
||||
`M365_CLIENT_ID`, `M365_CLIENT_SECRET`, `M365_MAILBOX_USER` en
|
||||
`EMAIL_BACKEND=apps.notifications.backends.Microsoft365OAuthEmailBackend`.
|
||||
Verleen alleen `IMAP.AccessAsApp` en `SMTP.SendAsApp`, admin consent en mailboxscope;
|
||||
Basic Auth en Microsoft 365-app-wachtwoorden worden niet ondersteund.
|
||||
- Controleer `DigestOutbox` op datum en status.
|
||||
- Bij SMTP-fout: corrigeer configuratie en retry dezelfde outbox gecontroleerd.
|
||||
- Maak geen tweede outboxrecord handmatig; uniciteit per profiel/datum is de duplicaatbarrière.
|
||||
|
||||
@@ -164,7 +164,9 @@ IMAP_MAX_MESSAGES_PER_POLL=200
|
||||
IMAP_MAX_MESSAGE_BYTES=1000000
|
||||
```
|
||||
|
||||
Herstart web, worker en scheduler. Open daarna **Bronnen → Platformmailboxen** en koppel ieder gewenst platform afzonderlijk: VDAB, Indeed, LinkedIn, ictjob.be, Jobat, StepStone, Careerjet, Randstad of Robert Half. Kies Gmail, Outlook / Microsoft 365 of een aangepaste publieke IMAP-host, gebruik een app-wachtwoord en laat `INBOX` geselecteerd tenzij de alert naar een aparte map wordt gefilterd. De scheduler controleert iedere vijf minuten welke mailbox volgens haar eigen interval verschuldigd is.
|
||||
Herstart web, worker en scheduler. Open daarna **Bronnen → Platformmailboxen** en koppel ieder gewenst platform afzonderlijk: VDAB, Indeed, LinkedIn, ictjob.be, Jobat, StepStone, Careerjet, Randstad of Robert Half. Gmail en aangepaste publieke IMAP-hosts gebruiken een app-wachtwoord. Outlook / Microsoft 365 gebruikt centraal app-only OAuth via de vier `M365_*`-waarden en `EMAIL_BACKEND=apps.notifications.backends.Microsoft365OAuthEmailBackend`; geef de Entra-app alleen `IMAP.AccessAsApp` en `SMTP.SendAsApp` en beperk de Exchange-serviceprincipal tot de bedoelde mailbox. Laat `INBOX` geselecteerd tenzij de alert naar een aparte map wordt gefilterd. De scheduler controleert iedere vijf minuten welke mailbox volgens haar eigen interval verschuldigd is.
|
||||
|
||||
Voor een immutable registryrelease zet je `VACATURERADAR_IMAGE=gitea.itworx.tech/jens/vacatureradar:<versie>@sha256:<digest>` en start je met `docker compose -f docker-compose.unraid.yml up -d --no-build`.
|
||||
|
||||
Voor sleutelrotatie zet je `nieuwe-sleutel,oude-sleutel` in `MAILBOX_CREDENTIAL_KEYS`, herstart je de processen en draai je:
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ Django/Celery
|
||||
| T-40 | Verouderde lokale CA-store of ongeldige serverketen lokt een TLS-bypass uit | onderschepte vacature- of mailboxdata en credentialdiefstal | expliciet gepinde `certifi`-CA-bundel voor HTTP, robots en IMAP; hostnaamcontrole en `CERT_REQUIRED`; nooit `verify=False`; dependency- en negatieve TLS-tests bij update | `tests/unit/test_tls.py`, `tests/security/test_robots.py`, `apps/sources/services/tls.py`, `VR-201` |
|
||||
| T-41 | Gemodificeerde `next`-parameter na vacaturefeedback | open redirect naar phishingdomein na een geldige gebruikersactie | terugkeer-URL moet dezelfde host en het actuele HTTPS-beleid respecteren; anders veilige detailfallback | `tests/integration/test_tasks_and_more_views.py`, `VR-206` |
|
||||
| T-42 | Gespoofte proxyheaders of ontbrekende publieke hostconfiguratie | host-headerfouten, onjuiste HTTPS-detectie of omzeilde IP-rate-limiting | `PUBLIC_BASE_URL`, exacte host/CSRF-validatie, proxy moet headers overschrijven, forwarded client-IP alleen vanaf `TRUSTED_PROXY_CIDRS` | `tests/unit/test_public_configuration.py`, `tests/unit/test_network.py`, `VR-225` |
|
||||
| T-43 | Gedeeld demoaccount wijzigt persistente data | bezoekers beïnvloeden elkaars ervaring of echte data | server-side read-only middleware, korte sessie, geen bruikbaar wachtwoord, zichtbare UI-status en productiecheck | `tests/integration/test_auth_login.py`, `tests/unit/test_settings_security.py`, `VR-225` |
|
||||
| T-43 | Gedeeld demoaccount wijzigt persistente data of activeert echte mailboxpolling | bezoekers beïnvloeden elkaars ervaring, echte data of externe accounts | server-side read-only middleware, korte sessie, geen bruikbaar wachtwoord, uitsluitend fictieve `example.test`-mailboxen; de loginseed herstelt begrensd en idempotent en de scheduler sluit de demo-user expliciet uit | `tests/integration/test_auth_login.py`, `tests/integration/test_tasks_and_more_views.py`, `tests/unit/test_settings_security.py`, `VR-225/229` |
|
||||
|
||||
## Misbruikscenario's
|
||||
|
||||
@@ -115,7 +115,10 @@ Een wijziging aan netwerk, mail, AI, rendering, auth, exports of uploads vereist
|
||||
|
||||
## Resterende risico's
|
||||
|
||||
- De fetcher controleert het werkelijk verbonden peer-IP wanneer httpcore dat exposeert. Een custom transport dat geen peerinformatie aanbiedt valt terug op DNS-set-overlap; volledige IP-pinning blijft een aanvullend egress-hardeningniveau.
|
||||
- De productiefetcher blokkeert fail-closed wanneer httpcore het werkelijk verbonden
|
||||
peer-IP niet exposeert en weigert ieder niet-publiek peer-IP. Alleen een expliciet
|
||||
geïnjecteerde test/custom client mag terugvallen op DNS-set-overlap; die clientgrens
|
||||
mag daarom niet voor productie-fetches worden gebruikt.
|
||||
- Brute-force of herhaalde foutieve mutatiepogingen blijven mogelijk, maar zijn in productie begrensd via gedeelde Redis-rate-limiting en blokkades op login/manual-import. Correcte individuele client-IPherkenning vereist het exacte proxy-CIDR.
|
||||
- Bronvoorwaarden vereisen menselijke/externe review per domein; automatisering kan dat niet juridisch beslissen.
|
||||
- Fuzzy dedupe en featureheuristieken kunnen inhoudelijk verkeerd zijn; provenance, feedback en benchmarkevaluaties beperken maar elimineren dit niet.
|
||||
@@ -150,3 +153,8 @@ Een wijziging aan netwerk, mail, AI, rendering, auth, exports of uploads vereist
|
||||
- Impact: herhaalde herstarts van dezelfde bron veroorzaken extra belasting
|
||||
- Mitigatie: cooldown plus metadataflag voor eenmalige canary, en trialstatus met beperkte runtrigger
|
||||
- Verificatie: `tests/integration/test_source_health.py`
|
||||
# Microsoft 365 OAuth
|
||||
|
||||
- Exchange Online gebruikt app-only XOAUTH2; Basic Auth en app-wachtwoorden zijn uitgesloten.
|
||||
- Client secrets leven alleen in de productie-secretstore en tokenfouten bevatten geen token of secret.
|
||||
- De serviceprincipal krijgt uitsluitend IMAP/SMTP-toegang tot de bedoelde mailbox; IMAP-selectie blijft read-only.
|
||||
|
||||
@@ -85,6 +85,7 @@ Iedere backlogtaak die een requirement wijzigt, werkt in dezelfde commit deze ma
|
||||
|
||||
## Aanvullende traceability-opmerking
|
||||
- VR-228 levert release 0.3.14 met een fail-closed disposable PostgreSQL-benchmark, run-lokale importcaches, gebundelde veldprovenance en ongewijzigde score-snapshotcopy. De officiële drie iteraties bewijzen 3.264 queries per 250 items, parser 12/12, dedupeprecision/recall 1,0 en nul rankingchurn; bewijs staat in `docs/audit/IMPORT_PERFORMANCE_*.md` en het machineleesbare artifact.
|
||||
- VR-229 houdt de publieke demo representatief door fictieve vacatures, scores, mailboxkoppelingen en opvolgdatums bij iedere demosessie idempotent te herstellen. Mailboxingress is uitgesloten van URL-fetchplanning en operationele UI-copy onderscheidt een actief profiel van bron- en systeemgezondheid.
|
||||
- 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-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.
|
||||
@@ -96,6 +97,12 @@ Iedere backlogtaak die een requirement wijzigt, werkt in dezelfde commit deze ma
|
||||
- VR-122 operationaliseert de veilige platformingress als dagelijkse, configureerbare en begrensde mailboxpoll. VDAB-alerts krijgen expliciete provenance, replay blijft idempotent en de bronpagina onderscheidt de werkende jobalertroute van de contractueel afgeschermde officiële Vacature API. Bewijs staat in de e-mailadapter-/importtests, `apps/sources/services/imap_import.py` en ADR-0005.
|
||||
- VR-123 vervangt die globale poll door gebruikersgescopeerde VDAB- en Indeed-mailboxkoppelingen met versleutelde app-wachtwoorden, afzonderlijke intervallen, per-mailboxleases en UI-beheer. Bewijs staat in `tests/unit/test_mailbox_credentials.py`, `tests/integration/test_mailbox_connections.py`, de migratie en ADR-0009.
|
||||
- VR-124 breidt dezelfde mailboxingang uit met LinkedIn, ictjob.be, Jobat, StepStone, EuroBrussels en BrusselsJobs. De platformgebonden parser accepteert uitsluitend gelabelde HTTPS-links op het gekozen jobboarddomein; fixtures, adapter-/integratietests en de centrale denylist bewijzen de afscherming.
|
||||
- VR-202 bewijst de productie-ingress met M365 app-only OAuth/XOAUTH2, een
|
||||
dedicated alias en map, negen platformconnecties, een idempotente live
|
||||
testmail, correcte schedulerstatus en een verzonden digest-outbox.
|
||||
- VR-204 bewijst de immutable releaseketen via de private Gitea-registry,
|
||||
digest-pin, commitlabel, SPDX-SBOM, checksums, release notes en een live
|
||||
Unraid pull-/rollback-/herstelsmoke.
|
||||
- VR-125 vervangt de twee Brusselse nicheboards door Careerjet, Randstad en Robert Half, retireert buitenregionale werkgevers zonder provenanceverlies en voegt host- en padgebonden adapters toe voor ACA Group, Xplore Group, UHasselt, PXL en ZOL in de kern Hasselt-Genk.
|
||||
- VR-126 herankert profiel en broncatalogus op postcode 2400 Mol en 40 km. De host-, pad- en locatiefilters voor SCK CEN, Cipal Schaubroeck, VanRoey, NTX en Thomas More worden gedekt door regionale adaptertests, source-viewtests, live bronruns en de bijgewerkte threat T-39.
|
||||
- VR-201 activeert Gemeente Mol en zeven bijkomende Kempen-werkgevers na menselijke voorwaardenreview, fixtures, fail-closed locatie-/routefilters en twee livecycli. Twaalf interessante werkgevers zonder stabiele actieve ingang staan afzonderlijk en niet-scanbaar op de UI-waaklijst. Bewijs staat in de livecatalogus, seeddata, `KempenEmployerAdapter`, bronruns en adapter-/seed-/viewtests.
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-radar-card">
|
||||
<span class="status-beacon {% if navigation_profile %}is-live{% endif %}" aria-hidden="true"></span>
|
||||
<span><strong>{% if navigation_profile %}Radar actief{% else %}Profiel nodig{% endif %}</strong><small>{% if navigation_profile %}{{ navigation_profile.name }}{% else %}Configureer je zoekprofiel{% endif %}</small></span>
|
||||
<span><strong>{% if navigation_profile %}Profiel actief{% else %}Profiel nodig{% endif %}</strong><small>{% if navigation_profile %}{{ navigation_profile.name }}{% else %}Configureer je zoekprofiel{% endif %}</small></span>
|
||||
</div>
|
||||
{% include "components/developer_credit.html" %}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block content %}
|
||||
<header class="cockpit-heading dashboard-heading">
|
||||
<div><p class="eyebrow">Intelligence cockpit · vandaag</p><h1>Goedemorgen, {{ user.first_name|default:user.username }}.</h1><p>{{ day_conclusion }}</p></div>
|
||||
<div class="radar-status" aria-label="Radarstatus"><span class="status-beacon {% if active_profile %}is-live{% endif %}" aria-hidden="true"></span><span>{% if active_profile %}{{ active_profile.name }} actief{% else %}Geen actief profiel{% endif %}</span>{% if latest_scan %}<span aria-hidden="true">·</span><span>scan {{ latest_scan.started_at|timesince }} geleden</span>{% endif %}</div>
|
||||
<div class="radar-status" aria-label="Profiel- en scanstatus"><span class="status-beacon {% if active_profile %}is-live{% endif %}" aria-hidden="true"></span><span>{% if active_profile %}Profiel actief · {{ active_profile.name }}{% else %}Geen actief profiel{% endif %}</span>{% if latest_scan %}<span aria-hidden="true">·</span><span>laatste URL-scan {{ latest_scan.started_at|timesince }} geleden</span>{% endif %}</div>
|
||||
</header>
|
||||
|
||||
<section class="stats-grid" aria-label="Radar kerncijfers">
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
</div>
|
||||
<div class="input-wrap">
|
||||
<input class="auth-input" id="id_password" name="password" type="password"
|
||||
autocomplete="current-password" placeholder="••••••••" required
|
||||
autocomplete="current-password" required
|
||||
{% if form.errors %}aria-invalid="true"{% endif %}>
|
||||
<button class="pw-toggle" type="button" data-pw-toggle aria-label="Wachtwoord tonen">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" aria-hidden="true" data-eye><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z" stroke="currentColor" stroke-width="1.8"/><circle cx="12" cy="12" r="2.7" stroke="currentColor" stroke-width="1.8"/></svg>
|
||||
|
||||
@@ -135,15 +135,54 @@ def test_demo_login_logs_in_and_seeds_environment(client):
|
||||
# Het gevulde dashboard rendert zonder fouten.
|
||||
dashboard = client.get(reverse("dashboard:today"))
|
||||
assert dashboard.status_code == 200
|
||||
dashboard_html = dashboard.content.decode("utf-8")
|
||||
assert "Profiel actief" in dashboard_html
|
||||
assert "Radar actief" not in dashboard_html
|
||||
assert "Nog geen persoonlijke matches" not in dashboard_html
|
||||
|
||||
sources = client.get(reverse("sources:list"))
|
||||
assert sources.status_code == 200
|
||||
assert "minstens één import actief" in sources.content.decode("utf-8")
|
||||
|
||||
|
||||
def test_demo_login_is_idempotent(client):
|
||||
from apps.jobs.models import JobPosting
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.jobs.models import Application, JobPosting
|
||||
from apps.profiles.models import SearchProfile
|
||||
from apps.sources.models import MailboxConnection, Source
|
||||
|
||||
client.post(reverse("demo-login"))
|
||||
first = JobPosting.objects.count()
|
||||
demo_user = get_user_model().objects.get(username=settings.DEMO_USERNAME)
|
||||
profile = SearchProfile.objects.get(user=demo_user, is_active=True)
|
||||
job = profile.scores.order_by("-score").first().job
|
||||
job.status = JobPosting.Status.EXPIRED
|
||||
job.last_seen = timezone.now() - timedelta(days=30)
|
||||
job.save(update_fields=["status", "last_seen"])
|
||||
application = Application.objects.filter(user=demo_user).first()
|
||||
application.follow_up_date = timezone.localdate() - timedelta(days=14)
|
||||
application.save(update_fields=["follow_up_date"])
|
||||
|
||||
client.logout()
|
||||
client.post(reverse("demo-login"))
|
||||
assert JobPosting.objects.count() == first
|
||||
job.refresh_from_db()
|
||||
application.refresh_from_db()
|
||||
assert job.status == JobPosting.Status.ACTIVE
|
||||
assert job.last_seen.date() == timezone.localdate()
|
||||
assert application.follow_up_date > timezone.localdate()
|
||||
assert profile.scores.count() == 6
|
||||
assert MailboxConnection.objects.filter(user=demo_user, enabled=True).count() == 9
|
||||
assert Source.objects.filter(source_type=Source.Type.EMAIL, failure_count=0).count() >= 9
|
||||
|
||||
|
||||
def test_login_password_field_has_no_mask_placeholder(client):
|
||||
html = client.get(reverse("login")).content.decode("utf-8")
|
||||
assert 'id="id_password"' in html
|
||||
assert 'placeholder="••••••••"' not in html
|
||||
|
||||
|
||||
def test_demo_account_is_read_only_and_session_is_bounded(client):
|
||||
|
||||
@@ -61,6 +61,7 @@ def test_user_can_link_edit_pause_and_sync_separate_platform_mailboxes(client, u
|
||||
)
|
||||
assert response.status_code == 302
|
||||
indeed = MailboxConnection.objects.get(user=user, platform="indeed")
|
||||
assert indeed.encrypted_password == ""
|
||||
|
||||
edit_response = client.get(reverse("sources:mailbox_edit", args=[vdab.pk]))
|
||||
assert edit_response.status_code == 200
|
||||
|
||||
@@ -104,6 +104,55 @@ def test_fetch_source_resets_fail_state_on_304(db, source, monkeypatch):
|
||||
assert source.failure_count == 0
|
||||
|
||||
|
||||
def test_due_source_scheduler_never_schedules_mailbox_sources(db, monkeypatch):
|
||||
from apps.sources.tasks import schedule_due_sources
|
||||
|
||||
email_source = Source.objects.create(
|
||||
name="Mailboxbron",
|
||||
source_type=Source.Type.EMAIL,
|
||||
base_url="",
|
||||
domain="example.mailbox.local",
|
||||
status=Source.Status.ACTIVE,
|
||||
policy=Source.Policy.ALLOW,
|
||||
)
|
||||
scheduled = []
|
||||
monkeypatch.setattr(
|
||||
"apps.sources.tasks.fetch_source.delay", lambda source_id: scheduled.append(source_id)
|
||||
)
|
||||
|
||||
result = schedule_due_sources()
|
||||
|
||||
assert email_source.pk not in scheduled
|
||||
assert result["scheduled"] == len(scheduled)
|
||||
|
||||
|
||||
def test_mailbox_scheduler_excludes_public_demo_user(db, monkeypatch, settings):
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from apps.sources.models import MailboxConnection
|
||||
from apps.sources.tasks import schedule_mailbox_polls
|
||||
|
||||
demo = get_user_model().objects.create_user(username=settings.DEMO_USERNAME)
|
||||
connection = MailboxConnection.objects.create(
|
||||
user=demo,
|
||||
platform="vdab",
|
||||
provider="outlook",
|
||||
username="demo@example.test",
|
||||
mailbox="Demo/VDAB",
|
||||
enabled=True,
|
||||
next_poll_at=timezone.now() - timedelta(minutes=1),
|
||||
)
|
||||
scheduled = []
|
||||
monkeypatch.setattr(
|
||||
"apps.sources.tasks.poll_mailbox.delay",
|
||||
lambda connection_id: scheduled.append(connection_id),
|
||||
)
|
||||
|
||||
schedule_mailbox_polls()
|
||||
|
||||
assert connection.pk not in scheduled
|
||||
|
||||
|
||||
def test_fetch_source_is_blocked_by_conflicting_review(db):
|
||||
source = Source.objects.create(
|
||||
name="Blocked source",
|
||||
|
||||
@@ -128,3 +128,11 @@ def test_deploy_uses_selected_environment_file_for_compose() -> None:
|
||||
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 "waarde uit ${env_file}" in deploy_script
|
||||
|
||||
|
||||
def test_release_uv_install_is_immutable_and_does_not_require_github_api() -> None:
|
||||
workflow = (ROOT / ".gitea" / "workflows" / "release.yml").read_text(encoding="utf-8")
|
||||
|
||||
assert "astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0" in workflow
|
||||
assert 'version: "0.11.12"' in workflow
|
||||
assert "astral-sh/setup-uv@v6" not in workflow
|
||||
|
||||
@@ -8,6 +8,7 @@ from apps.sources.services.fetcher import (
|
||||
FetchTimeoutError,
|
||||
PolicyBlockedError,
|
||||
RateLimitedError,
|
||||
_validate_connected_peer,
|
||||
fetch_url,
|
||||
)
|
||||
from apps.sources.services.url_security import ValidatedUrl
|
||||
@@ -100,6 +101,24 @@ def test_fetcher_rejects_dns_rebinding(monkeypatch):
|
||||
client.close()
|
||||
|
||||
|
||||
def test_production_transport_fails_closed_without_connected_peer():
|
||||
validated = ValidatedUrl(
|
||||
url="https://example.org/jobs",
|
||||
hostname="example.org",
|
||||
port=443,
|
||||
addresses=("93.184.216.34",),
|
||||
)
|
||||
response = httpx.Response(200)
|
||||
|
||||
with pytest.raises(FetchError, match="Verbonden IP-adres"):
|
||||
_validate_connected_peer(
|
||||
response,
|
||||
before=validated,
|
||||
after=validated,
|
||||
require_peer=True,
|
||||
)
|
||||
|
||||
|
||||
class _PeerStream:
|
||||
def __init__(self, address: str) -> None:
|
||||
self.address = address
|
||||
|
||||
@@ -89,6 +89,19 @@ def test_rotation_reencrypts_existing_credentials_with_primary_key(user):
|
||||
assert decrypt_mailbox_password(connection) == "rotate-me"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_rotation_skips_outlook_oauth_connections(user):
|
||||
MailboxConnection.objects.create(
|
||||
user=user,
|
||||
platform=MailboxConnection.Platform.INDEED,
|
||||
provider=MailboxConnection.Provider.OUTLOOK,
|
||||
username="vacatureradar@example.invalid",
|
||||
encrypted_password="",
|
||||
)
|
||||
|
||||
assert rotate_mailbox_credentials() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_private_imap_host_is_blocked_before_connection(user):
|
||||
key = Fernet.generate_key().decode("ascii")
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
from django.test import override_settings
|
||||
|
||||
from apps.core.services.microsoft365 import (
|
||||
Microsoft365OAuthError,
|
||||
acquire_exchange_access_token,
|
||||
xoauth2_b64,
|
||||
)
|
||||
from apps.notifications.backends import Microsoft365OAuthEmailBackend
|
||||
|
||||
|
||||
@override_settings(
|
||||
M365_TENANT_ID="tenant-id",
|
||||
M365_CLIENT_ID="client-id",
|
||||
M365_CLIENT_SECRET="client-secret",
|
||||
M365_EXCHANGE_SCOPE="https://outlook.office365.com/.default",
|
||||
)
|
||||
def test_app_only_token_uses_tenant_authority_and_exchange_scope():
|
||||
calls = {}
|
||||
|
||||
class Client:
|
||||
def acquire_token_for_client(self, *, scopes):
|
||||
calls["scopes"] = scopes
|
||||
return {"access_token": "access-token"}
|
||||
|
||||
def factory(client_id, *, authority, client_credential):
|
||||
calls.update(
|
||||
client_id=client_id,
|
||||
authority=authority,
|
||||
client_credential=client_credential,
|
||||
)
|
||||
return Client()
|
||||
|
||||
assert acquire_exchange_access_token(client_factory=factory) == "access-token"
|
||||
assert calls == {
|
||||
"client_id": "client-id",
|
||||
"authority": "https://login.microsoftonline.com/tenant-id",
|
||||
"client_credential": "client-secret",
|
||||
"scopes": ["https://outlook.office365.com/.default"],
|
||||
}
|
||||
|
||||
|
||||
@override_settings(M365_TENANT_ID="", M365_CLIENT_ID="", M365_CLIENT_SECRET="")
|
||||
def test_missing_oauth_configuration_fails_without_secret_material():
|
||||
with pytest.raises(Microsoft365OAuthError, match="niet volledig") as error:
|
||||
acquire_exchange_access_token()
|
||||
assert "secret" not in str(error.value).lower()
|
||||
|
||||
|
||||
def test_xoauth2_payload_is_encoded_exactly():
|
||||
encoded = xoauth2_b64("mailbox@example.invalid", "token-value")
|
||||
assert base64.b64decode(encoded) == (
|
||||
b"user=mailbox@example.invalid\x01auth=Bearer token-value\x01\x01"
|
||||
)
|
||||
|
||||
|
||||
@override_settings(
|
||||
M365_MAILBOX_USER="vacatureradar@example.invalid",
|
||||
EMAIL_HOST="smtp.office365.com",
|
||||
EMAIL_PORT=587,
|
||||
DEFAULT_FROM_EMAIL="VacatureRadar <vacatureradar@example.invalid>",
|
||||
M365_SMTP_TIMEOUT_SECONDS=10,
|
||||
)
|
||||
def test_oauth_smtp_backend_authenticates_and_sends(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class SMTP:
|
||||
def __init__(self, host, port, *, timeout):
|
||||
calls.append(("connect", host, port, timeout))
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return None
|
||||
|
||||
def ehlo(self):
|
||||
calls.append(("ehlo",))
|
||||
|
||||
def starttls(self, *, context):
|
||||
calls.append(("starttls", bool(context)))
|
||||
|
||||
def docmd(self, command, argument):
|
||||
calls.append((command, argument))
|
||||
return 235, b"ok"
|
||||
|
||||
def sendmail(self, sender, recipients, message):
|
||||
calls.append(("sendmail", sender, recipients, message))
|
||||
|
||||
monkeypatch.setattr("apps.notifications.backends.smtplib.SMTP", SMTP)
|
||||
monkeypatch.setattr(
|
||||
"apps.notifications.backends.acquire_exchange_access_token",
|
||||
lambda: "token-value",
|
||||
)
|
||||
from django.core.mail import EmailMessage as DjangoEmailMessage
|
||||
|
||||
django_message = DjangoEmailMessage(
|
||||
subject="Test",
|
||||
body="test",
|
||||
from_email="vacatureradar@example.invalid",
|
||||
to=["recipient@example.invalid"],
|
||||
)
|
||||
assert Microsoft365OAuthEmailBackend().send_messages([django_message]) == 1
|
||||
assert calls[0] == ("connect", "smtp.office365.com", 587, 10)
|
||||
assert any(call[0] == "AUTH" and call[1].startswith("XOAUTH2 ") for call in calls)
|
||||
assert any(call[0] == "sendmail" for call in calls)
|
||||
Reference in New Issue
Block a user