This commit is contained in:
@@ -85,10 +85,14 @@ def _run_html_accessibility_probe(client, user, profile, job) -> None:
|
||||
response = client.get(reverse("dashboard:today"))
|
||||
assert response.status_code == 200
|
||||
_assert_basic_html_accessibility(response.content, page_name="dashboard")
|
||||
assert "Zoek door vacatures" in response.content.decode("utf-8")
|
||||
|
||||
response = client.get(reverse("jobs:list"), {"q": job.original_title})
|
||||
assert response.status_code == 200
|
||||
_assert_basic_html_accessibility(response.content, page_name="jobs-list")
|
||||
jobs_body = response.content.decode("utf-8")
|
||||
assert "1 vacatures gevonden" in jobs_body
|
||||
assert "88%" in jobs_body
|
||||
|
||||
response = client.get(reverse("jobs:detail", kwargs={"pk": job.pk}))
|
||||
assert response.status_code == 200
|
||||
@@ -186,12 +190,12 @@ def test_vr114_browser_flow(viewport, live_server, client, tmp_path, user, profi
|
||||
page.get_by_label("Wachtwoord").fill("correct-horse-battery-staple")
|
||||
page.get_by_role("button", name="Aanmelden").click()
|
||||
page.wait_for_url(dashboard_url)
|
||||
_assert_playwright_accessibility(page, expected_h1="Vandaag")
|
||||
_assert_playwright_accessibility(page, expected_h1="Goedemorgen")
|
||||
|
||||
# Dashboard -> filter -> detail.
|
||||
page.get_by_role("link", name="Vacatures").click()
|
||||
page.get_by_role("textbox", name="Zoeken").fill(job.original_title)
|
||||
page.get_by_role("button", name="Filter").click()
|
||||
page.get_by_role("button", name="Filters toepassen").click()
|
||||
assert page.url.startswith(jobs_url)
|
||||
page.get_by_role("link", name=job.original_title).first.click()
|
||||
page.wait_for_url(detail_url)
|
||||
@@ -209,13 +213,12 @@ def test_vr114_browser_flow(viewport, live_server, client, tmp_path, user, profi
|
||||
page.wait_for_selector(".message.success")
|
||||
|
||||
# Profiel aanpassen.
|
||||
page.get_by_role("link", name="Profiel").click()
|
||||
page.get_by_role("link", name="Zoekprofiel").click()
|
||||
page.wait_for_url(profile_list_url)
|
||||
page.get_by_role("link", name="Bewerken").click()
|
||||
page.get_by_label("Gewenste functietitels").fill(
|
||||
"Infrastructure Engineer\nSysteembeheerder"
|
||||
)
|
||||
page.get_by_role("button", name="Profiel opslaan").click()
|
||||
page.get_by_role("link", name="Profiel bewerken").click()
|
||||
page.get_by_label("Infrastructure engineer").check()
|
||||
page.get_by_label("System engineer").check()
|
||||
page.get_by_role("button", name="Instellingen opslaan").click()
|
||||
page.wait_for_url(profile_list_url)
|
||||
|
||||
# Sollicitatie dossier openen en bijwerken.
|
||||
@@ -231,12 +234,13 @@ def test_vr114_browser_flow(viewport, live_server, client, tmp_path, user, profi
|
||||
(timezone.localdate() + timedelta(days=7)).isoformat()
|
||||
)
|
||||
page.locator("textarea[name='notes']").fill("Eerste follow-up ingepland.")
|
||||
page.get_by_role("button", name="Opslaan").click()
|
||||
page.get_by_role("button", name="Dossier opslaan").click()
|
||||
page.wait_for_url(applications_url)
|
||||
|
||||
# Handmatige import via plaktekst.
|
||||
page.get_by_role("link", name="Brongezondheid").click()
|
||||
page.get_by_role("link", name="Bronnen").click()
|
||||
page.wait_for_url(sources_url)
|
||||
page.locator(".import-disclosure > summary").click()
|
||||
page.locator("textarea[name='pasted_text']").fill(
|
||||
"Vacaturetitel: Infrastructure Engineer\nWerkgever: Example IT\nLocatie: Hasselt"
|
||||
)
|
||||
@@ -249,7 +253,7 @@ def test_vr114_browser_flow(viewport, live_server, client, tmp_path, user, profi
|
||||
page.keyboard.press("Tab")
|
||||
page.keyboard.press("Tab")
|
||||
page.keyboard.press("Tab")
|
||||
_assert_playwright_accessibility(page, expected_h1="Brongezondheid")
|
||||
_assert_playwright_accessibility(page, expected_h1="Bronnen")
|
||||
|
||||
assert Feedback.objects.filter(user=user, job=job).count() >= 4
|
||||
except Exception:
|
||||
|
||||
@@ -3,6 +3,7 @@ from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from django.core import mail
|
||||
from django.test import override_settings
|
||||
|
||||
@@ -10,8 +11,10 @@ from apps.jobs.models import Feedback, ScoreRun
|
||||
from apps.jobs.services.scoring import score_and_save
|
||||
from apps.notifications.models import DigestOutbox
|
||||
from apps.notifications.services import build_digest_payload, create_daily_outbox, send_digest
|
||||
from apps.sources.models import EmailMessageRecord, RawDocument
|
||||
from apps.sources.models import EmailMessageRecord, MailboxConnection, RawDocument
|
||||
from apps.sources.services.email_import import ingest_email, message_identity
|
||||
from apps.sources.services.imap_import import poll_imap_mailbox
|
||||
from apps.sources.services.mailbox_connections import encrypt_mailbox_password
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -27,6 +30,132 @@ def test_email_import_is_idempotent(profile):
|
||||
assert len(first.links) == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_vdab_alert_has_provider_provenance_and_replays_idempotently(profile):
|
||||
raw = Path("fixtures/emails/vdab_job_alert.eml").read_bytes()
|
||||
|
||||
first = ingest_email(raw)
|
||||
second = ingest_email(raw)
|
||||
|
||||
assert second.pk == first.pk
|
||||
assert first.processed is True
|
||||
assert first.raw_document.metadata["alert_provider"] == "vdab"
|
||||
assert len(first.links) == 2
|
||||
assert all("vdab.be/vindeenjob/vacatures/" in link for link in first.links)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
("platform", "fixture", "expected_domain"),
|
||||
(
|
||||
(MailboxConnection.Platform.LINKEDIN, "linkedin_job_alert.eml", "linkedin.com"),
|
||||
(MailboxConnection.Platform.ICTJOB, "ictjob_job_alert.eml", "ictjob.be"),
|
||||
(MailboxConnection.Platform.JOBAT, "jobat_job_alert.eml", "jobat.be"),
|
||||
(MailboxConnection.Platform.STEPSTONE, "stepstone_job_alert.eml", "stepstone.be"),
|
||||
(MailboxConnection.Platform.CAREERJET, "careerjet_job_alert.eml", "careerjet.be"),
|
||||
(MailboxConnection.Platform.RANDSTAD, "randstad_job_alert.eml", "randstad.be"),
|
||||
(
|
||||
MailboxConnection.Platform.ROBERTHALF,
|
||||
"roberthalf_job_alert.eml",
|
||||
"roberthalf.com",
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_jobboard_mail_import_keeps_platform_provenance_and_rejects_external_links(
|
||||
profile, platform, fixture, expected_domain
|
||||
):
|
||||
raw = (Path("fixtures/emails") / fixture).read_bytes()
|
||||
|
||||
record = ingest_email(raw, platform=platform)
|
||||
|
||||
assert record.processed is True
|
||||
assert len(record.links) == 1
|
||||
assert expected_domain in record.links[0]
|
||||
assert "outside.example.invalid" not in record.links[0]
|
||||
assert record.raw_document.metadata["alert_provider"] == platform
|
||||
assert record.raw_document.source.name == f"{platform.label} vacaturemailbox"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_imap_poll_is_bounded_and_one_bad_message_does_not_abort(profile):
|
||||
valid = Path("fixtures/emails/vdab_job_alert.eml").read_bytes()
|
||||
oversized = b"x" * (len(valid) + 1)
|
||||
|
||||
class FakeImapClient:
|
||||
def __init__(self, host, port, timeout=None):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.timeout = timeout
|
||||
self.logged_out = False
|
||||
|
||||
def login(self, user, password):
|
||||
return "OK", []
|
||||
|
||||
def select(self, mailbox, readonly=True):
|
||||
assert readonly is True
|
||||
return "OK", []
|
||||
|
||||
def uid(self, command, *args):
|
||||
if command == "search":
|
||||
return "OK", [b"1 2 3"]
|
||||
uid = args[0]
|
||||
if uid == b"1":
|
||||
return "OK", [(b"RFC822", valid)]
|
||||
if uid == b"2":
|
||||
return "OK", [(b"RFC822", oversized)]
|
||||
return "NO", []
|
||||
|
||||
def logout(self):
|
||||
self.logged_out = True
|
||||
|
||||
clients = []
|
||||
|
||||
def factory(host, port, timeout=None):
|
||||
client = FakeImapClient(host, port, timeout=timeout)
|
||||
clients.append(client)
|
||||
return client
|
||||
|
||||
key = Fernet.generate_key().decode("ascii")
|
||||
with override_settings(
|
||||
MAILBOX_CREDENTIAL_KEYS=[key],
|
||||
IMAP_MAX_MESSAGES_PER_POLL=3,
|
||||
IMAP_MAX_MESSAGE_BYTES=len(valid),
|
||||
):
|
||||
connection = MailboxConnection.objects.create(
|
||||
user=profile.user,
|
||||
platform=MailboxConnection.Platform.VDAB,
|
||||
provider=MailboxConnection.Provider.CUSTOM,
|
||||
custom_host="imap.example.invalid",
|
||||
username="radar@example.invalid",
|
||||
encrypted_password=encrypt_mailbox_password("test-only"),
|
||||
)
|
||||
first = poll_imap_mailbox(
|
||||
connection, client_factory=factory, host_validator=lambda url: url
|
||||
)
|
||||
second = poll_imap_mailbox(
|
||||
connection, client_factory=factory, host_validator=lambda url: url
|
||||
)
|
||||
|
||||
assert first == {
|
||||
"status": "ok",
|
||||
"imported": 1,
|
||||
"duplicates": 0,
|
||||
"skipped_oversized": 1,
|
||||
"failed": 1,
|
||||
}
|
||||
assert second == {
|
||||
"status": "ok",
|
||||
"imported": 0,
|
||||
"duplicates": 1,
|
||||
"skipped_oversized": 1,
|
||||
"failed": 1,
|
||||
}
|
||||
assert all(client.logged_out for client in clients)
|
||||
record = EmailMessageRecord.objects.get(mailbox_connection=connection)
|
||||
assert record.raw_document.metadata["alert_provider"] == "vdab"
|
||||
assert record.raw_document.metadata["mailbox_connection_id"] == connection.pk
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_digest_payload_outbox_deduplication_and_send(profile, job):
|
||||
score = score_and_save(job, profile)
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client, override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.sources.models import MailboxConnection
|
||||
from apps.sources.services.mailbox_connections import (
|
||||
claim_mailbox_connection,
|
||||
decrypt_mailbox_password,
|
||||
)
|
||||
from apps.sources.tasks import poll_mailbox, schedule_mailbox_polls
|
||||
|
||||
|
||||
def _payload(**overrides):
|
||||
payload = {
|
||||
"platform": MailboxConnection.Platform.VDAB,
|
||||
"provider": MailboxConnection.Provider.GMAIL,
|
||||
"custom_host": "",
|
||||
"port": 993,
|
||||
"username": "vdab-alerts@example.invalid",
|
||||
"password": "test-app-password",
|
||||
"mailbox": "INBOX",
|
||||
"poll_interval_minutes": 15,
|
||||
"enabled": "on",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_user_can_link_edit_pause_and_sync_separate_platform_mailboxes(client, user, monkeypatch):
|
||||
key = Fernet.generate_key().decode("ascii")
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
"apps.sources.views.poll_mailbox.delay",
|
||||
lambda connection_id, force=False: calls.append((connection_id, force)),
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
with override_settings(MAILBOX_CREDENTIAL_KEYS=[key]):
|
||||
response = client.post(reverse("sources:mailbox_add"), _payload())
|
||||
assert response.status_code == 302
|
||||
vdab = MailboxConnection.objects.get(user=user, platform="vdab")
|
||||
assert decrypt_mailbox_password(vdab) == "test-app-password"
|
||||
assert "test-app-password" not in vdab.encrypted_password
|
||||
|
||||
response = client.post(
|
||||
reverse("sources:mailbox_add"),
|
||||
_payload(
|
||||
platform=MailboxConnection.Platform.INDEED,
|
||||
provider=MailboxConnection.Provider.OUTLOOK,
|
||||
username="indeed-alerts@example.invalid",
|
||||
password="second-app-password",
|
||||
poll_interval_minutes=30,
|
||||
),
|
||||
)
|
||||
assert response.status_code == 302
|
||||
indeed = MailboxConnection.objects.get(user=user, platform="indeed")
|
||||
|
||||
edit_response = client.get(reverse("sources:mailbox_edit", args=[vdab.pk]))
|
||||
assert edit_response.status_code == 200
|
||||
assert b"test-app-password" not in edit_response.content
|
||||
assert vdab.encrypted_password.encode() not in edit_response.content
|
||||
|
||||
original_ciphertext = vdab.encrypted_password
|
||||
response = client.post(
|
||||
reverse("sources:mailbox_edit", args=[vdab.pk]),
|
||||
_payload(
|
||||
username="vdab-updated@example.invalid",
|
||||
password="",
|
||||
poll_interval_minutes=5,
|
||||
),
|
||||
)
|
||||
assert response.status_code == 302
|
||||
vdab.refresh_from_db()
|
||||
assert vdab.username == "vdab-updated@example.invalid"
|
||||
assert vdab.poll_interval_minutes == 5
|
||||
assert vdab.encrypted_password == original_ciphertext
|
||||
assert decrypt_mailbox_password(vdab) == "test-app-password"
|
||||
|
||||
response = client.post(reverse("sources:mailbox_toggle", args=[indeed.pk]))
|
||||
assert response.status_code == 302
|
||||
indeed.refresh_from_db()
|
||||
assert indeed.enabled is False
|
||||
|
||||
response = client.post(reverse("sources:sync_platform_alerts", args=[vdab.pk]))
|
||||
assert response.status_code == 302
|
||||
assert calls == [
|
||||
(vdab.pk, True),
|
||||
(indeed.pk, True),
|
||||
(vdab.pk, True),
|
||||
(vdab.pk, True),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_sources_page_offers_all_reviewed_jobboard_mailboxes(client, user):
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(reverse("sources:list"))
|
||||
|
||||
assert response.status_code == 200
|
||||
for platform in MailboxConnection.Platform:
|
||||
assert f'value="{platform.value}"'.encode() in response.content
|
||||
assert f"{platform.label}-alert instellen".encode() in response.content
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_mailbox_views_are_user_scoped_and_require_encryption_key(client, user, monkeypatch):
|
||||
other = get_user_model().objects.create_user(username="other", password="test-only-password")
|
||||
other_connection = MailboxConnection.objects.create(
|
||||
user=other,
|
||||
platform=MailboxConnection.Platform.VDAB,
|
||||
provider=MailboxConnection.Provider.GMAIL,
|
||||
username="private@example.invalid",
|
||||
encrypted_password="opaque-ciphertext",
|
||||
)
|
||||
client.force_login(user)
|
||||
monkeypatch.setattr("apps.sources.views.poll_mailbox.delay", lambda *args, **kwargs: None)
|
||||
|
||||
assert (
|
||||
client.get(reverse("sources:mailbox_edit", args=[other_connection.pk])).status_code == 404
|
||||
)
|
||||
assert (
|
||||
client.post(reverse("sources:sync_platform_alerts", args=[other_connection.pk])).status_code
|
||||
== 404
|
||||
)
|
||||
assert (
|
||||
client.post(reverse("sources:mailbox_toggle", args=[other_connection.pk])).status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
with override_settings(MAILBOX_CREDENTIAL_KEYS=[]):
|
||||
response = client.post(reverse("sources:mailbox_add"), _payload(password="visible-once"))
|
||||
assert response.status_code == 200
|
||||
assert MailboxConnection.objects.filter(user=user).count() == 0
|
||||
assert b"visible-once" not in response.content
|
||||
assert b"Credentialopslag is niet geconfigureerd" in response.content
|
||||
|
||||
anonymous_response = Client().post(reverse("sources:mailbox_add"), _payload())
|
||||
assert anonymous_response.status_code == 302
|
||||
csrf_client = Client(enforce_csrf_checks=True)
|
||||
csrf_client.force_login(user)
|
||||
assert csrf_client.post(reverse("sources:mailbox_add"), _payload()).status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_scheduler_queues_only_due_active_mailboxes_and_claim_is_exclusive(user, monkeypatch):
|
||||
now = timezone.now()
|
||||
due = MailboxConnection.objects.create(
|
||||
user=user,
|
||||
platform=MailboxConnection.Platform.VDAB,
|
||||
provider=MailboxConnection.Provider.GMAIL,
|
||||
username="due@example.invalid",
|
||||
encrypted_password="opaque",
|
||||
next_poll_at=now - timedelta(minutes=1),
|
||||
)
|
||||
MailboxConnection.objects.create(
|
||||
user=user,
|
||||
platform=MailboxConnection.Platform.INDEED,
|
||||
provider=MailboxConnection.Provider.OUTLOOK,
|
||||
username="later@example.invalid",
|
||||
encrypted_password="opaque",
|
||||
next_poll_at=now + timedelta(minutes=20),
|
||||
)
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
"apps.sources.tasks.poll_mailbox.delay", lambda connection_id: calls.append(connection_id)
|
||||
)
|
||||
|
||||
assert schedule_mailbox_polls() == {"scheduled": 1}
|
||||
assert calls == [due.pk]
|
||||
assert claim_mailbox_connection(connection_id=due.pk, worker_token="worker-a") is not None
|
||||
assert claim_mailbox_connection(connection_id=due.pk, worker_token="worker-b") is None
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_mailbox_task_does_not_persist_or_return_secret_on_imap_error(user, monkeypatch):
|
||||
connection = MailboxConnection.objects.create(
|
||||
user=user,
|
||||
platform=MailboxConnection.Platform.VDAB,
|
||||
provider=MailboxConnection.Provider.GMAIL,
|
||||
username="errors@example.invalid",
|
||||
encrypted_password="opaque",
|
||||
next_poll_at=timezone.now() - timedelta(minutes=1),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"apps.sources.tasks.poll_imap_mailbox",
|
||||
lambda connection: (_ for _ in ()).throw(RuntimeError("leaked-app-password")),
|
||||
)
|
||||
|
||||
result = poll_mailbox(connection.pk)
|
||||
connection.refresh_from_db()
|
||||
assert "leaked-app-password" not in str(result)
|
||||
assert "leaked-app-password" not in connection.last_error_message
|
||||
assert result == {"status": "failed", "error_category": "imap", "imported": 0}
|
||||
@@ -34,6 +34,40 @@ def test_pipeline_is_idempotent_and_scores(source, profile):
|
||||
assert ScoreRun.objects.filter(profile=profile).count() == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_pipeline_uses_reviewed_employer_identity_for_public_ats_feed(profile):
|
||||
source = Source.objects.create(
|
||||
name="Deliverect",
|
||||
source_type=Source.Type.ATS,
|
||||
base_url="https://api.lever.co/v0/postings/deliverect?mode=json",
|
||||
domain="api.lever.co",
|
||||
status=Source.Status.ACTIVE,
|
||||
policy=Source.Policy.ALLOW,
|
||||
metadata={"employer_name": "Deliverect", "direct_employer": True},
|
||||
)
|
||||
content = Path("fixtures/ats/lever-public-postings.json").read_text(encoding="utf-8")
|
||||
document = RawDocument.objects.create(
|
||||
source=source,
|
||||
url=source.base_url,
|
||||
final_url=source.base_url,
|
||||
kind=RawDocument.Kind.JSON,
|
||||
content_type="application/json",
|
||||
content_hash=hashlib.sha256(content.encode()).hexdigest(),
|
||||
body_text=content,
|
||||
byte_length=len(content.encode()),
|
||||
retain_until=timezone.now() + timedelta(days=7),
|
||||
)
|
||||
|
||||
result = process_raw_document(document)
|
||||
|
||||
assert result["created"] == 1
|
||||
job = JobPosting.objects.get()
|
||||
assert job.employer_name == "Deliverect"
|
||||
assert job.direct_employer is True
|
||||
evidence = job.source_aliases.get().field_evidence.get(field_name="employer_name")
|
||||
assert evidence.extraction_method == "reviewed-source-config"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_pipeline_resolves_recruiter_alias_to_direct_employer():
|
||||
source = Source.objects.create(
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.jobs.models import GeocodeLocationLookup
|
||||
from apps.profiles.forms import SearchProfileForm
|
||||
|
||||
|
||||
def _profile_post_data(**overrides):
|
||||
data = {
|
||||
"name": "Testprofiel",
|
||||
"is_active": "on",
|
||||
"home_postal_code": "3500",
|
||||
"max_distance_km": "45",
|
||||
"desired_titles": ["infrastructure engineer", "cloud engineer"],
|
||||
"excluded_titles": ["sales"],
|
||||
"desired_skills": ["azure", "terraform"],
|
||||
"excluded_skills": ["cold calling"],
|
||||
"allowed_employment_types": ["full_time", "permanent"],
|
||||
"preferred_workplace": ["hybrid"],
|
||||
"preferred_regions": ["Limburg"],
|
||||
"excluded_regions": ["West-Vlaanderen"],
|
||||
"recommendation_threshold": "65",
|
||||
"top_match_threshold": "90",
|
||||
"digest_time": "07:30",
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_profile_form_uses_guided_choices_and_preserves_existing_custom_values(profile):
|
||||
form = SearchProfileForm(instance=profile)
|
||||
|
||||
assert form.fields["home_postal_code"].widget.attrs["maxlength"] == 4
|
||||
assert "home_municipality" not in form.fields
|
||||
assert "home_latitude" not in form.fields
|
||||
assert "home_longitude" not in form.fields
|
||||
assert form.fields["desired_titles"].widget.allow_multiple_selected is True
|
||||
assert ("systeembeheerder", "systeembeheerder") in form.fields["desired_titles"].choices
|
||||
assert ("VMware", "VMware") in form.fields["desired_skills"].choices
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_profile_form_rejects_non_belgian_postcode_and_conflicting_choices(profile):
|
||||
invalid_postcode = SearchProfileForm(
|
||||
data=_profile_post_data(home_postal_code="3500 Hasselt"), instance=profile
|
||||
)
|
||||
assert invalid_postcode.is_valid() is False
|
||||
assert "home_postal_code" in invalid_postcode.errors
|
||||
|
||||
conflicting = SearchProfileForm(
|
||||
data=_profile_post_data(preferred_regions=["Limburg"], excluded_regions=["Limburg"]),
|
||||
instance=profile,
|
||||
)
|
||||
assert conflicting.is_valid() is False
|
||||
assert "excluded_regions" in conflicting.errors
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_profile_update_derives_home_location_from_postcode(client, user, profile):
|
||||
GeocodeLocationLookup.objects.create(
|
||||
source_name="test-geodata",
|
||||
source_version="2026-01",
|
||||
query_kind=GeocodeLocationLookup.QueryKind.POSTAL,
|
||||
query_value="3500",
|
||||
postal_code="3500",
|
||||
municipality="Hasselt",
|
||||
region="Limburg",
|
||||
latitude=Decimal("50.930700"),
|
||||
longitude=Decimal("5.332500"),
|
||||
confidence=Decimal("1.00"),
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
reverse("profiles:edit", kwargs={"pk": profile.pk}), _profile_post_data()
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
profile.refresh_from_db()
|
||||
assert profile.home_postal_code == "3500"
|
||||
assert profile.home_municipality == "Hasselt"
|
||||
assert profile.home_latitude == Decimal("50.930700")
|
||||
assert profile.desired_titles == ["infrastructure engineer", "cloud engineer"]
|
||||
assert profile.preferred_workplace == ["hybrid"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_profile_update_clears_stale_coordinates_when_postcode_is_unknown(client, user, profile):
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
reverse("profiles:edit", kwargs={"pk": profile.pk}),
|
||||
_profile_post_data(home_postal_code="9999"),
|
||||
follow=True,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "De postcode is bewaard" in response.content.decode("utf-8")
|
||||
profile.refresh_from_db()
|
||||
assert profile.home_postal_code == "9999"
|
||||
assert profile.home_municipality == ""
|
||||
assert profile.home_latitude is None
|
||||
assert profile.home_longitude is None
|
||||
@@ -0,0 +1,91 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from django.core.management import call_command
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.sources.models import Source, SourcePolicyReview
|
||||
from apps.sources.views import _source_list_queryset
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_seed_sources_registers_reviewed_live_feed_and_hides_demo(tmp_path):
|
||||
Source.objects.create(
|
||||
name="Voorbeeldwerkgever (fixture)",
|
||||
source_type=Source.Type.EMPLOYER,
|
||||
base_url="https://jobs.example.org/vacatures/",
|
||||
domain="jobs.example.org",
|
||||
)
|
||||
expires_at = (timezone.now() + timedelta(days=30)).isoformat()
|
||||
retired_source = Source.objects.create(
|
||||
name="Brusselse werkgever",
|
||||
source_type=Source.Type.ATS,
|
||||
base_url="https://brussels-employer.example/jobs",
|
||||
domain="brussels-employer.example",
|
||||
status=Source.Status.ACTIVE,
|
||||
policy=Source.Policy.ALLOW,
|
||||
next_run_at=timezone.now(),
|
||||
)
|
||||
payload = {
|
||||
"disable_demo_sources": True,
|
||||
"retire_source_urls": [retired_source.base_url],
|
||||
"sources": [
|
||||
{
|
||||
"name": "VITO",
|
||||
"url": "https://vito.recruitee.com/api/offers/",
|
||||
"type": "ats",
|
||||
"status": "trial",
|
||||
"policy": "review",
|
||||
"parser": "ats-recruitee",
|
||||
"allow_public_endpoint": True,
|
||||
"metadata": {"country_scope": "BE"},
|
||||
"review": {
|
||||
"decision": "trial",
|
||||
"reason": "Publieke feed",
|
||||
"evidence_link": "https://docs.recruitee.com/reference/offers",
|
||||
"expires_at": expires_at,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "Lever werkgever A",
|
||||
"url": "https://api.lever.co/v0/postings/werkgever-a?mode=json",
|
||||
"type": "ats",
|
||||
"status": "trial",
|
||||
"policy": "allow",
|
||||
"parser": "ats-lever",
|
||||
"allow_public_endpoint": True,
|
||||
},
|
||||
{
|
||||
"name": "Lever werkgever B",
|
||||
"url": "https://api.lever.co/v0/postings/werkgever-b?mode=json",
|
||||
"type": "ats",
|
||||
"status": "trial",
|
||||
"policy": "allow",
|
||||
"parser": "ats-lever",
|
||||
"allow_public_endpoint": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
seed_path = tmp_path / "sources.yaml"
|
||||
seed_path.write_text(yaml.safe_dump(payload), encoding="utf-8")
|
||||
|
||||
call_command("seed_sources", str(seed_path))
|
||||
call_command("seed_sources", str(seed_path))
|
||||
|
||||
demo = Source.objects.get(domain="jobs.example.org")
|
||||
assert demo.status == Source.Status.DISABLED
|
||||
assert demo.metadata["demo"] is True
|
||||
retired_source.refresh_from_db()
|
||||
assert retired_source.status == Source.Status.DISABLED
|
||||
assert retired_source.next_run_at is None
|
||||
assert retired_source.metadata["hidden_from_source_list"] is True
|
||||
assert retired_source.metadata["retired_reason"] == "Buiten de ingestelde regionale dekking"
|
||||
source = Source.objects.get(name="VITO")
|
||||
assert retired_source not in _source_list_queryset()
|
||||
assert source in _source_list_queryset()
|
||||
assert source.allow_public_endpoint is True
|
||||
assert source.parser_key == "ats-recruitee"
|
||||
assert source.latest_policy_review.decision == SourcePolicyReview.Decision.TRIAL
|
||||
assert source.policy_reviews.count() == 1
|
||||
assert Source.objects.filter(domain="api.lever.co", source_type=Source.Type.ATS).count() == 2
|
||||
@@ -177,7 +177,20 @@ def test_job_profile_source_and_application_views(client, user, profile, job, so
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_sources_candidate_bulk_actions_and_evidence_view(client, user):
|
||||
def test_sources_candidate_bulk_actions_and_evidence_view(client, user, profile):
|
||||
profile.home_postal_code = "2400"
|
||||
profile.max_distance_km = 40
|
||||
profile.home_latitude = None
|
||||
profile.home_longitude = None
|
||||
profile.save(
|
||||
update_fields=[
|
||||
"home_postal_code",
|
||||
"max_distance_km",
|
||||
"home_latitude",
|
||||
"home_longitude",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
candidate = Source.objects.create(
|
||||
name="Discoverd candidate",
|
||||
source_type=Source.Type.EMPLOYER,
|
||||
@@ -206,12 +219,37 @@ def test_sources_candidate_bulk_actions_and_evidence_view(client, user):
|
||||
status=Source.Status.CANDIDATE,
|
||||
policy=Source.Policy.REVIEW,
|
||||
)
|
||||
watchlist = Source.objects.create(
|
||||
name="Interessante werkgever zonder stabiele feed",
|
||||
source_type=Source.Type.EMPLOYER,
|
||||
base_url="https://watch.example.org/jobs/",
|
||||
domain="watch.example.org",
|
||||
status=Source.Status.PAUSED,
|
||||
policy=Source.Policy.REVIEW,
|
||||
metadata={
|
||||
"watchlist": True,
|
||||
"municipality_scope": "Olen",
|
||||
"watchlist_reason": "Regionaal interessante werkgever",
|
||||
"current_observation": "Momenteel geen lokale vacature bevestigd.",
|
||||
"public_job_page": "https://watch.example.org/jobs/",
|
||||
},
|
||||
)
|
||||
|
||||
client.force_login(user)
|
||||
response = client.get(reverse("sources:list"))
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode("utf-8")
|
||||
assert "jobs.example.org/careers" in content
|
||||
assert "Platformmailboxen" in content
|
||||
assert "Mailbox koppelen en testen" in content
|
||||
assert "elke vijf minuten" in content
|
||||
assert "2400 Mol" in content
|
||||
assert "maximaal 40 km" in content
|
||||
assert "Coördinaten ontbreken nog" in content
|
||||
assert "Interessante werkgeverswaaklijst" in content
|
||||
assert watchlist.name in content
|
||||
assert response.context["watchlist_count"] == 1
|
||||
assert {item.pk for item in response.context["sources"]} == {candidate.pk, other.pk}
|
||||
|
||||
response = client.post(
|
||||
reverse("sources:bulk"),
|
||||
@@ -229,3 +267,12 @@ def test_sources_candidate_bulk_actions_and_evidence_view(client, user):
|
||||
other.refresh_from_db()
|
||||
assert other.status == Source.Status.DISABLED
|
||||
assert other.policy == Source.Policy.DENY
|
||||
|
||||
response = client.post(
|
||||
reverse("sources:bulk"),
|
||||
{"action": "dismiss", "source_ids": [str(watchlist.pk)]},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
watchlist.refresh_from_db()
|
||||
assert watchlist.status == Source.Status.PAUSED
|
||||
assert watchlist.policy == Source.Policy.REVIEW
|
||||
|
||||
@@ -9,10 +9,20 @@ from apps.sources.services.robots import RobotsDecision
|
||||
|
||||
|
||||
def test_platform_domains_are_denied_including_subdomains():
|
||||
assert is_denied_domain("www.linkedin.com")
|
||||
assert is_denied_domain("be.indeed.com")
|
||||
for domain in (
|
||||
"www.linkedin.com",
|
||||
"be.indeed.com",
|
||||
"www.ictjob.be",
|
||||
"www.jobat.be",
|
||||
"www.stepstone.be",
|
||||
"www.careerjet.be",
|
||||
"jobs.randstad.be",
|
||||
"www.roberthalf.com",
|
||||
):
|
||||
assert is_denied_domain(domain)
|
||||
assert not is_denied_domain("jobs.example.org")
|
||||
assert not assess_url("https://www.linkedin.com/jobs/view/123").allowed
|
||||
assert not assess_url("https://www.ictjob.be/nl/it-vacatures-zoeken/example").allowed
|
||||
|
||||
|
||||
@pytest.mark.security
|
||||
|
||||
@@ -92,3 +92,61 @@ def test_robots_cache_refreshes_after_ttl(db, settings, monkeypatch):
|
||||
assert calls["count"] == 2
|
||||
assert SourceRobotsCache.objects.filter(origin__contains="jobs.example.org").exists()
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.mark.security
|
||||
def test_robots_follows_and_revalidates_redirects(db, monkeypatch):
|
||||
validated_urls = []
|
||||
|
||||
def validate(url, **kwargs):
|
||||
validated_urls.append(url)
|
||||
if "127.0.0.1" in url:
|
||||
from apps.sources.services.url_security import UnsafeUrlError
|
||||
|
||||
raise UnsafeUrlError("Privéadres")
|
||||
return ValidatedUrl(
|
||||
url=url,
|
||||
hostname="jobs.example.org",
|
||||
port=443,
|
||||
addresses=("93.184.216.34",),
|
||||
)
|
||||
|
||||
monkeypatch.setattr("apps.sources.services.robots.validate_public_url", validate)
|
||||
source = Source.objects.create(
|
||||
name="Redirect jobs",
|
||||
source_type=Source.Type.ATS,
|
||||
base_url="https://ats.example.org/jobs",
|
||||
domain="ats.example.org",
|
||||
status=Source.Status.ACTIVE,
|
||||
policy=Source.Policy.ALLOW,
|
||||
)
|
||||
|
||||
def safe_handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.host == "ats.example.org":
|
||||
return httpx.Response(
|
||||
302,
|
||||
headers={"location": "https://jobs.example.org/robots.txt"},
|
||||
request=request,
|
||||
)
|
||||
return httpx.Response(200, text="User-agent: *\nAllow: /", request=request)
|
||||
|
||||
client = httpx.Client(transport=httpx.MockTransport(safe_handler))
|
||||
assert assess_robots(source.base_url, source=source, client=client).allowed is True
|
||||
client.close()
|
||||
assert validated_urls == [
|
||||
"https://ats.example.org/robots.txt",
|
||||
"https://jobs.example.org/robots.txt",
|
||||
]
|
||||
|
||||
SourceRobotsCache.objects.all().delete()
|
||||
|
||||
def unsafe_handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
302, headers={"location": "http://127.0.0.1/robots.txt"}, request=request
|
||||
)
|
||||
|
||||
client = httpx.Client(transport=httpx.MockTransport(unsafe_handler))
|
||||
blocked = assess_robots(source.base_url, source=source, client=client)
|
||||
client.close()
|
||||
assert blocked.allowed is False
|
||||
assert "Privéadres" in blocked.reason
|
||||
|
||||
@@ -89,6 +89,22 @@ def test_lever_adapter_detail_supports_changed_markup():
|
||||
assert job.location_text == "Aalst"
|
||||
|
||||
|
||||
def test_lever_adapter_supports_documented_public_postings_api_shape():
|
||||
adapter = LeverAdapter()
|
||||
result = adapter.extract(
|
||||
_load_fixture("fixtures/ats/lever-public-postings.json"),
|
||||
url="https://api.lever.co/v0/postings/deliverect?mode=json",
|
||||
)
|
||||
|
||||
assert len(result.jobs) == 1
|
||||
job = result.jobs[0]
|
||||
assert job.title == "Backend Engineer"
|
||||
assert job.url == "https://jobs.lever.co/deliverect/deliverect-open-1"
|
||||
assert job.location_text == "Ghent, Flemish Region"
|
||||
assert job.employment_types == ["Full-time Permanent"]
|
||||
assert job.workplace_type == "hybrid"
|
||||
|
||||
|
||||
def test_recruitee_adapter_listing_extracts_open_jobs_and_skips_closed():
|
||||
adapter = RecruiteeAdapter()
|
||||
result = adapter.extract(
|
||||
@@ -114,6 +130,23 @@ def test_recruitee_adapter_detail_supports_changed_markup():
|
||||
assert job.employment_types == ["permanent"]
|
||||
|
||||
|
||||
def test_recruitee_adapter_supports_documented_public_offers_shape():
|
||||
adapter = RecruiteeAdapter()
|
||||
result = adapter.extract(
|
||||
_load_fixture("fixtures/ats/recruitee-public-offers.json"),
|
||||
url="https://vito.recruitee.com/api/offers/",
|
||||
)
|
||||
|
||||
assert len(result.jobs) == 1
|
||||
job = result.jobs[0]
|
||||
assert job.title == "Research Software Engineer"
|
||||
assert job.employer_name == "VITO"
|
||||
assert job.url == "https://jobs.vito.be/o/research-software-engineer"
|
||||
assert job.postal_code == "2400"
|
||||
assert job.employment_types == ["fulltime_fixed_term"]
|
||||
assert job.workplace_type == "hybrid"
|
||||
|
||||
|
||||
def test_smartrecruiters_adapter_listing_extracts_open_jobs_and_skips_closed():
|
||||
adapter = SmartRecruitersAdapter()
|
||||
result = adapter.extract(
|
||||
@@ -139,6 +172,24 @@ def test_smartrecruiters_adapter_detail_supports_changed_markup():
|
||||
assert job.workplace_type in {"remote", "hybrid", ""}
|
||||
|
||||
|
||||
def test_smartrecruiters_adapter_supports_documented_public_postings_shape():
|
||||
adapter = SmartRecruitersAdapter()
|
||||
result = adapter.extract(
|
||||
_load_fixture("fixtures/ats/smartrecruiters-public-postings.json"),
|
||||
url=("https://api.smartrecruiters.com/v1/companies/ARHS/postings?limit=100&country=be"),
|
||||
)
|
||||
|
||||
assert len(result.jobs) == 2
|
||||
job = result.jobs[1]
|
||||
assert job.title == "Cloud Engineer"
|
||||
assert job.employer_name == "ARHS"
|
||||
assert job.url == "https://jobs.smartrecruiters.com/ARHS/744000136173889"
|
||||
assert job.location_text == "Leuven"
|
||||
assert job.country == "be"
|
||||
assert job.employment_types == ["Permanent"]
|
||||
assert job.workplace_type == "remote"
|
||||
|
||||
|
||||
def test_workable_adapter_listing_extracts_open_jobs_and_skips_closed():
|
||||
adapter = WorkableAdapter()
|
||||
result = adapter.extract(
|
||||
|
||||
@@ -24,3 +24,18 @@ def test_runtime_is_compatible_with_read_only_container() -> None:
|
||||
assert '"--no-control-socket"' in dockerfile
|
||||
assert "exec -T web python manage.py collectstatic" not in deploy_script
|
||||
assert "for required_host in 127.0.0.1 localhost" in deploy_script
|
||||
|
||||
|
||||
def test_deployment_examples_use_per_platform_mailbox_configuration() -> None:
|
||||
required_keys = {
|
||||
"MAILBOX_CREDENTIAL_KEYS",
|
||||
"IMAP_CONNECT_TIMEOUT_SECONDS",
|
||||
"IMAP_MAX_MESSAGES_PER_POLL",
|
||||
"IMAP_MAX_MESSAGE_BYTES",
|
||||
}
|
||||
for filename in (".env.example", "scripts/codex_bootstrap.sh", "scripts/release_verify.sh"):
|
||||
content = (ROOT / filename).read_text(encoding="utf-8")
|
||||
|
||||
assert "IMAP_ENABLED" not in content
|
||||
for key in required_keys:
|
||||
assert f"{key}=" in content
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.sources.adapters.email_alert import EmailAlertAdapter
|
||||
from apps.sources.models import MailboxConnection
|
||||
from apps.sources.platforms import PLATFORM_ALERTS
|
||||
|
||||
|
||||
def test_email_adapter_extracts_job_links_and_skips_unsubscribe():
|
||||
@@ -10,3 +14,43 @@ def test_email_adapter_extracts_job_links_and_skips_unsubscribe():
|
||||
assert "https://jobs.example.org/vacatures/infrastructure-engineer" in urls
|
||||
assert "https://www.linkedin.com/jobs/view/123456" in urls
|
||||
assert all("unsubscribe" not in url for url in urls)
|
||||
|
||||
|
||||
def test_email_adapter_recognizes_sanitized_vdab_alert():
|
||||
raw = Path("fixtures/emails/vdab_job_alert.eml").read_bytes()
|
||||
result = EmailAlertAdapter().extract_message(raw)
|
||||
|
||||
assert {job.title for job in result.jobs} == {"Python developer", "Cloud engineer"}
|
||||
assert all(job.raw["alert_provider"] == "vdab" for job in result.jobs)
|
||||
assert all(job.raw["target_domain"] == "www.vdab.be" for job in result.jobs)
|
||||
assert all("uitschrijven" not in job.url for job in result.jobs)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provider", "fixture", "title", "domain"),
|
||||
(
|
||||
("linkedin", "linkedin_job_alert.eml", "Software Engineer bij Example", "linkedin.com"),
|
||||
("ictjob", "ictjob_job_alert.eml", "Cloud Engineer", "ictjob.be"),
|
||||
("jobat", "jobat_job_alert.eml", "Data Engineer", "jobat.be"),
|
||||
("stepstone", "stepstone_job_alert.eml", "Software Developer", "stepstone.be"),
|
||||
("careerjet", "careerjet_job_alert.eml", "System Engineer", "careerjet.be"),
|
||||
("randstad", "randstad_job_alert.eml", "Infrastructure Engineer", "randstad.be"),
|
||||
("roberthalf", "roberthalf_job_alert.eml", "Network Engineer", "roberthalf.com"),
|
||||
),
|
||||
)
|
||||
def test_platform_bound_alert_extracts_only_expected_https_jobboard_link(
|
||||
provider, fixture, title, domain
|
||||
):
|
||||
raw = (Path("fixtures/emails") / fixture).read_bytes()
|
||||
|
||||
result = EmailAlertAdapter().extract_message(raw, expected_provider=provider)
|
||||
|
||||
assert [job.title for job in result.jobs] == [title]
|
||||
assert result.jobs[0].raw["alert_provider"] == provider
|
||||
assert result.jobs[0].raw["target_domain"].endswith(domain)
|
||||
assert result.jobs[0].url.startswith("https://")
|
||||
assert "outside.example.invalid" not in result.jobs[0].url
|
||||
|
||||
|
||||
def test_mailbox_platform_choices_and_alert_catalog_stay_in_sync():
|
||||
assert set(MailboxConnection.Platform.values) == set(PLATFORM_ALERTS)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import override_settings
|
||||
|
||||
from apps.sources.models import MailboxConnection
|
||||
from apps.sources.services.imap_import import poll_imap_mailbox
|
||||
from apps.sources.services.mailbox_connections import (
|
||||
MailboxCredentialError,
|
||||
decrypt_mailbox_password,
|
||||
encrypt_mailbox_password,
|
||||
rotate_mailbox_credentials,
|
||||
)
|
||||
from apps.sources.services.url_security import UnsafeUrlError
|
||||
|
||||
|
||||
def test_mailbox_password_is_encrypted_and_supports_key_rotation():
|
||||
old_key = Fernet.generate_key().decode("ascii")
|
||||
new_key = Fernet.generate_key().decode("ascii")
|
||||
with override_settings(MAILBOX_CREDENTIAL_KEYS=[old_key]):
|
||||
ciphertext = encrypt_mailbox_password("app-password-value")
|
||||
|
||||
connection = MailboxConnection(encrypted_password=ciphertext)
|
||||
assert "app-password-value" not in ciphertext
|
||||
with override_settings(MAILBOX_CREDENTIAL_KEYS=[new_key, old_key]):
|
||||
assert decrypt_mailbox_password(connection) == "app-password-value"
|
||||
rotated = encrypt_mailbox_password("replacement-value")
|
||||
assert Fernet(new_key.encode("ascii")).decrypt(rotated.encode("ascii")) == b"replacement-value"
|
||||
|
||||
|
||||
def test_mailbox_password_fails_closed_without_valid_key():
|
||||
with (
|
||||
override_settings(MAILBOX_CREDENTIAL_KEYS=[]),
|
||||
pytest.raises(MailboxCredentialError, match="niet geconfigureerd"),
|
||||
):
|
||||
encrypt_mailbox_password("never-stored")
|
||||
with (
|
||||
override_settings(MAILBOX_CREDENTIAL_KEYS=["invalid"]),
|
||||
pytest.raises(MailboxCredentialError, match="ongeldige sleutel"),
|
||||
):
|
||||
encrypt_mailbox_password("never-stored")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["", "https://imap.example.org", "imap.example.org/path", "a b"])
|
||||
def test_custom_mailbox_host_rejects_non_hostname_values(host):
|
||||
connection = MailboxConnection(
|
||||
platform=MailboxConnection.Platform.VDAB,
|
||||
provider=MailboxConnection.Provider.CUSTOM,
|
||||
custom_host=host,
|
||||
port=993,
|
||||
username="mailbox@example.invalid",
|
||||
encrypted_password="ciphertext",
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
connection.clean()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("port", [0, 65536])
|
||||
def test_mailbox_rejects_invalid_port(port):
|
||||
connection = MailboxConnection(
|
||||
platform=MailboxConnection.Platform.VDAB,
|
||||
provider=MailboxConnection.Provider.GMAIL,
|
||||
port=port,
|
||||
username="mailbox@example.invalid",
|
||||
encrypted_password="ciphertext",
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
connection.clean()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_rotation_reencrypts_existing_credentials_with_primary_key(user):
|
||||
old_key = Fernet.generate_key().decode("ascii")
|
||||
new_key = Fernet.generate_key().decode("ascii")
|
||||
with override_settings(MAILBOX_CREDENTIAL_KEYS=[old_key]):
|
||||
ciphertext = encrypt_mailbox_password("rotate-me")
|
||||
connection = MailboxConnection.objects.create(
|
||||
user=user,
|
||||
platform=MailboxConnection.Platform.VDAB,
|
||||
provider=MailboxConnection.Provider.GMAIL,
|
||||
username="rotate@example.invalid",
|
||||
encrypted_password=ciphertext,
|
||||
)
|
||||
|
||||
with override_settings(MAILBOX_CREDENTIAL_KEYS=[new_key, old_key]):
|
||||
assert rotate_mailbox_credentials() == 1
|
||||
connection.refresh_from_db()
|
||||
with override_settings(MAILBOX_CREDENTIAL_KEYS=[new_key]):
|
||||
assert decrypt_mailbox_password(connection) == "rotate-me"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_private_imap_host_is_blocked_before_connection(user):
|
||||
key = Fernet.generate_key().decode("ascii")
|
||||
with override_settings(MAILBOX_CREDENTIAL_KEYS=[key]):
|
||||
connection = MailboxConnection.objects.create(
|
||||
user=user,
|
||||
platform=MailboxConnection.Platform.VDAB,
|
||||
provider=MailboxConnection.Provider.CUSTOM,
|
||||
custom_host="127.0.0.1",
|
||||
username="private@example.invalid",
|
||||
encrypted_password=encrypt_mailbox_password("never-sent"),
|
||||
)
|
||||
with pytest.raises(UnsafeUrlError, match="Niet-publiek"):
|
||||
poll_imap_mailbox(
|
||||
connection,
|
||||
client_factory=lambda *args, **kwargs: pytest.fail(
|
||||
"Private IMAP-host had niet geconnecteerd mogen worden"
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,253 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.sources.adapters.kempen import KempenEmployerAdapter
|
||||
from apps.sources.adapters.mol_region import MolRegionEmployerAdapter
|
||||
from apps.sources.adapters.regional import CordaCampusAdapter, LocalEmployerListingAdapter
|
||||
|
||||
|
||||
def test_corda_adapter_extracts_local_employer_cards_and_rejects_off_domain_links():
|
||||
content = Path("fixtures/pages/corda-campus-jobs.html").read_text(encoding="utf-8")
|
||||
|
||||
result = CordaCampusAdapter().extract(
|
||||
content,
|
||||
url="https://www.cordacampus.com/jobs/",
|
||||
)
|
||||
|
||||
assert result.parser_key == "regional-corda-campus"
|
||||
assert len(result.jobs) == 2
|
||||
assert {job.title for job in result.jobs} == {"System Engineer", "Data Engineer"}
|
||||
assert {job.employer_name for job in result.jobs} == {
|
||||
"Voorbeeld IT",
|
||||
"Voorbeeld Data",
|
||||
}
|
||||
assert all(job.region == "Limburg" for job in result.jobs)
|
||||
assert all("evil.example" not in job.url for job in result.jobs)
|
||||
|
||||
|
||||
def test_corda_adapter_does_not_capture_individual_detail_pages():
|
||||
result = CordaCampusAdapter().extract(
|
||||
"<h1>System Engineer</h1>",
|
||||
url="https://www.cordacampus.com/job/system-engineer/",
|
||||
)
|
||||
|
||||
assert result.jobs == []
|
||||
assert result.confidence == 0.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("fixture_name", "url", "expected_title", "expected_employer"),
|
||||
[
|
||||
(
|
||||
"aca-group-jobs.html",
|
||||
"https://acagroup.be/en/jobs",
|
||||
"AWS Cloud & DevOps Engineer",
|
||||
"ACA Group",
|
||||
),
|
||||
(
|
||||
"xplore-group-jobs.html",
|
||||
"https://www.xploregroup.be/en/jobs",
|
||||
"Platform Engineer",
|
||||
"Xplore Group",
|
||||
),
|
||||
(
|
||||
"uhasselt-jobs.html",
|
||||
"https://www.uhasselt.be/vacatures",
|
||||
"Expert systeembeheerder (100%)",
|
||||
"Universiteit Hasselt",
|
||||
),
|
||||
(
|
||||
"pxl-jobs.html",
|
||||
"https://ses.pxl.be/",
|
||||
"Lector netwerkbeheer",
|
||||
"Hogeschool PXL",
|
||||
),
|
||||
(
|
||||
"zol-jobs.html",
|
||||
"https://ziekenhuis-oost-limburg.cvw.io/?lang=nl-BE",
|
||||
"(Senior) IT Engineer Infrastructuur",
|
||||
"Ziekenhuis Oost-Limburg",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_local_employer_adapter_extracts_reviewed_routes_only(
|
||||
fixture_name, url, expected_title, expected_employer
|
||||
):
|
||||
content = Path(f"fixtures/pages/{fixture_name}").read_text(encoding="utf-8")
|
||||
|
||||
result = LocalEmployerListingAdapter().extract(content, url=url)
|
||||
|
||||
assert result.parser_key == "regional-local-employers"
|
||||
assert len(result.jobs) == 1
|
||||
assert result.jobs[0].title == expected_title
|
||||
assert result.jobs[0].employer_name == expected_employer
|
||||
assert result.jobs[0].region == "Limburg"
|
||||
assert "evil.example" not in result.jobs[0].url
|
||||
|
||||
|
||||
def test_local_employer_adapter_fails_closed_for_unreviewed_host_or_path():
|
||||
adapter = LocalEmployerListingAdapter()
|
||||
|
||||
assert adapter.extract("<h3>Fake</h3>", url="https://evil.example/jobs").jobs == []
|
||||
assert (
|
||||
adapter.extract(
|
||||
"<h3>Detail</h3>",
|
||||
url="https://www.uhasselt.be/vacatures/detail/3999",
|
||||
).jobs
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("fixture_name", "url", "expected_title", "expected_employer"),
|
||||
[
|
||||
(
|
||||
"sck-cen-jobs.html",
|
||||
"https://www.sckcen.be/nl/carriere/vacatures",
|
||||
"Automatisatie Ingenieur PLC & OT-netwerken",
|
||||
"SCK CEN",
|
||||
),
|
||||
(
|
||||
"cipal-schaubroeck-jobs.html",
|
||||
"https://cipalschaubroeck.teamtailor.com/jobs",
|
||||
"Network & Security Engineer",
|
||||
"Cipal Schaubroeck",
|
||||
),
|
||||
(
|
||||
"vanroey-jobs.html",
|
||||
"https://www.vanroey.be/en/job-overview/",
|
||||
"Network Engineer",
|
||||
"VanRoey",
|
||||
),
|
||||
(
|
||||
"netropolix-jobs.html",
|
||||
"https://netropolix.recruitee.com/",
|
||||
"System Engineer - Geel",
|
||||
"NTX (Netropolix)",
|
||||
),
|
||||
(
|
||||
"thomas-more-jobs.html",
|
||||
"https://jobpage.cvwarehouse.com/?companyGuid=eab9ca13-ee10-4504-8a87-a785d0b037ef&lang=nl-BE",
|
||||
"Een medewerker lokale IT - Campus Geel",
|
||||
"Thomas More",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_mol_region_adapter_extracts_only_reviewed_local_cards(
|
||||
fixture_name, url, expected_title, expected_employer
|
||||
):
|
||||
content = Path(f"fixtures/pages/{fixture_name}").read_text(encoding="utf-8")
|
||||
|
||||
result = MolRegionEmployerAdapter().extract(content, url=url)
|
||||
|
||||
assert result.parser_key == "regional-mol-employers"
|
||||
assert result.warnings == []
|
||||
assert len(result.jobs) == 1
|
||||
assert result.jobs[0].title == expected_title
|
||||
assert result.jobs[0].employer_name == expected_employer
|
||||
assert result.jobs[0].region == "Antwerpen"
|
||||
assert "evil.example" not in result.jobs[0].url
|
||||
|
||||
|
||||
def test_mol_region_adapter_fails_closed_for_unreviewed_route_and_company():
|
||||
adapter = MolRegionEmployerAdapter()
|
||||
|
||||
assert adapter.extract("<h1>Fake</h1>", url="https://evil.example/jobs").jobs == []
|
||||
assert (
|
||||
adapter.extract(
|
||||
"<h1>Detail</h1>",
|
||||
url="https://www.sckcen.be/nl/carriere/vacatures/detail",
|
||||
).jobs
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("fixture_name", "url", "expected_title", "expected_employer", "expected_postal"),
|
||||
[
|
||||
(
|
||||
"ziekenhuis-geel-jobs.html",
|
||||
"https://ziekenhuisgeel.careersite.be/nl/vacatures",
|
||||
"ICT Support Engineer",
|
||||
"Ziekenhuis Geel",
|
||||
"2440",
|
||||
),
|
||||
(
|
||||
"stad-geel-jobs.html",
|
||||
"https://geel.hro.be/",
|
||||
"Administratief medewerker digitale dienstverlening",
|
||||
"Lokaal bestuur Geel",
|
||||
"2440",
|
||||
),
|
||||
(
|
||||
"stad-turnhout-jobs.html",
|
||||
"https://jobs.turnhout.be/",
|
||||
"Coördinator digitale projecten",
|
||||
"Stad Turnhout",
|
||||
"2300",
|
||||
),
|
||||
(
|
||||
"renotec-geel-jobs.html",
|
||||
"https://jobs.renotec.be/nl/alle-jobs/",
|
||||
"Technisch commercieel verantwoordelijke",
|
||||
"Group Renotec",
|
||||
"2440",
|
||||
),
|
||||
(
|
||||
"ravago-kempen-jobs.html",
|
||||
"https://ravago.softgarden.io/en/vacancies",
|
||||
"SAP Operations Analyst",
|
||||
"Ravago",
|
||||
"2370",
|
||||
),
|
||||
(
|
||||
"sanofi-geel-jobs.html",
|
||||
"https://jobs.sanofi.com/en/belgium",
|
||||
"Maintenance Engineer",
|
||||
"Sanofi",
|
||||
"2440",
|
||||
),
|
||||
(
|
||||
"daf-westerlo-jobs.html",
|
||||
"https://www.daf.com/nl-nl/werken-bij-daf/vacatures",
|
||||
"Technieker Facilitaire Diensten",
|
||||
"DAF Trucks",
|
||||
"2260",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_kempen_adapter_extracts_only_reviewed_local_cards(
|
||||
fixture_name, url, expected_title, expected_employer, expected_postal
|
||||
):
|
||||
content = Path(f"fixtures/pages/{fixture_name}").read_text(encoding="utf-8")
|
||||
|
||||
result = KempenEmployerAdapter().extract(content, url=url)
|
||||
|
||||
assert result.parser_key == "regional-kempen-employers"
|
||||
assert result.warnings == []
|
||||
assert len(result.jobs) == 1
|
||||
assert result.jobs[0].title == expected_title
|
||||
assert result.jobs[0].employer_name == expected_employer
|
||||
assert result.jobs[0].postal_code == expected_postal
|
||||
assert "evil.example" not in result.jobs[0].url
|
||||
|
||||
|
||||
def test_kempen_adapter_fails_closed_for_unreviewed_route():
|
||||
adapter = KempenEmployerAdapter()
|
||||
|
||||
assert adapter.extract("<h1>Fake</h1>", url="https://evil.example/jobs").jobs == []
|
||||
assert (
|
||||
adapter.extract(
|
||||
"<h1>Detail</h1>",
|
||||
url="https://jobs.sanofi.com/en/search-jobs",
|
||||
).jobs
|
||||
== []
|
||||
)
|
||||
assert (
|
||||
adapter.extract(
|
||||
"<h1>Andere werkgever</h1>",
|
||||
url="https://jobpage.cvwarehouse.com/?companyGuid=other&lang=nl-BE",
|
||||
).jobs
|
||||
== []
|
||||
)
|
||||
@@ -3,7 +3,20 @@ from __future__ import annotations
|
||||
import pytest
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from config.settings import _validate_production_security
|
||||
from config.settings import _validate_production_security, env_int_range
|
||||
|
||||
|
||||
def test_env_int_range_accepts_default_and_rejects_invalid_values(monkeypatch):
|
||||
monkeypatch.delenv("TEST_BOUNDED_SETTING", raising=False)
|
||||
assert env_int_range("TEST_BOUNDED_SETTING", 6, minimum=0, maximum=23) == 6
|
||||
|
||||
monkeypatch.setenv("TEST_BOUNDED_SETTING", "24")
|
||||
with pytest.raises(ImproperlyConfigured):
|
||||
env_int_range("TEST_BOUNDED_SETTING", 6, minimum=0, maximum=23)
|
||||
|
||||
monkeypatch.setenv("TEST_BOUNDED_SETTING", "morgen")
|
||||
with pytest.raises(ImproperlyConfigured):
|
||||
env_int_range("TEST_BOUNDED_SETTING", 6, minimum=0, maximum=23)
|
||||
|
||||
|
||||
def test_validate_production_security_skips_when_debug_mode_is_enabled():
|
||||
|
||||
@@ -2,6 +2,7 @@ from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.sources.models import Source, SourceRun
|
||||
@@ -40,3 +41,27 @@ def test_source_run_finish_updates_metrics(source):
|
||||
assert run.finished_at is not None
|
||||
assert run.http_status == 200
|
||||
assert run.extracted_count == 3
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_shared_ats_domain_accepts_distinct_public_feeds_but_not_duplicate_urls():
|
||||
Source.objects.create(
|
||||
name="Lever A",
|
||||
source_type=Source.Type.ATS,
|
||||
base_url="https://api.lever.co/v0/postings/a?mode=json",
|
||||
domain="api.lever.co",
|
||||
)
|
||||
Source.objects.create(
|
||||
name="Lever B",
|
||||
source_type=Source.Type.ATS,
|
||||
base_url="https://api.lever.co/v0/postings/b?mode=json",
|
||||
domain="api.lever.co",
|
||||
)
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
Source.objects.create(
|
||||
name="Lever A dubbel",
|
||||
source_type=Source.Type.ATS,
|
||||
base_url="https://api.lever.co/v0/postings/a?mode=json",
|
||||
domain="api.lever.co",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import ssl
|
||||
from pathlib import Path
|
||||
|
||||
import certifi
|
||||
|
||||
from apps.sources.services.tls import trusted_tls_context
|
||||
|
||||
|
||||
def test_application_tls_context_uses_strict_pinned_ca_bundle():
|
||||
context = trusted_tls_context()
|
||||
|
||||
assert Path(certifi.where()).is_file()
|
||||
assert context.verify_mode == ssl.CERT_REQUIRED
|
||||
assert context.check_hostname is True
|
||||
assert context.cert_store_stats()["x509_ca"] > 0
|
||||
Reference in New Issue
Block a user