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_USER=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_USE_TLS=1
|
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.
|
# Roteerbare Fernet-sleutels voor app-wachtwoorden; actieve sleutel eerst.
|
||||||
# Genereer met: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
# 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.
|
# Maakt bij een lege database eenmalig het beheerdersaccount aan.
|
||||||
# Inloggen kan met de gebruikersnaam of het e-mailadres.
|
# Inloggen kan met de gebruikersnaam of het e-mailadres.
|
||||||
VACATURERADAR_ADMIN_USERNAME=jens@itworx.tech
|
VACATURERADAR_ADMIN_USERNAME=admin@example.invalid
|
||||||
VACATURERADAR_ADMIN_EMAIL=jens@itworx.tech
|
VACATURERADAR_ADMIN_EMAIL=admin@example.invalid
|
||||||
VACATURERADAR_ADMIN_PASSWORD=CHANGE_ME_unique_admin_password
|
VACATURERADAR_ADMIN_PASSWORD=CHANGE_ME_unique_admin_password
|
||||||
VACATURERADAR_AUTO_BOOTSTRAP=1
|
VACATURERADAR_AUTO_BOOTSTRAP=1
|
||||||
|
|
||||||
@@ -74,6 +74,10 @@ SMTP_PORT=587
|
|||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_USE_TLS=1
|
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())"
|
# Genereer met: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
MAILBOX_CREDENTIAL_KEYS=
|
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.
|
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
|
## 0.3.14 - 2026-07-29
|
||||||
|
|
||||||
### Gewijzigd
|
### 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).
|
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.
|
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
|
## Productiebewijs 0.3.13
|
||||||
|
|
||||||
- Doel: `/mnt/user/appdata/VacatureRadar`, composeproject `vacatureradar`, hostpoort 1226 naar containerpoort 8080.
|
- Doel: `/mnt/user/appdata/VacatureRadar`, composeproject `vacatureradar`, hostpoort 1226 naar containerpoort 8080.
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
FROM python:3.13-slim-trixie
|
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 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
UV_COMPILE_BYTECODE=1 \
|
UV_COMPILE_BYTECODE=1 \
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ Dagelijks beheer omvat readiness, worker/beat-heartbeat, bronruns, notificatie-o
|
|||||||
|
|
||||||
## Live observability 2026-07-29
|
## 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.
|
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.
|
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?
|
## 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 |
|
| Vacatureverkenner | Zoeken en filteren met een uitlegbare detailanalyse naast de resultaten |
|
||||||
| Skillsradar | Actuele marktvraag naast de skills uit je eigen profiel |
|
| Skillsradar | Actuele marktvraag naast de skills uit je eigen profiel |
|
||||||
| Sollicitatiepipeline | Dossiers, status, notities, deadlines en een auditbare tijdlijn |
|
| 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 |
|
| Automatisering | Eerlijke 24-uursmetingen; niet-gemeten informatie blijft expliciet onbekend |
|
||||||
|
|
||||||
## Bekijk de volledige productflow
|
## 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
|
### 1. Vind de relevante kansen
|
||||||
|
|
||||||
@@ -78,7 +88,7 @@ Een analyse maakt score, datakwaliteit, beslissignalen, aandachtspunten en broni
|
|||||||
<table>
|
<table>
|
||||||
<tr>
|
<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>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>
|
||||||
<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>
|
<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.
|
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.
|
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.
|
4. Controleer **Automatisering** voordat je op onbemande verwerking vertrouwt.
|
||||||
5. Verifieer in productie altijd HTTPS, back-ups, herstel en healthchecks.
|
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).
|
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
|
## Eerlijke grenzen
|
||||||
|
|
||||||
VacatureRadar:
|
VacatureRadar:
|
||||||
@@ -193,6 +209,11 @@ Die controleert formatting, lint, Django-configuratie, migraties, de volledige t
|
|||||||
|
|
||||||
## Beheer en deployment
|
## 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)
|
- [Lokaal ontwikkelen](docs/operations/LOCAL_DEVELOPMENT.md)
|
||||||
- [Docker en Unraid uitrollen](docs/operations/UNRAID_DEPLOYMENT.md)
|
- [Docker en Unraid uitrollen](docs/operations/UNRAID_DEPLOYMENT.md)
|
||||||
- [Nginx Proxy Manager configureren](docs/operations/NGINX_PROXY_MANAGER.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.notifications.models import DigestOutbox, ReminderOutbox
|
||||||
from apps.profiles.models import ProfileRevision
|
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
|
from ..models import ApplicationTimelineEvent, Feedback, JobVersion
|
||||||
|
|
||||||
@@ -37,7 +37,8 @@ ACTIVITY_FILTERS = (
|
|||||||
|
|
||||||
def _source_events() -> list[ActivityEvent]:
|
def _source_events() -> list[ActivityEvent]:
|
||||||
events = []
|
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 = (
|
detail = (
|
||||||
f"{run.extracted_count} geëxtraheerd · {run.created_count} nieuw · "
|
f"{run.extracted_count} geëxtraheerd · {run.created_count} nieuw · "
|
||||||
f"{run.duplicate_count} duplicaat"
|
f"{run.duplicate_count} duplicaat"
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ def annotate_latest_profile_score(
|
|||||||
|
|
||||||
def _recent_dashboard_activity(user: User) -> list[dict[str, Any]]:
|
def _recent_dashboard_activity(user: User) -> list[dict[str, Any]]:
|
||||||
activity: 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(
|
activity.append(
|
||||||
{
|
{
|
||||||
"occurred_at": run.started_at,
|
"occurred_at": run.started_at,
|
||||||
@@ -154,7 +155,9 @@ def build_dashboard_cockpit(user: User, profile: SearchProfile | None) -> dict[s
|
|||||||
"source_counts": source_counts,
|
"source_counts": source_counts,
|
||||||
"recent_activity": _recent_dashboard_activity(user),
|
"recent_activity": _recent_dashboard_activity(user),
|
||||||
"day_conclusion": day_conclusion,
|
"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:
|
def seed_demo_environment(user) -> None:
|
||||||
"""Maak (idempotent) een fictieve demo-omgeving voor ``user``."""
|
"""Maak of herstel een fictieve, tijdsbestendige demo-omgeving voor ``user``."""
|
||||||
if demo_environment_seeded(user):
|
|
||||||
return
|
|
||||||
|
|
||||||
from apps.jobs.models import Application, Employer, JobPosting, ScoreRun
|
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(
|
profile, _ = SearchProfile.objects.get_or_create(
|
||||||
user=user,
|
user=user,
|
||||||
@@ -185,7 +184,7 @@ def seed_demo_environment(user) -> None:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
slug = _digest("demo-job", str(index), title, employer_name)
|
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,
|
canonical_key=slug,
|
||||||
defaults={
|
defaults={
|
||||||
"employer": employer,
|
"employer": employer,
|
||||||
@@ -221,6 +220,11 @@ def seed_demo_environment(user) -> None:
|
|||||||
)
|
)
|
||||||
created_jobs.append(job)
|
created_jobs.append(job)
|
||||||
|
|
||||||
|
ScoreRun.objects.filter(
|
||||||
|
job=job,
|
||||||
|
profile=profile,
|
||||||
|
model_version="demo-seed",
|
||||||
|
).delete()
|
||||||
ScoreRun.objects.create(
|
ScoreRun.objects.create(
|
||||||
job=job,
|
job=job,
|
||||||
profile=profile,
|
profile=profile,
|
||||||
@@ -244,7 +248,7 @@ def seed_demo_environment(user) -> None:
|
|||||||
|
|
||||||
# Een kleine sollicitatiepijplijn voor de demo.
|
# Een kleine sollicitatiepijplijn voor de demo.
|
||||||
if created_jobs:
|
if created_jobs:
|
||||||
Application.objects.get_or_create(
|
Application.objects.update_or_create(
|
||||||
user=user,
|
user=user,
|
||||||
job=created_jobs[0],
|
job=created_jobs[0],
|
||||||
defaults={
|
defaults={
|
||||||
@@ -257,7 +261,7 @@ def seed_demo_environment(user) -> None:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
if len(created_jobs) > 3:
|
if len(created_jobs) > 3:
|
||||||
Application.objects.get_or_create(
|
Application.objects.update_or_create(
|
||||||
user=user,
|
user=user,
|
||||||
job=created_jobs[3],
|
job=created_jobs[3],
|
||||||
defaults={
|
defaults={
|
||||||
@@ -267,3 +271,42 @@ def seed_demo_environment(user) -> None:
|
|||||||
"notes": "Sollicitatie verstuurd. Demo-data.",
|
"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(
|
password = forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
label="App-wachtwoord",
|
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(
|
widget=forms.PasswordInput(
|
||||||
attrs={"autocomplete": "new-password", "placeholder": "App-wachtwoord"},
|
attrs={"autocomplete": "new-password", "placeholder": "App-wachtwoord"},
|
||||||
render_value=False,
|
render_value=False,
|
||||||
@@ -50,7 +53,8 @@ class MailboxConnectionForm(forms.ModelForm):
|
|||||||
data = super().clean()
|
data = super().clean()
|
||||||
if data.get("provider") != MailboxConnection.Provider.CUSTOM:
|
if data.get("provider") != MailboxConnection.Provider.CUSTOM:
|
||||||
data["custom_host"] = ""
|
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.")
|
self.add_error("password", "Een app-wachtwoord is verplicht voor een nieuwe koppeling.")
|
||||||
return data
|
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)
|
custom_host = models.CharField(max_length=255, blank=True)
|
||||||
port = models.PositiveIntegerField(default=993)
|
port = models.PositiveIntegerField(default=993)
|
||||||
username = models.CharField(max_length=320)
|
username = models.CharField(max_length=320)
|
||||||
encrypted_password = models.TextField()
|
encrypted_password = models.TextField(blank=True)
|
||||||
mailbox = models.CharField(max_length=255, default="INBOX")
|
mailbox = models.CharField(max_length=255, default="INBOX")
|
||||||
enabled = models.BooleanField(default=True)
|
enabled = models.BooleanField(default=True)
|
||||||
poll_interval_minutes = models.PositiveIntegerField(
|
poll_interval_minutes = models.PositiveIntegerField(
|
||||||
|
|||||||
@@ -74,14 +74,19 @@ def _validate_connected_peer(
|
|||||||
*,
|
*,
|
||||||
before: ValidatedUrl,
|
before: ValidatedUrl,
|
||||||
after: ValidatedUrl,
|
after: ValidatedUrl,
|
||||||
|
require_peer: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
peer_ip = _connected_peer_ip(response)
|
peer_ip = _connected_peer_ip(response)
|
||||||
if peer_ip is not None:
|
if peer_ip is not None:
|
||||||
if not peer_ip.is_global:
|
if not peer_ip.is_global:
|
||||||
raise FetchError(f"Niet-publiek verbonden IP-adres geblokkeerd: {peer_ip}")
|
raise FetchError(f"Niet-publiek verbonden IP-adres geblokkeerd: {peer_ip}")
|
||||||
return
|
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
|
# 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):
|
if not set(before.addresses).intersection(after.addresses):
|
||||||
raise FetchError("DNS-rebindcontrole faalde bij het benaderen van bron.")
|
raise FetchError("DNS-rebindcontrole faalde bij het benaderen van bron.")
|
||||||
|
|
||||||
@@ -175,6 +180,7 @@ def fetch_url(
|
|||||||
response,
|
response,
|
||||||
before=validation,
|
before=validation,
|
||||||
after=post_validation,
|
after=post_validation,
|
||||||
|
require_peer=own_client,
|
||||||
)
|
)
|
||||||
if response.status_code in {301, 302, 303, 307, 308}:
|
if response.status_code in {301, 302, 303, 307, 308}:
|
||||||
location = response.headers.get("location")
|
location = response.headers.get("location")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import Any
|
|||||||
|
|
||||||
from django.conf import settings
|
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.models import EmailMessageRecord, MailboxConnection
|
||||||
from apps.sources.services.email_import import ingest_email, message_identity
|
from apps.sources.services.email_import import ingest_email, message_identity
|
||||||
from apps.sources.services.mailbox_connections import decrypt_mailbox_password
|
from apps.sources.services.mailbox_connections import decrypt_mailbox_password
|
||||||
@@ -30,7 +31,11 @@ def poll_imap_mailbox(
|
|||||||
if client_factory is None:
|
if client_factory is None:
|
||||||
client_factory = imaplib.IMAP4_SSL
|
client_factory = imaplib.IMAP4_SSL
|
||||||
host_validator(f"https://{connection.imap_host}")
|
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}
|
client_options = {"timeout": settings.IMAP_CONNECT_TIMEOUT_SECONDS}
|
||||||
if client_factory is imaplib.IMAP4_SSL:
|
if client_factory is imaplib.IMAP4_SSL:
|
||||||
client_options["ssl_context"] = trusted_tls_context()
|
client_options["ssl_context"] = trusted_tls_context()
|
||||||
@@ -42,6 +47,12 @@ def poll_imap_mailbox(
|
|||||||
"failed": 0,
|
"failed": 0,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
|
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)
|
client.login(connection.username, password)
|
||||||
status, _ = client.select(connection.mailbox, readonly=True)
|
status, _ = client.select(connection.mailbox, readonly=True)
|
||||||
if status != "OK":
|
if status != "OK":
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ def decrypt_mailbox_password(connection: MailboxConnection) -> str:
|
|||||||
def rotate_mailbox_credentials() -> int:
|
def rotate_mailbox_credentials() -> int:
|
||||||
"""Re-encrypt every mailbox password with the first configured key."""
|
"""Re-encrypt every mailbox password with the first configured key."""
|
||||||
rotated = 0
|
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)
|
password = decrypt_mailbox_password(connection)
|
||||||
connection.encrypted_password = encrypt_mailbox_password(password)
|
connection.encrypted_password = encrypt_mailbox_password(password)
|
||||||
connection.save(update_fields=["encrypted_password", "updated_at"])
|
connection.save(update_fields=["encrypted_password", "updated_at"])
|
||||||
@@ -79,7 +81,10 @@ def save_mailbox_connection(
|
|||||||
"poll_interval_minutes",
|
"poll_interval_minutes",
|
||||||
):
|
):
|
||||||
setattr(connection, field, cleaned_data[field])
|
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)
|
connection.encrypted_password = encrypt_mailbox_password(password)
|
||||||
elif not connection.encrypted_password:
|
elif not connection.encrypted_password:
|
||||||
raise MailboxCredentialError("Een app-wachtwoord is verplicht.")
|
raise MailboxCredentialError("Een app-wachtwoord is verplicht.")
|
||||||
|
|||||||
@@ -188,7 +188,8 @@ def schedule_due_sources() -> dict[str, int]:
|
|||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
due = Source.objects.filter(
|
due = Source.objects.filter(
|
||||||
status__in=[Source.Status.ACTIVE, Source.Status.TRIAL],
|
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])
|
ids = list(due.values_list("id", flat=True).distinct()[:200])
|
||||||
for source_id in ids:
|
for source_id in ids:
|
||||||
fetch_source.delay(source_id)
|
fetch_source.delay(source_id)
|
||||||
@@ -200,6 +201,7 @@ def schedule_mailbox_polls() -> dict[str, int]:
|
|||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
due_ids = list(
|
due_ids = list(
|
||||||
MailboxConnection.objects.filter(enabled=True, next_poll_at__lte=now)
|
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))
|
.filter(Q(lease_expires_at__isnull=True) | Q(lease_expires_at__lte=now))
|
||||||
.values_list("id", flat=True)[:100]
|
.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")
|
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.14").strip() or "0.3.14"
|
VACATURERADAR_VERSION = os.getenv("VACATURERADAR_VERSION", "0.3.17").strip() or "0.3.17"
|
||||||
LANGUAGE_CODE = "nl-be"
|
LANGUAGE_CODE = "nl-be"
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
USE_TZ = 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_USER = os.getenv("SMTP_USER", "")
|
||||||
EMAIL_HOST_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
EMAIL_HOST_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
||||||
EMAIL_USE_TLS = env_bool("SMTP_USE_TLS", True)
|
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_HTTPONLY = True
|
||||||
SESSION_COOKIE_SAMESITE = "Lax"
|
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_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.14
|
VACATURERADAR_VERSION=0.3.17
|
||||||
|
|
||||||
POSTGRES_DB=vacatureradar
|
POSTGRES_DB=vacatureradar
|
||||||
POSTGRES_USER=vacatureradar
|
POSTGRES_USER=vacatureradar
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.unraid
|
dockerfile: Dockerfile.unraid
|
||||||
image: vacatureradar:unraid
|
image: ${VACATURERADAR_IMAGE:-vacatureradar:unraid}
|
||||||
container_name: VacatureRadar
|
container_name: VacatureRadar
|
||||||
env_file:
|
env_file:
|
||||||
- ${VACATURERADAR_ENV_FILE:-.env}
|
- ${VACATURERADAR_ENV_FILE:-.env}
|
||||||
|
|||||||
+96
-15
@@ -1,6 +1,6 @@
|
|||||||
schema_version: 1
|
schema_version: 1
|
||||||
project: VacatureRadar
|
project: VacatureRadar
|
||||||
updated_at: '2026-07-29'
|
updated_at: '2026-08-12'
|
||||||
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
|
||||||
@@ -1341,7 +1341,7 @@ tasks:
|
|||||||
gebruikt strikt de gepinde certifi-bundel zonder bypass.
|
gebruikt strikt de gepinde certifi-bundel zonder bypass.
|
||||||
- id: VR-202
|
- id: VR-202
|
||||||
title: Live IMAP- en SMTP-credentials configureren
|
title: Live IMAP- en SMTP-credentials configureren
|
||||||
status: blocked-external
|
status: done
|
||||||
priority: P0
|
priority: P0
|
||||||
requirement_ids:
|
requirement_ids:
|
||||||
- PR-016
|
- PR-016
|
||||||
@@ -1356,22 +1356,27 @@ tasks:
|
|||||||
summary: Configureer de gewenste afzonderlijke platformmailboxen via de beveiligde UI, de Fernet-secretkey en
|
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.
|
digestrecipient; test read-only import en mailverzending zonder credentials in Git/logs.
|
||||||
acceptance_criteria:
|
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
|
- Dezelfde live testmail importeert per mailbox idempotent en alle geconfigureerde intervallen worden door de
|
||||||
scheduler gerespecteerd.
|
scheduler gerespecteerd.
|
||||||
- SMTP verstuurt één testdigest en outboxstatus klopt.
|
- SMTP verstuurt één testdigest en outboxstatus klopt.
|
||||||
- De encryptiesleutel staat alleen in de productie-secretstore/.env; app-wachtwoorden zijn als ciphertext in de
|
- OAuth-clientcredentials en de encryptiesleutel staan alleen in de productie-secretstore/.env; eventuele Gmail/custom-app-wachtwoorden
|
||||||
database aanwezig en sleutelrotatie is live getest.
|
zijn als ciphertext opgeslagen en sleutelrotatie is live getest.
|
||||||
verification:
|
verification:
|
||||||
- Volg Unraid mailboxchecklist
|
- Volg Unraid mailboxchecklist
|
||||||
- Inspecteer logs op afwezigheid van secret/payload
|
- Inspecteer logs op afwezigheid van secret/payload
|
||||||
primary_paths:
|
primary_paths:
|
||||||
- /mnt/user/appdata/vacatureradar/config/.env
|
- /mnt/user/appdata/VacatureRadar/.env
|
||||||
- Bronnen -> Platformmailboxen
|
- 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
|
- id: VR-203
|
||||||
title: Productiedomein, TLS en reverse proxy activeren
|
title: Productiedomein, TLS en reverse proxy activeren
|
||||||
status: blocked-external
|
status: done
|
||||||
priority: P0
|
priority: P0
|
||||||
requirement_ids:
|
requirement_ids:
|
||||||
- NFR-001
|
- NFR-001
|
||||||
@@ -1393,10 +1398,14 @@ tasks:
|
|||||||
primary_paths:
|
primary_paths:
|
||||||
- Unraid reverse proxy
|
- Unraid reverse proxy
|
||||||
- /mnt/user/appdata/vacatureradar/config/.env
|
- /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
|
- id: VR-204
|
||||||
title: Immutable containerimage publiceren
|
title: Immutable containerimage publiceren
|
||||||
status: blocked-external
|
status: done
|
||||||
priority: P1
|
priority: P1
|
||||||
requirement_ids:
|
requirement_ids:
|
||||||
- NFR-009
|
- NFR-009
|
||||||
@@ -1414,9 +1423,12 @@ tasks:
|
|||||||
- Pull image op schone host
|
- Pull image op schone host
|
||||||
- Start smoke stack en rollback naar vorige digest
|
- Start smoke stack en rollback naar vorige digest
|
||||||
primary_paths:
|
primary_paths:
|
||||||
- .github/workflows/release.yml
|
- .gitea/workflows/release.yml
|
||||||
- docker-compose.unraid.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
|
- id: VR-205
|
||||||
title: Gelicenseerde Belgische geodatabron laden
|
title: Gelicenseerde Belgische geodatabron laden
|
||||||
status: done
|
status: done
|
||||||
@@ -2331,7 +2343,7 @@ tasks:
|
|||||||
documentatie; volledige gate 312 tests groen, 84,16% dekking.
|
documentatie; volledige gate 312 tests groen, 84,16% dekking.
|
||||||
- id: VR-228
|
- id: VR-228
|
||||||
title: Optimaliseer de importpipeline voor release 0.3.14
|
title: Optimaliseer de importpipeline voor release 0.3.14
|
||||||
status: ready
|
status: done
|
||||||
priority: P0
|
priority: P0
|
||||||
requirement_ids:
|
requirement_ids:
|
||||||
- NFR-007
|
- NFR-007
|
||||||
@@ -2345,8 +2357,8 @@ tasks:
|
|||||||
- Een fail-closed runner meet een disposable PostgreSQL-database met vaste fixture, warm-up en drie herhalingen.
|
- 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
|
- De geoptimaliseerde import reduceert queryvolume en doorlooptijd aantoonbaar tegenover 0.3.13 en rapporteert
|
||||||
p50, p95, throughput, geheugen en queryfamilies.
|
p50, p95, throughput, geheugen en queryfamilies.
|
||||||
- Parserdekking blijft 12/12, dedupeprecision en -recall blijven 1,0, rankingchurn blijft nul en historische
|
- Parserdekking blijft 12/12, dedupeprecision en -recall blijven 1,0, rankingchurn blijft nul en historische ScoreRuns
|
||||||
ScoreRuns plus veldprovenance blijven intact.
|
plus veldprovenance blijven intact.
|
||||||
- Regressietests begrenzen het queryvolume; de volledige kwaliteitsgate slaagt tweemaal en productieacceptatie
|
- Regressietests begrenzen het queryvolume; de volledige kwaliteitsgate slaagt tweemaal en productieacceptatie
|
||||||
bewijst health, beperkte importsmoke, publieke kernflows en rollbackgereedheid.
|
bewijst health, beperkte importsmoke, publieke kernflows en rollbackgereedheid.
|
||||||
verification:
|
verification:
|
||||||
@@ -2359,3 +2371,72 @@ tasks:
|
|||||||
- scripts/benchmark_import.py
|
- scripts/benchmark_import.py
|
||||||
- tests/integration/test_pipeline.py
|
- tests/integration/test_pipeline.py
|
||||||
- docs/audit/IMPORT_PERFORMANCE_FINAL.md
|
- 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
|
# Projectstatus
|
||||||
|
|
||||||
- Laatst bijgewerkt: 2026-07-29
|
- Laatst bijgewerkt: 2026-08-12
|
||||||
- Repositoryversie: 0.3.13 integrale professionalisering
|
- Repositoryversie: 0.3.16 publieke demo-hardening
|
||||||
- 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`
|
||||||
|
|
||||||
@@ -19,8 +19,110 @@ De repository bevat een uitvoerbare Django-MVP met:
|
|||||||
- Celeryworker/scheduler, healthchecks, Docker/Unraidconfiguratie en back-up-/herstelscripts;
|
- Celeryworker/scheduler, healthchecks, Docker/Unraidconfiguratie en back-up-/herstelscripts;
|
||||||
- offline unit-, integratie- en securitytests.
|
- 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
|
## 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:
|
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`;
|
- 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
|
## 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
|
## Externe blokkades
|
||||||
|
|
||||||
Deze blokkeren onafhankelijke code niet:
|
Deze blokkeren onafhankelijke code niet:
|
||||||
|
|
||||||
- verdere persoonlijke werkgevers-/bronnenkeuze en actuele menselijke review buiten de actieve VITO-/Deliverect-startercatalogus;
|
- 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
|
## 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
|
## 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
|
## 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
## 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
|
## Digest ontbreekt of dubbel
|
||||||
|
|
||||||
- Controleer actief profiel, digesttijd/zone en `DIGEST_RECIPIENT`.
|
- 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.
|
- Controleer `DigestOutbox` op datum en status.
|
||||||
- Bij SMTP-fout: corrigeer configuratie en retry dezelfde outbox gecontroleerd.
|
- Bij SMTP-fout: corrigeer configuratie en retry dezelfde outbox gecontroleerd.
|
||||||
- Maak geen tweede outboxrecord handmatig; uniciteit per profiel/datum is de duplicaatbarrière.
|
- 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
|
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:
|
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-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-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-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
|
## Misbruikscenario's
|
||||||
|
|
||||||
@@ -115,7 +115,10 @@ Een wijziging aan netwerk, mail, AI, rendering, auth, exports of uploads vereist
|
|||||||
|
|
||||||
## Resterende risico's
|
## 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.
|
- 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.
|
- 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.
|
- 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
|
- Impact: herhaalde herstarts van dezelfde bron veroorzaken extra belasting
|
||||||
- Mitigatie: cooldown plus metadataflag voor eenmalige canary, en trialstatus met beperkte runtrigger
|
- Mitigatie: cooldown plus metadataflag voor eenmalige canary, en trialstatus met beperkte runtrigger
|
||||||
- Verificatie: `tests/integration/test_source_health.py`
|
- 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
|
## 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-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-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.
|
||||||
@@ -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-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-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-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-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-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.
|
- 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-footer">
|
||||||
<div class="sidebar-radar-card">
|
<div class="sidebar-radar-card">
|
||||||
<span class="status-beacon {% if navigation_profile %}is-live{% endif %}" aria-hidden="true"></span>
|
<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>
|
</div>
|
||||||
{% include "components/developer_credit.html" %}
|
{% include "components/developer_credit.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="cockpit-heading dashboard-heading">
|
<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><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>
|
</header>
|
||||||
|
|
||||||
<section class="stats-grid" aria-label="Radar kerncijfers">
|
<section class="stats-grid" aria-label="Radar kerncijfers">
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="input-wrap">
|
<div class="input-wrap">
|
||||||
<input class="auth-input" id="id_password" name="password" type="password"
|
<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 %}>
|
{% if form.errors %}aria-invalid="true"{% endif %}>
|
||||||
<button class="pw-toggle" type="button" data-pw-toggle aria-label="Wachtwoord tonen">
|
<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>
|
<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.
|
# Het gevulde dashboard rendert zonder fouten.
|
||||||
dashboard = client.get(reverse("dashboard:today"))
|
dashboard = client.get(reverse("dashboard:today"))
|
||||||
assert dashboard.status_code == 200
|
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):
|
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"))
|
client.post(reverse("demo-login"))
|
||||||
first = JobPosting.objects.count()
|
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"))
|
client.post(reverse("demo-login"))
|
||||||
assert JobPosting.objects.count() == first
|
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):
|
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
|
assert response.status_code == 302
|
||||||
indeed = MailboxConnection.objects.get(user=user, platform="indeed")
|
indeed = MailboxConnection.objects.get(user=user, platform="indeed")
|
||||||
|
assert indeed.encrypted_password == ""
|
||||||
|
|
||||||
edit_response = client.get(reverse("sources:mailbox_edit", args=[vdab.pk]))
|
edit_response = client.get(reverse("sources:mailbox_edit", args=[vdab.pk]))
|
||||||
assert edit_response.status_code == 200
|
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
|
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):
|
def test_fetch_source_is_blocked_by_conflicting_review(db):
|
||||||
source = Source.objects.create(
|
source = Source.objects.create(
|
||||||
name="Blocked source",
|
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 '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
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
FetchTimeoutError,
|
||||||
PolicyBlockedError,
|
PolicyBlockedError,
|
||||||
RateLimitedError,
|
RateLimitedError,
|
||||||
|
_validate_connected_peer,
|
||||||
fetch_url,
|
fetch_url,
|
||||||
)
|
)
|
||||||
from apps.sources.services.url_security import ValidatedUrl
|
from apps.sources.services.url_security import ValidatedUrl
|
||||||
@@ -100,6 +101,24 @@ def test_fetcher_rejects_dns_rebinding(monkeypatch):
|
|||||||
client.close()
|
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:
|
class _PeerStream:
|
||||||
def __init__(self, address: str) -> None:
|
def __init__(self, address: str) -> None:
|
||||||
self.address = address
|
self.address = address
|
||||||
|
|||||||
@@ -89,6 +89,19 @@ def test_rotation_reencrypts_existing_credentials_with_primary_key(user):
|
|||||||
assert decrypt_mailbox_password(connection) == "rotate-me"
|
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
|
@pytest.mark.django_db
|
||||||
def test_private_imap_host_is_blocked_before_connection(user):
|
def test_private_imap_host_is_blocked_before_connection(user):
|
||||||
key = Fernet.generate_key().decode("ascii")
|
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