Skip to content

Commit fe6eadc

Browse files
OlhaZahoruikoswrichards
authored andcommitted
feat: [#2886] add BRP config check
1 parent ab50e33 commit fe6eadc

5 files changed

Lines changed: 366 additions & 1 deletion

File tree

src/open_inwoner/haalcentraal/admin.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@
33

44
from solo.admin import SingletonModelAdmin
55

6+
from maykin_config_checks.api.api import with_config_checks
7+
8+
from .config_checks.fetch_brp import FetchBRPCheck
69
from .models import HaalCentraalConfig
710

811

912
@admin.register(HaalCentraalConfig)
13+
@with_config_checks(FetchBRPCheck)
1014
class HaalCentraalConfigAdmin(SingletonModelAdmin):
1115
fieldsets = (
1216
(
@@ -24,4 +28,11 @@ class HaalCentraalConfigAdmin(SingletonModelAdmin):
2428
"fields": ("headers",),
2529
},
2630
),
31+
(
32+
_("Checks"),
33+
{
34+
"fields": ("config_check_links",),
35+
},
36+
),
2737
)
38+
readonly_fields = ("config_check_links",)

src/open_inwoner/haalcentraal/apps.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@ class HaalCentraalConfig(AppConfig):
55
name = "open_inwoner.haalcentraal"
66

77
def ready(self):
8-
pass
8+
from .config_checks import fetch_brp # noqa

src/open_inwoner/haalcentraal/config_checks/__init__.py

Whitespace-only changes.
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
from typing import Any, Optional, TypedDict
2+
3+
from django import forms
4+
from django.core.exceptions import ImproperlyConfigured
5+
from django.http import HttpRequest
6+
from django.utils.translation import gettext as _
7+
8+
from log_outgoing_requests.models import OutgoingRequestsLogConfig
9+
from pydantic import ValidationError
10+
11+
from maykin_config_checks import GenericConfigCheckResult
12+
from maykin_config_checks.permissions import IsSuperUser
13+
from maykin_config_checks.protocols import InteractiveConfigCheck
14+
from maykin_config_checks.registry import registry
15+
from open_inwoner.haalcentraal.clients import BRPClient
16+
from open_inwoner.haalcentraal.exceptions import BRPAPIError
17+
from open_inwoner.haalcentraal.models import HaalCentraalConfig
18+
from open_inwoner.utils.logentry import system_action, user_action
19+
20+
21+
class FetchBRPForm(forms.Form):
22+
bsn = forms.CharField(max_length=9, label="BSN")
23+
24+
def clean_bsn(self):
25+
bsn = self.cleaned_data["bsn"]
26+
if not bsn.isdigit() or len(bsn) != 9:
27+
raise forms.ValidationError(_("Invalid BSN format (must be 9 digits)"))
28+
return bsn
29+
30+
31+
class FetchBRPCheckParams(TypedDict):
32+
bsn: str
33+
34+
35+
class FetchBRPCheck(
36+
InteractiveConfigCheck[
37+
FetchBRPCheckParams,
38+
HaalCentraalConfig,
39+
]
40+
):
41+
identifier = "fetch_brp"
42+
label = _("Fetch BRP data for BSN")
43+
form_class = FetchBRPForm
44+
45+
required_permissions = (IsSuperUser(),)
46+
47+
@classmethod
48+
def get_form_kwargs(
49+
cls,
50+
instance: Optional[HaalCentraalConfig] = None,
51+
) -> dict[str, Any]:
52+
return {}
53+
54+
def run(
55+
self,
56+
data: FetchBRPCheckParams,
57+
instance: Optional[HaalCentraalConfig] = None,
58+
request: Optional[HttpRequest] = None,
59+
) -> GenericConfigCheckResult:
60+
bsn = data["bsn"]
61+
masked_bsn = f"{bsn[:3]}******"
62+
log_message = f"fetch BRP check run for bsn {masked_bsn}"
63+
64+
if request:
65+
user_action(request, request.user, log_message)
66+
else:
67+
system_action(log_message)
68+
69+
if OutgoingRequestsLogConfig.get_solo().save_body_enabled:
70+
return GenericConfigCheckResult(
71+
success=False,
72+
identifier=self.identifier,
73+
verbose_name=self.label,
74+
message=_(
75+
"This check cannot be run while outgoing request log bodies "
76+
"are being saved, since it exposes personal data (BSN and "
77+
"BRP data) in the outgoing request logs. Disable 'Save "
78+
"request + response body' in the outgoing request log "
79+
"configuration before running this check."
80+
),
81+
extra={},
82+
)
83+
84+
try:
85+
client = BRPClient.from_config()
86+
except ImproperlyConfigured as exc:
87+
return GenericConfigCheckResult(
88+
success=False,
89+
identifier=self.identifier,
90+
verbose_name=self.label,
91+
message=_("Haal Centraal is not configured"),
92+
extra={"error": str(exc)},
93+
)
94+
95+
try:
96+
persoon = client.fetch_brp_data_for_bsn(bsn)
97+
except ValidationError as exc:
98+
return GenericConfigCheckResult(
99+
success=False,
100+
identifier=self.identifier,
101+
verbose_name=self.label,
102+
message=_("Connected, but the response data failed validation"),
103+
extra={"error": str(exc), "version": client.version},
104+
)
105+
except BRPAPIError as exc:
106+
return GenericConfigCheckResult(
107+
success=False,
108+
identifier=self.identifier,
109+
verbose_name=self.label,
110+
message=_("Failed to connect to the BRP API"),
111+
extra={"error": str(exc), "type": type(exc).__name__},
112+
)
113+
except Exception as exc:
114+
return GenericConfigCheckResult(
115+
success=False,
116+
identifier=self.identifier,
117+
verbose_name=self.label,
118+
message=_("Unexpected error while fetching BRP data"),
119+
extra={"exception": str(exc), "type": type(exc).__name__},
120+
)
121+
122+
if persoon is None:
123+
return GenericConfigCheckResult(
124+
success=False,
125+
identifier=self.identifier,
126+
verbose_name=self.label,
127+
message=_("No person found for this BSN"),
128+
extra={"version": client.version},
129+
)
130+
131+
return GenericConfigCheckResult(
132+
success=True,
133+
identifier=self.identifier,
134+
verbose_name=self.label,
135+
message=_("BRP data retrieved and validated (version %(version)s)")
136+
% {"version": client.version},
137+
extra={"version": client.version},
138+
)
139+
140+
141+
registry.register(FetchBRPCheck)
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
from unittest.mock import MagicMock, patch
2+
3+
from django.contrib.auth.models import Permission
4+
from django.test import Client, TestCase
5+
from django.urls import reverse
6+
7+
from log_outgoing_requests.constants import SaveLogsChoice
8+
from log_outgoing_requests.models import OutgoingRequestsLogConfig
9+
from maykin_2fa.test import disable_admin_mfa
10+
from pydantic import ValidationError
11+
12+
from open_inwoner.accounts.tests.factories import UserFactory
13+
from open_inwoner.haalcentraal.api_models import BRP2xPersoon
14+
from open_inwoner.haalcentraal.clients import BRPClient
15+
from open_inwoner.haalcentraal.config_checks.fetch_brp import (
16+
FetchBRPCheck,
17+
FetchBRPForm,
18+
)
19+
from open_inwoner.haalcentraal.exceptions import BRPAPINetworkError
20+
from open_inwoner.haalcentraal.models import HaalCentraalConfig
21+
22+
23+
def make_validation_error() -> ValidationError:
24+
try:
25+
BRP2xPersoon.model_validate(
26+
{"verblijfplaats": {"verblijfadres": {"huisnummer": "not-a-number"}}}
27+
)
28+
except ValidationError as exc:
29+
return exc
30+
raise AssertionError("expected a ValidationError") # pragma: no cover
31+
32+
33+
class FetchBRPCheckTests(TestCase):
34+
def setUp(self):
35+
self.check = FetchBRPCheck()
36+
37+
def test_refuses_to_run_while_save_body_enabled(self):
38+
config = OutgoingRequestsLogConfig.get_solo()
39+
config.save_body = SaveLogsChoice.yes
40+
config.save()
41+
42+
form = FetchBRPForm(data={"bsn": "123456789"})
43+
self.assertTrue(form.is_valid())
44+
45+
result = self.check.run(form.cleaned_data)
46+
47+
self.assertFalse(result.success)
48+
self.assertIn("cannot be run", result.message)
49+
50+
def test_invalid_bsn(self):
51+
form = FetchBRPForm(data={"bsn": "123"})
52+
53+
self.assertFalse(form.is_valid())
54+
self.assertIn("bsn", form.errors)
55+
self.assertIn("Ongeldig BSN", form.errors["bsn"][0])
56+
57+
def test_not_configured(self):
58+
form = FetchBRPForm(data={"bsn": "123456789"})
59+
self.assertTrue(form.is_valid())
60+
61+
result = self.check.run(form.cleaned_data)
62+
63+
self.assertFalse(result.success)
64+
self.assertIn("not configured", result.message)
65+
66+
def test_network_error(self):
67+
form = FetchBRPForm(data={"bsn": "123456789"})
68+
self.assertTrue(form.is_valid())
69+
70+
mock_client = MagicMock()
71+
mock_client.fetch_brp_data_for_bsn.side_effect = BRPAPINetworkError(
72+
"Connection failed"
73+
)
74+
75+
with patch.object(BRPClient, "from_config", return_value=mock_client):
76+
result = self.check.run(form.cleaned_data)
77+
78+
self.assertFalse(result.success)
79+
self.assertIn("Failed to connect", result.message)
80+
81+
def test_validation_error(self):
82+
form = FetchBRPForm(data={"bsn": "123456789"})
83+
self.assertTrue(form.is_valid())
84+
85+
mock_client = MagicMock()
86+
mock_client.fetch_brp_data_for_bsn.side_effect = make_validation_error()
87+
88+
with patch.object(BRPClient, "from_config", return_value=mock_client):
89+
result = self.check.run(form.cleaned_data)
90+
91+
self.assertFalse(result.success)
92+
self.assertIn("failed validation", result.message)
93+
94+
def test_unexpected_error(self):
95+
form = FetchBRPForm(data={"bsn": "123456789"})
96+
self.assertTrue(form.is_valid())
97+
98+
mock_client = MagicMock()
99+
mock_client.fetch_brp_data_for_bsn.side_effect = RuntimeError("boom")
100+
101+
with patch.object(BRPClient, "from_config", return_value=mock_client):
102+
result = self.check.run(form.cleaned_data)
103+
104+
self.assertFalse(result.success)
105+
self.assertIn("Unexpected error", result.message)
106+
107+
def test_no_person_found(self):
108+
form = FetchBRPForm(data={"bsn": "123456789"})
109+
self.assertTrue(form.is_valid())
110+
111+
mock_client = MagicMock()
112+
mock_client.fetch_brp_data_for_bsn.return_value = None
113+
mock_client.version = "2.1"
114+
115+
with patch.object(BRPClient, "from_config", return_value=mock_client):
116+
result = self.check.run(form.cleaned_data)
117+
118+
self.assertFalse(result.success)
119+
self.assertIn("No person found", result.message)
120+
121+
def test_person_found(self):
122+
form = FetchBRPForm(data={"bsn": "123456789"})
123+
self.assertTrue(form.is_valid())
124+
125+
mock_client = MagicMock()
126+
mock_client.fetch_brp_data_for_bsn.return_value = BRP2xPersoon()
127+
mock_client.version = "2.1"
128+
129+
with patch.object(BRPClient, "from_config", return_value=mock_client):
130+
result = self.check.run(form.cleaned_data)
131+
132+
self.assertTrue(result.success)
133+
self.assertIn("BRP data retrieved", result.message)
134+
self.assertEqual(result.extra["version"], "2.1")
135+
136+
137+
class FetchBRPViewTests(TestCase):
138+
def setUp(self):
139+
self.client = Client()
140+
self.superuser = UserFactory(is_superuser=True, is_staff=True)
141+
self.user = UserFactory()
142+
self.config = HaalCentraalConfig.get_solo()
143+
144+
def get_url(self):
145+
return reverse(
146+
"run_config_check",
147+
args=["haalcentraal", "haalcentraalconfig", self.config.pk, "fetch_brp"],
148+
)
149+
150+
def test_permission_denied_for_normal_user(self):
151+
self.client.force_login(self.user)
152+
153+
response = self.client.get(self.get_url())
154+
155+
self.assertEqual(response.status_code, 403)
156+
157+
def test_permission_denied_for_staff_user_with_model_access(self):
158+
staff_user = UserFactory(is_staff=True, is_superuser=False)
159+
staff_user.user_permissions.add(
160+
*Permission.objects.filter(
161+
content_type__app_label="haalcentraal",
162+
content_type__model="haalcentraalconfig",
163+
)
164+
)
165+
self.client.force_login(staff_user)
166+
167+
response = self.client.get(self.get_url())
168+
169+
self.assertEqual(response.status_code, 403)
170+
171+
def test_superuser_can_access(self):
172+
self.client.force_login(self.superuser)
173+
174+
response = self.client.get(self.get_url())
175+
176+
self.assertEqual(response.status_code, 200)
177+
178+
def test_post_runs_check(self):
179+
self.client.force_login(self.superuser)
180+
181+
response = self.client.post(self.get_url(), {"bsn": "123456789"})
182+
183+
self.assertEqual(response.status_code, 200)
184+
self.assertContains(response, "not configured")
185+
186+
@disable_admin_mfa()
187+
def test_config_check_button_on_admin_change_page(self):
188+
self.client.force_login(self.superuser)
189+
190+
response = self.client.get(
191+
reverse(
192+
"admin:haalcentraal_haalcentraalconfig_change", args=[self.config.pk]
193+
)
194+
)
195+
196+
self.assertEqual(response.status_code, 200)
197+
self.assertContains(response, self.get_url())
198+
199+
200+
class FetchBRPStandaloneTests(TestCase):
201+
def setUp(self):
202+
self.client = Client()
203+
self.superuser = UserFactory(is_superuser=True, is_staff=True)
204+
205+
def test_standalone_runs_check(self):
206+
self.client.force_login(self.superuser)
207+
208+
url = reverse("run_config_check_standalone", args=["fetch_brp"])
209+
210+
response = self.client.post(url, {"bsn": "123456789"})
211+
212+
self.assertEqual(response.status_code, 200)
213+
self.assertContains(response, "not configured")

0 commit comments

Comments
 (0)