Surface redacted directory bind failures before native onboarding
All checks were successful
All checks were successful
Map uncaught HTTPError from LLDAP login to a structured dependency_unavailable response, add /readyz as the provisioner-to-directory preflight, keep /healthz as process liveness, and run the contract in CI. Auth rejection is not retried during cooldown. NK-WP-0036-T05 remains in progress until the immutable image is published, pinned with /readyz, and one native login/create/password-setup journey is verified. Assistant: grok Assistant-Session: 01a09dc6-3f0e-78f1-a884-c8c703c24ddf
This commit is contained in:
parent
d90e3b27f2
commit
c8e07615c3
9 changed files with 554 additions and 33 deletions
|
|
@ -4,15 +4,16 @@ from __future__ import annotations
|
|||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
from urllib.request import urlopen
|
||||
|
||||
from provisioner import directory_login
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -116,16 +117,12 @@ class LLDAPPasswordSetter:
|
|||
self.runner = runner
|
||||
|
||||
def __call__(self, subject: str, password: str) -> None:
|
||||
request = Request(
|
||||
self.base_url + "/auth/simple/login",
|
||||
data=json.dumps(
|
||||
{"username": "admin", "password": self.admin_password}
|
||||
).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
token = directory_login(
|
||||
base_url=self.base_url,
|
||||
admin_password=self.admin_password,
|
||||
opener=self.opener,
|
||||
timeout=10,
|
||||
)
|
||||
with self.opener(request, timeout=10) as response:
|
||||
token = str(json.loads(response.read())["token"])
|
||||
env = dict(os.environ)
|
||||
env["LLDAP_USER_PASSWORD"] = password
|
||||
result = self.runner(
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ from __future__ import annotations
|
|||
from dataclasses import dataclass
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
|
|
@ -27,11 +28,38 @@ class DriftResult:
|
|||
changed: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class DependencyFailure(RuntimeError):
|
||||
"""Directory dependency failed. Messages never carry credentials or upstream bodies."""
|
||||
|
||||
def __init__(self, code: str, *, dependency: str = "directory") -> None:
|
||||
self.code = code
|
||||
self.dependency = dependency
|
||||
super().__init__("dependency_unavailable")
|
||||
|
||||
def payload(self) -> dict[str, str]:
|
||||
return {
|
||||
"error": "dependency_unavailable",
|
||||
"dependency": self.dependency,
|
||||
"reason": self.code,
|
||||
}
|
||||
|
||||
|
||||
class LLDAPProvisioner:
|
||||
def __init__(self, *, base_url: str, admin_password: str, opener: Callable = urlopen) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
admin_password: str,
|
||||
opener: Callable = urlopen,
|
||||
auth_rejected_cooldown: float = 30.0,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.admin_password = admin_password
|
||||
self.opener = opener
|
||||
self.auth_rejected_cooldown = auth_rejected_cooldown
|
||||
self.clock = clock
|
||||
self._auth_rejected_until = 0.0
|
||||
|
||||
def provision(self, payload: dict[str, Any]) -> Result:
|
||||
_required(payload, "user_id", "tenant", "primary_email", "idempotency_key", "correlation_id")
|
||||
|
|
@ -177,15 +205,27 @@ mutation Remove($userId: String!, $groupId: Int!) {
|
|||
status = "reconciled" if not remaining else "drifted"
|
||||
return DriftResult("netkingdom-lldap", subject, status, tuple(remaining), tuple(changed))
|
||||
|
||||
def _login(self) -> str:
|
||||
request = Request(
|
||||
self.base_url + "/auth/simple/login",
|
||||
data=json.dumps({"username": "admin", "password": self.admin_password}).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
def preflight(self) -> dict[str, str]:
|
||||
"""One login plus one directory read. Auth rejection is not retried during cooldown."""
|
||||
now = self.clock()
|
||||
if now < self._auth_rejected_until:
|
||||
raise DependencyFailure("auth_rejected")
|
||||
try:
|
||||
token = self._login(timeout=3)
|
||||
self._gql(token, "query { groups { id } }", {}, timeout=3)
|
||||
except DependencyFailure as exc:
|
||||
if exc.code == "auth_rejected":
|
||||
self._auth_rejected_until = now + self.auth_rejected_cooldown
|
||||
raise
|
||||
return {"status": "ready", "dependency": "directory"}
|
||||
|
||||
def _login(self, timeout: float = 10) -> str:
|
||||
return directory_login(
|
||||
base_url=self.base_url,
|
||||
admin_password=self.admin_password,
|
||||
opener=self.opener,
|
||||
timeout=timeout,
|
||||
)
|
||||
with self.opener(request, timeout=10) as response:
|
||||
return str(json.loads(response.read())["token"])
|
||||
|
||||
def _directory(self, token: str) -> tuple[list[dict], list[dict]]:
|
||||
value = self._gql(token, "query { users { id email displayName } groups { id displayName } }", {})
|
||||
|
|
@ -264,20 +304,74 @@ mutation Remove($userId: String!, $groupId: Int!) {
|
|||
drift.append("status:mismatch")
|
||||
return drift
|
||||
|
||||
def _gql(self, token: str, query: str, variables: dict[str, Any]) -> dict:
|
||||
def _gql(self, token: str, query: str, variables: dict[str, Any], timeout: float = 15) -> dict:
|
||||
request = Request(
|
||||
self.base_url + "/api/graphql",
|
||||
data=json.dumps({"query": query, "variables": variables}).encode(),
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with self.opener(request, timeout=15) as response:
|
||||
payload = json.loads(response.read())
|
||||
try:
|
||||
with self.opener(request, timeout=timeout) as response:
|
||||
payload = json.loads(response.read())
|
||||
except HTTPError as exc:
|
||||
status = int(getattr(exc, "code", 0) or 0)
|
||||
_discard(exc)
|
||||
if status in {401, 403}:
|
||||
raise DependencyFailure("auth_rejected") from None
|
||||
raise DependencyFailure("protocol_error") from None
|
||||
except (TimeoutError, URLError, OSError):
|
||||
raise DependencyFailure("unreachable") from None
|
||||
except (json.JSONDecodeError, TypeError, ValueError, UnicodeDecodeError):
|
||||
raise DependencyFailure("protocol_error") from None
|
||||
if payload.get("errors"):
|
||||
raise ValueError(str(payload["errors"][0].get("message", "LLDAP GraphQL error")))
|
||||
return dict(payload.get("data") or {})
|
||||
|
||||
|
||||
def directory_login(
|
||||
*,
|
||||
base_url: str,
|
||||
admin_password: str,
|
||||
opener: Callable = urlopen,
|
||||
timeout: float = 10,
|
||||
) -> str:
|
||||
request = Request(
|
||||
base_url.rstrip("/") + "/auth/simple/login",
|
||||
data=json.dumps({"username": "admin", "password": admin_password}).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with opener(request, timeout=timeout) as response:
|
||||
payload = json.loads(response.read())
|
||||
except HTTPError as exc:
|
||||
status = int(getattr(exc, "code", 0) or 0)
|
||||
_discard(exc)
|
||||
if status in {401, 403}:
|
||||
raise DependencyFailure("auth_rejected") from None
|
||||
raise DependencyFailure("protocol_error") from None
|
||||
except (TimeoutError, URLError, OSError):
|
||||
raise DependencyFailure("unreachable") from None
|
||||
except (json.JSONDecodeError, TypeError, ValueError, UnicodeDecodeError):
|
||||
raise DependencyFailure("protocol_error") from None
|
||||
if not isinstance(payload, dict):
|
||||
raise DependencyFailure("protocol_error")
|
||||
token = str(payload.get("token") or "")
|
||||
if not token:
|
||||
raise DependencyFailure("protocol_error")
|
||||
return token
|
||||
|
||||
|
||||
def _discard(exc: BaseException) -> None:
|
||||
read = getattr(exc, "read", None)
|
||||
if callable(read):
|
||||
try:
|
||||
read(65536)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def dispatch(
|
||||
provisioner: LLDAPProvisioner, path: str, payload: dict[str, Any]
|
||||
) -> Result | DriftResult:
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import os
|
|||
from urllib.parse import parse_qs, urlsplit
|
||||
import secrets
|
||||
|
||||
from provisioner import LLDAPProvisioner, _directory_username, dispatch
|
||||
from provisioner import DependencyFailure, LLDAPProvisioner, _directory_username, dispatch
|
||||
from password_setup import LLDAPPasswordSetter, PasswordSetupGrants
|
||||
|
||||
|
||||
|
|
@ -19,6 +19,17 @@ class Handler(BaseHTTPRequestHandler):
|
|||
path = urlsplit(self.path)
|
||||
if path.path == "/healthz":
|
||||
return self._send(200, {"status": "ok"})
|
||||
if path.path == "/readyz":
|
||||
try:
|
||||
return self._send(200, self.provisioner.preflight())
|
||||
except DependencyFailure as exc:
|
||||
return self._send(503, exc.payload())
|
||||
except RuntimeError:
|
||||
return self._send(503, {
|
||||
"error": "dependency_unavailable",
|
||||
"dependency": "directory",
|
||||
"reason": "unreachable",
|
||||
})
|
||||
if path.path == "/setup/password":
|
||||
token = parse_qs(path.query).get("token", [""])[0]
|
||||
if not self.password_setups.valid(token):
|
||||
|
|
@ -41,6 +52,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||
return self._send(404, {"error": "not_found"})
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
return self._send(400, {"error": "invalid_request", "message": str(exc)})
|
||||
except DependencyFailure as exc:
|
||||
return self._send(503, exc.payload())
|
||||
except RuntimeError:
|
||||
return self._send(503, {"error": "dependency_unavailable"})
|
||||
response = asdict(result)
|
||||
|
|
@ -64,7 +77,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||
return_to = self.password_setups.consume(token, password)
|
||||
except ValueError as exc:
|
||||
return self._html(400, _setup_page(token, str(exc)))
|
||||
except RuntimeError:
|
||||
except (DependencyFailure, RuntimeError):
|
||||
return self._html(503, _failed_page())
|
||||
return self._html(200, _complete_page(return_to))
|
||||
|
||||
|
|
|
|||
336
identity-provisioner/tests/test_directory_contract.py
Normal file
336
identity-provisioner/tests/test_directory_contract.py
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
"""Contained provisioner-to-directory contract: login, preflight, redacted failure."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).parents[1]))
|
||||
from password_setup import LLDAPPasswordSetter, PasswordSetupGrants
|
||||
from provisioner import DependencyFailure, LLDAPProvisioner, directory_login
|
||||
from server import Handler
|
||||
|
||||
|
||||
SECRET = "synthetic-only-directory-password"
|
||||
TOKEN = "synthetic-directory-token"
|
||||
|
||||
|
||||
class DirectoryState:
|
||||
def __init__(self) -> None:
|
||||
self.password = SECRET
|
||||
self.logins = 0
|
||||
self.graphql = 0
|
||||
self.users: dict[str, dict] = {}
|
||||
self.groups: dict[str, int] = {}
|
||||
self.next_group = 1
|
||||
self.login_status = 200
|
||||
self.login_body: dict | bytes = {"token": TOKEN}
|
||||
|
||||
|
||||
class FakeDirectory(BaseHTTPRequestHandler):
|
||||
state: DirectoryState
|
||||
|
||||
def do_POST(self):
|
||||
raw = self.rfile.read(int(self.headers.get("Content-Length", "0")))
|
||||
if self.path == "/auth/simple/login":
|
||||
self.state.logins += 1
|
||||
payload = json.loads(raw)
|
||||
if self.state.login_status != 200:
|
||||
body = self.state.login_body
|
||||
if isinstance(body, dict):
|
||||
body = json.dumps(body).encode()
|
||||
self._raw(self.state.login_status, body)
|
||||
return
|
||||
if payload.get("username") != "admin" or payload.get("password") != self.state.password:
|
||||
leaked = json.dumps({"message": "invalid", "password": payload.get("password")}).encode()
|
||||
self._raw(401, leaked)
|
||||
return
|
||||
self._send(200, {"token": TOKEN})
|
||||
return
|
||||
if self.path == "/api/graphql":
|
||||
self.state.graphql += 1
|
||||
if self.headers.get("Authorization") != f"Bearer {TOKEN}":
|
||||
self._send(401, {"message": "unauthorized"})
|
||||
return
|
||||
self._send(200, self._graphql(json.loads(raw)))
|
||||
return
|
||||
self._send(404, {"error": "not_found"})
|
||||
|
||||
def _graphql(self, body: dict) -> dict:
|
||||
query = str(body.get("query") or "")
|
||||
variables = body.get("variables") or {}
|
||||
if "createUser" in query:
|
||||
user_id = str(variables["id"])
|
||||
self.state.users[user_id] = {
|
||||
"id": user_id,
|
||||
"email": variables["email"],
|
||||
"displayName": variables["display"],
|
||||
"groups": [],
|
||||
}
|
||||
return {"data": {"createUser": {"id": user_id}}}
|
||||
if "createGroup" in query:
|
||||
name = str(variables["name"])
|
||||
group_id = self.state.next_group
|
||||
self.state.next_group += 1
|
||||
self.state.groups[name] = group_id
|
||||
return {"data": {"createGroup": {"id": group_id, "displayName": name}}}
|
||||
if "addUserToGroup" in query:
|
||||
user = self.state.users[str(variables["userId"])]
|
||||
group_id = int(variables["groupId"])
|
||||
name = next(n for n, i in self.state.groups.items() if i == group_id)
|
||||
user["groups"].append({"id": group_id, "displayName": name})
|
||||
return {"data": {"addUserToGroup": {"ok": True}}}
|
||||
if "users {" in query and "groups {" in query:
|
||||
return {
|
||||
"data": {
|
||||
"users": [
|
||||
{"id": user["id"], "email": user["email"], "displayName": user["displayName"]}
|
||||
for user in self.state.users.values()
|
||||
],
|
||||
"groups": [
|
||||
{"id": group_id, "displayName": name}
|
||||
for name, group_id in self.state.groups.items()
|
||||
],
|
||||
}
|
||||
}
|
||||
if "groups { id }" in query:
|
||||
return {"data": {"groups": [{"id": group_id} for group_id in self.state.groups.values()]}}
|
||||
return {"data": {}}
|
||||
|
||||
def _send(self, status: int, payload: dict) -> None:
|
||||
self._raw(status, json.dumps(payload).encode())
|
||||
|
||||
def _raw(self, status: int, body: bytes) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
return
|
||||
|
||||
|
||||
def serve(handler):
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
return server, thread, f"http://127.0.0.1:{server.server_port}"
|
||||
|
||||
|
||||
class DirectoryContractTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.state = DirectoryState()
|
||||
|
||||
class Bound(FakeDirectory):
|
||||
state = self.state
|
||||
|
||||
self.directory, self.directory_thread, self.directory_url = serve(Bound)
|
||||
self.provisioner = LLDAPProvisioner(
|
||||
base_url=self.directory_url,
|
||||
admin_password=SECRET,
|
||||
auth_rejected_cooldown=60,
|
||||
clock=lambda: self.now,
|
||||
)
|
||||
self.now = 0.0
|
||||
|
||||
class App(Handler):
|
||||
service_token = "fixture-only-service-token"
|
||||
provisioner = self.provisioner
|
||||
password_setups = PasswordSetupGrants(
|
||||
public_url="https://kc.example/setup/password",
|
||||
setter=LLDAPPasswordSetter(
|
||||
base_url=self.directory_url,
|
||||
admin_password=SECRET,
|
||||
helper="/bin/true",
|
||||
runner=lambda *args, **kwargs: type("R", (), {"returncode": 0})(),
|
||||
),
|
||||
)
|
||||
|
||||
self.app, self.app_thread, self.app_url = serve(App)
|
||||
|
||||
def tearDown(self):
|
||||
for server, thread in (
|
||||
(self.app, self.app_thread),
|
||||
(self.directory, self.directory_thread),
|
||||
):
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
|
||||
def get(self, path: str):
|
||||
try:
|
||||
response = urlopen(self.app_url + path)
|
||||
except HTTPError as error:
|
||||
response = error
|
||||
with response:
|
||||
body = response.read().decode()
|
||||
return response.status, json.loads(body) if body.startswith("{") else body
|
||||
|
||||
def provision(self):
|
||||
payload = {
|
||||
"user_id": "person-1",
|
||||
"tenant": "tenant:trial:demo",
|
||||
"primary_email": "person@example.test",
|
||||
"display_name": "Person",
|
||||
"idempotency_key": "fixture-long-idempotency",
|
||||
"correlation_id": "corr-fixture",
|
||||
"roles": ["tenant-admin"],
|
||||
}
|
||||
request = Request(
|
||||
self.app_url + "/v1/identities/provision",
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Authorization": "Bearer fixture-only-service-token"},
|
||||
)
|
||||
try:
|
||||
response = urlopen(request)
|
||||
except HTTPError as error:
|
||||
response = error
|
||||
with response:
|
||||
return response.status, json.loads(response.read())
|
||||
|
||||
def test_preflight_covers_login_and_directory_read(self):
|
||||
self.assertEqual(
|
||||
{"status": "ready", "dependency": "directory"},
|
||||
self.provisioner.preflight(),
|
||||
)
|
||||
self.assertEqual(1, self.state.logins)
|
||||
self.assertEqual(1, self.state.graphql)
|
||||
|
||||
def test_healthz_stays_ok_when_directory_rejects_bind(self):
|
||||
self.state.login_status = 401
|
||||
self.state.login_body = {"message": "invalid", "password": SECRET}
|
||||
live_status, live = self.get("/healthz")
|
||||
ready_status, ready = self.get("/readyz")
|
||||
self.assertEqual(200, live_status)
|
||||
self.assertEqual({"status": "ok"}, live)
|
||||
self.assertEqual(503, ready_status)
|
||||
self.assertEqual(
|
||||
{
|
||||
"error": "dependency_unavailable",
|
||||
"dependency": "directory",
|
||||
"reason": "auth_rejected",
|
||||
},
|
||||
ready,
|
||||
)
|
||||
self.assertNotIn(SECRET, json.dumps(ready))
|
||||
|
||||
def test_rejected_bind_is_not_retried_during_cooldown(self):
|
||||
self.state.login_status = 401
|
||||
with self.assertRaises(DependencyFailure) as failure:
|
||||
self.provisioner.preflight()
|
||||
self.assertEqual("auth_rejected", failure.exception.code)
|
||||
self.assertEqual(1, self.state.logins)
|
||||
self.now = 30.0
|
||||
with self.assertRaises(DependencyFailure):
|
||||
self.provisioner.preflight()
|
||||
self.assertEqual(1, self.state.logins)
|
||||
self.now = 60.0
|
||||
with self.assertRaises(DependencyFailure):
|
||||
self.provisioner.preflight()
|
||||
self.assertEqual(2, self.state.logins)
|
||||
|
||||
def test_provision_returns_redacted_structured_failure_not_http_error(self):
|
||||
self.state.login_status = 401
|
||||
self.state.login_body = {"message": "invalid", "password": SECRET}
|
||||
status, payload = self.provision()
|
||||
self.assertEqual(503, status)
|
||||
self.assertEqual("dependency_unavailable", payload["error"])
|
||||
self.assertEqual("directory", payload["dependency"])
|
||||
self.assertEqual("auth_rejected", payload["reason"])
|
||||
serialized = json.dumps(payload)
|
||||
self.assertNotIn(SECRET, serialized)
|
||||
self.assertNotIn("HTTPError", serialized)
|
||||
self.assertNotIn("password", serialized)
|
||||
|
||||
def test_native_provision_and_password_setup_journey(self):
|
||||
status, payload = self.provision()
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual("password_setup_required", payload["status"])
|
||||
self.assertIn("uid=person,ou=people,dc=netkingdom,dc=local", payload["external_subject"])
|
||||
self.assertIn("person", self.state.users)
|
||||
self.assertIn("tenant:trial:demo:users", self.state.groups)
|
||||
self.assertIn("tenant:trial:demo:admins", self.state.groups)
|
||||
token = payload["password_setup_url"].partition("token=")[2]
|
||||
request = Request(
|
||||
self.app_url + "/setup/password",
|
||||
data=urlencode({
|
||||
"token": token,
|
||||
"password": "recipient-long-password",
|
||||
"confirmation": "recipient-long-password",
|
||||
}).encode(),
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
with urlopen(request) as response:
|
||||
body = response.read().decode()
|
||||
self.assertEqual(200, response.status)
|
||||
self.assertIn("Password set", body)
|
||||
self.assertNotIn(SECRET, body)
|
||||
self.assertNotIn("recipient-long-password", body)
|
||||
self.assertNotIn(token, body)
|
||||
|
||||
|
||||
class DirectoryLoginMappingTests(unittest.TestCase):
|
||||
def test_http_401_becomes_auth_rejected_without_body_or_cause(self):
|
||||
def opener(request, timeout=10):
|
||||
raise HTTPError(
|
||||
request.full_url,
|
||||
401,
|
||||
"Unauthorized",
|
||||
hdrs=None,
|
||||
fp=io.BytesIO(json.dumps({"password": SECRET, "token": "leaked"}).encode()),
|
||||
)
|
||||
|
||||
with self.assertRaises(DependencyFailure) as failure:
|
||||
directory_login(base_url="http://directory", admin_password=SECRET, opener=opener)
|
||||
self.assertEqual("auth_rejected", failure.exception.code)
|
||||
self.assertIsNone(failure.exception.__cause__)
|
||||
self.assertNotIn(SECRET, str(failure.exception))
|
||||
self.assertNotIn("leaked", str(failure.exception.payload()))
|
||||
self.assertEqual(
|
||||
{
|
||||
"error": "dependency_unavailable",
|
||||
"dependency": "directory",
|
||||
"reason": "auth_rejected",
|
||||
},
|
||||
failure.exception.payload(),
|
||||
)
|
||||
|
||||
def test_unreachable_directory_is_structured_and_not_retried(self):
|
||||
calls = []
|
||||
|
||||
def opener(request, timeout=10):
|
||||
calls.append(timeout)
|
||||
raise URLError("connection refused")
|
||||
|
||||
with self.assertRaises(DependencyFailure) as failure:
|
||||
directory_login(base_url="http://directory", admin_password=SECRET, opener=opener)
|
||||
self.assertEqual("unreachable", failure.exception.code)
|
||||
self.assertEqual([10], calls)
|
||||
|
||||
def test_password_setter_rejects_bind_once(self):
|
||||
calls = []
|
||||
|
||||
def opener(request, timeout=10):
|
||||
calls.append(json.loads(request.data)["password"])
|
||||
raise HTTPError(request.full_url, 401, "Unauthorized", hdrs=None, fp=io.BytesIO(b"{}"))
|
||||
|
||||
setter = LLDAPPasswordSetter(
|
||||
base_url="http://directory",
|
||||
admin_password=SECRET,
|
||||
opener=opener,
|
||||
runner=lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("helper must not run")),
|
||||
)
|
||||
with self.assertRaises(DependencyFailure) as failure:
|
||||
setter("person", "recipient-long-password")
|
||||
self.assertEqual("auth_rejected", failure.exception.code)
|
||||
self.assertEqual([SECRET], calls)
|
||||
self.assertNotIn(SECRET, str(failure.exception))
|
||||
Loading…
Add table
Add a link
Reference in a new issue