feat: add HTTP dispatch to AccountManager for generic store services
Services with accounts.manager='http' now use POST/DELETE to the service container's /service-api/accounts endpoint instead of requiring a named Python manager. _resolve_service allows 'http' without a registered Python object; _provision_http and _deprovision_http handle the HTTP calls with 404-as-success on delete. 9 new tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -437,5 +437,95 @@ class TestEdgeCases(unittest.TestCase):
|
||||
self.assertIsNone(self.am.get_credentials('email', 'alice'))
|
||||
|
||||
|
||||
# ── HTTP dispatch (manager == "http") ─────────────────────────────────────────
|
||||
|
||||
class TestHttpDispatch(unittest.TestCase):
|
||||
"""AccountManager with manager='http' uses HTTP POST/DELETE to the service backend."""
|
||||
|
||||
def _make_http_registry(self, backend='cell-myapp:8080'):
|
||||
reg = MagicMock()
|
||||
reg.get.return_value = {
|
||||
'id': 'myapp',
|
||||
'backend': backend,
|
||||
'accounts': {'manager': 'http', 'credentials': ['password']},
|
||||
}
|
||||
return reg
|
||||
|
||||
def setUp(self):
|
||||
import tempfile
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
self.am = _make_am(self.tmp, registry=self._make_http_registry())
|
||||
|
||||
def test_provision_http_posts_to_service_api(self):
|
||||
with patch('account_manager._requests') as mock_req:
|
||||
mock_req.post.return_value = MagicMock(status_code=201)
|
||||
creds = self.am.provision('myapp', 'alice', password='s3cret')
|
||||
mock_req.post.assert_called_once_with(
|
||||
'http://cell-myapp:8080/service-api/accounts',
|
||||
json={'username': 'alice', 'password': 's3cret'},
|
||||
timeout=10,
|
||||
)
|
||||
self.assertEqual(creds['password'], 's3cret')
|
||||
|
||||
def test_provision_http_stores_credentials_on_success(self):
|
||||
with patch('account_manager._requests') as mock_req:
|
||||
mock_req.post.return_value = MagicMock(status_code=200)
|
||||
self.am.provision('myapp', 'alice', password='pw')
|
||||
self.assertEqual(self.am.get_credentials('myapp', 'alice'), {'password': 'pw'})
|
||||
|
||||
def test_provision_http_returns_false_on_non_2xx(self):
|
||||
with patch('account_manager._requests') as mock_req:
|
||||
mock_req.post.return_value = MagicMock(status_code=409, text='conflict')
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.am.provision('myapp', 'alice', password='pw')
|
||||
|
||||
def test_provision_http_raises_on_request_exception(self):
|
||||
with patch('account_manager._requests') as mock_req:
|
||||
mock_req.post.side_effect = Exception('connection refused')
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.am.provision('myapp', 'alice', password='pw')
|
||||
|
||||
def test_deprovision_http_deletes_to_service_api(self):
|
||||
self.am.store_credentials('myapp', 'alice', {'password': 'pw'})
|
||||
with patch('account_manager._requests') as mock_req:
|
||||
mock_req.delete.return_value = MagicMock(status_code=204)
|
||||
ok = self.am.deprovision('myapp', 'alice')
|
||||
mock_req.delete.assert_called_once_with(
|
||||
'http://cell-myapp:8080/service-api/accounts/alice',
|
||||
timeout=10,
|
||||
)
|
||||
self.assertTrue(ok)
|
||||
|
||||
def test_deprovision_http_treats_404_as_success(self):
|
||||
"""404 means already deleted — still a clean deprovision."""
|
||||
self.am.store_credentials('myapp', 'alice', {'password': 'pw'})
|
||||
with patch('account_manager._requests') as mock_req:
|
||||
mock_req.delete.return_value = MagicMock(status_code=404)
|
||||
ok = self.am.deprovision('myapp', 'alice')
|
||||
self.assertTrue(ok)
|
||||
|
||||
def test_deprovision_http_removes_stored_credentials(self):
|
||||
self.am.store_credentials('myapp', 'alice', {'password': 'pw'})
|
||||
with patch('account_manager._requests') as mock_req:
|
||||
mock_req.delete.return_value = MagicMock(status_code=204)
|
||||
self.am.deprovision('myapp', 'alice')
|
||||
self.assertIsNone(self.am.get_credentials('myapp', 'alice'))
|
||||
|
||||
def test_resolve_service_http_does_not_require_python_manager(self):
|
||||
"""manager='http' must not raise even with no named managers passed."""
|
||||
am = AccountManager(
|
||||
service_registry=self._make_http_registry(),
|
||||
data_dir=str(self.tmp),
|
||||
)
|
||||
svc, manager_name, manager = am._resolve_service('myapp')
|
||||
self.assertEqual(manager_name, 'http')
|
||||
self.assertIsNone(manager)
|
||||
|
||||
def test_http_base_url_raises_when_no_backend(self):
|
||||
svc = {'id': 'nobackend', 'backend': ''}
|
||||
with self.assertRaises(ValueError):
|
||||
AccountManager._http_base_url(svc)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user