Skip to content

Commit 89e9796

Browse files
committed
feat: [#2886] add BRP config check
1 parent 6557389 commit 89e9796

4 files changed

Lines changed: 287 additions & 1 deletion

File tree

‎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: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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 pydantic import ValidationError
9+
10+
from maykin_config_checks import GenericConfigCheckResult
11+
from maykin_config_checks.permissions import IsSuperUser
12+
from maykin_config_checks.protocols import InteractiveConfigCheck
13+
from maykin_config_checks.registry import registry
14+
from open_inwoner.haalcentraal.clients import BRPClient
15+
from open_inwoner.haalcentraal.exceptions import BRPAPIError
16+
from open_inwoner.haalcentraal.models import HaalCentraalConfig
17+
from open_inwoner.utils.logentry import system_action, user_action
18+
19+
20+
class FetchBRPForm(forms.Form):
21+
bsn = forms.CharField(max_length=9, label="BSN")
22+
23+
24+
class FetchBRPCheckParams(TypedDict):
25+
bsn: str
26+
27+
28+
class FetchBRPCheck(
29+
InteractiveConfigCheck[
30+
FetchBRPCheckParams,
31+
HaalCentraalConfig,
32+
]
33+
):
34+
identifier = "fetch_brp"
35+
label = _("Fetch BRP data for BSN")
36+
form_class = FetchBRPForm
37+
38+
required_permissions = (IsSuperUser(),)
39+
40+
@classmethod
41+
def get_form_kwargs(
42+
cls,
43+
instance: Optional[HaalCentraalConfig] = None,
44+
) -> dict[str, Any]:
45+
return {}
46+
47+
def run(
48+
self,
49+
data: FetchBRPCheckParams,
50+
instance: Optional[HaalCentraalConfig] = None,
51+
request: Optional[HttpRequest] = None,
52+
) -> GenericConfigCheckResult:
53+
bsn = data["bsn"]
54+
masked_bsn = f"{bsn[:3]}******"
55+
log_message = f"fetch BRP check run for bsn {masked_bsn}"
56+
57+
if request:
58+
user_action(request, request.user, log_message)
59+
else:
60+
system_action(log_message)
61+
62+
if not bsn.isdigit() or len(bsn) != 9:
63+
return GenericConfigCheckResult(
64+
success=False,
65+
identifier=self.identifier,
66+
verbose_name=self.label,
67+
message=_("Invalid BSN format (must be 9 digits)"),
68+
extra={},
69+
)
70+
71+
try:
72+
client = BRPClient.from_config()
73+
except ImproperlyConfigured as exc:
74+
return GenericConfigCheckResult(
75+
success=False,
76+
identifier=self.identifier,
77+
verbose_name=self.label,
78+
message=_("Haal Centraal is not configured"),
79+
extra={"error": str(exc)},
80+
)
81+
82+
try:
83+
persoon = client.fetch_brp_data_for_bsn(bsn)
84+
except ValidationError as exc:
85+
return GenericConfigCheckResult(
86+
success=False,
87+
identifier=self.identifier,
88+
verbose_name=self.label,
89+
message=_("Connected, but the response data failed validation"),
90+
extra={"error": str(exc), "version": client.version},
91+
)
92+
except BRPAPIError as exc:
93+
return GenericConfigCheckResult(
94+
success=False,
95+
identifier=self.identifier,
96+
verbose_name=self.label,
97+
message=_("Failed to connect to the BRP API"),
98+
extra={"error": str(exc), "type": type(exc).__name__},
99+
)
100+
except Exception as exc:
101+
return GenericConfigCheckResult(
102+
success=False,
103+
identifier=self.identifier,
104+
verbose_name=self.label,
105+
message=_("Unexpected error while fetching BRP data"),
106+
extra={"exception": str(exc), "type": type(exc).__name__},
107+
)
108+
109+
if persoon is None:
110+
return GenericConfigCheckResult(
111+
success=False,
112+
identifier=self.identifier,
113+
verbose_name=self.label,
114+
message=_("No person found for this BSN"),
115+
extra={"version": client.version},
116+
)
117+
118+
return GenericConfigCheckResult(
119+
success=True,
120+
identifier=self.identifier,
121+
verbose_name=self.label,
122+
message=_("BRP data retrieved and validated (version %(version)s)")
123+
% {"version": client.version},
124+
extra={"version": client.version},
125+
)
126+
127+
128+
registry.register(FetchBRPCheck)
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
from unittest.mock import MagicMock, patch
2+
3+
from django.test import Client, TestCase
4+
from django.urls import reverse
5+
6+
from pydantic import ValidationError
7+
8+
from open_inwoner.accounts.tests.factories import UserFactory
9+
from open_inwoner.haalcentraal.api_models import BRP2xPersoon
10+
from open_inwoner.haalcentraal.clients import BRPClient
11+
from open_inwoner.haalcentraal.config_checks.fetch_brp import (
12+
FetchBRPCheck,
13+
FetchBRPForm,
14+
)
15+
from open_inwoner.haalcentraal.exceptions import BRPAPINetworkError
16+
from open_inwoner.haalcentraal.models import HaalCentraalConfig
17+
18+
19+
def make_validation_error() -> ValidationError:
20+
try:
21+
BRP2xPersoon.model_validate(
22+
{"verblijfplaats": {"verblijfadres": {"huisnummer": "not-a-number"}}}
23+
)
24+
except ValidationError as exc:
25+
return exc
26+
raise AssertionError("expected a ValidationError") # pragma: no cover
27+
28+
29+
class FetchBRPCheckTests(TestCase):
30+
def setUp(self):
31+
self.check = FetchBRPCheck()
32+
33+
def test_invalid_bsn(self):
34+
form = FetchBRPForm(data={"bsn": "123"})
35+
self.assertTrue(form.is_valid())
36+
37+
result = self.check.run(form.cleaned_data)
38+
39+
self.assertFalse(result.success)
40+
self.assertIn("Ongeldig BSN", result.message)
41+
42+
def test_not_configured(self):
43+
form = FetchBRPForm(data={"bsn": "123456789"})
44+
self.assertTrue(form.is_valid())
45+
46+
result = self.check.run(form.cleaned_data)
47+
48+
self.assertFalse(result.success)
49+
self.assertIn("not configured", result.message)
50+
51+
def test_network_error(self):
52+
form = FetchBRPForm(data={"bsn": "123456789"})
53+
self.assertTrue(form.is_valid())
54+
55+
mock_client = MagicMock()
56+
mock_client.fetch_brp_data_for_bsn.side_effect = BRPAPINetworkError(
57+
"Connection failed"
58+
)
59+
60+
with patch.object(BRPClient, "from_config", return_value=mock_client):
61+
result = self.check.run(form.cleaned_data)
62+
63+
self.assertFalse(result.success)
64+
self.assertIn("Failed to connect", result.message)
65+
66+
def test_validation_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 = make_validation_error()
72+
73+
with patch.object(BRPClient, "from_config", return_value=mock_client):
74+
result = self.check.run(form.cleaned_data)
75+
76+
self.assertFalse(result.success)
77+
self.assertIn("failed validation", result.message)
78+
79+
def test_no_person_found(self):
80+
form = FetchBRPForm(data={"bsn": "123456789"})
81+
self.assertTrue(form.is_valid())
82+
83+
mock_client = MagicMock()
84+
mock_client.fetch_brp_data_for_bsn.return_value = None
85+
mock_client.version = "2.1"
86+
87+
with patch.object(BRPClient, "from_config", return_value=mock_client):
88+
result = self.check.run(form.cleaned_data)
89+
90+
self.assertFalse(result.success)
91+
self.assertIn("No person found", result.message)
92+
93+
def test_person_found(self):
94+
form = FetchBRPForm(data={"bsn": "123456789"})
95+
self.assertTrue(form.is_valid())
96+
97+
mock_client = MagicMock()
98+
mock_client.fetch_brp_data_for_bsn.return_value = BRP2xPersoon()
99+
mock_client.version = "2.1"
100+
101+
with patch.object(BRPClient, "from_config", return_value=mock_client):
102+
result = self.check.run(form.cleaned_data)
103+
104+
self.assertTrue(result.success)
105+
self.assertIn("BRP data retrieved", result.message)
106+
self.assertEqual(result.extra["version"], "2.1")
107+
108+
109+
class FetchBRPViewTests(TestCase):
110+
def setUp(self):
111+
self.client = Client()
112+
self.superuser = UserFactory(is_superuser=True, is_staff=True)
113+
self.user = UserFactory()
114+
self.config = HaalCentraalConfig.get_solo()
115+
116+
def get_url(self):
117+
return reverse(
118+
"run_config_check",
119+
args=["haalcentraal", "haalcentraalconfig", self.config.pk, "fetch_brp"],
120+
)
121+
122+
def test_permission_denied_for_normal_user(self):
123+
self.client.force_login(self.user)
124+
125+
response = self.client.get(self.get_url())
126+
127+
self.assertEqual(response.status_code, 403)
128+
129+
def test_superuser_can_access(self):
130+
self.client.force_login(self.superuser)
131+
132+
response = self.client.get(self.get_url())
133+
134+
self.assertEqual(response.status_code, 200)
135+
136+
def test_post_runs_check(self):
137+
self.client.force_login(self.superuser)
138+
139+
response = self.client.post(self.get_url(), {"bsn": "123456789"})
140+
141+
self.assertEqual(response.status_code, 200)
142+
self.assertContains(response, "not configured")
143+
144+
145+
class FetchBRPStandaloneTests(TestCase):
146+
def setUp(self):
147+
self.client = Client()
148+
self.superuser = UserFactory(is_superuser=True, is_staff=True)
149+
150+
def test_standalone_runs_check(self):
151+
self.client.force_login(self.superuser)
152+
153+
url = reverse("run_config_check_standalone", args=["fetch_brp"])
154+
155+
response = self.client.post(url, {"bsn": "123456789"})
156+
157+
self.assertEqual(response.status_code, 200)
158+
self.assertContains(response, "not configured")

0 commit comments

Comments
 (0)