""" Tests for AccountManager — per-service credential provisioning. Covers: - provision: dispatches to right manager method, stores credentials, generates password - deprovision: calls manager method, removes stored credentials - get_credentials / list_accounts / list_peer_services - deprovision_peer: bulk cleanup on peer deletion - store_credentials: direct storage (used by peers-POST legacy route) - get_all_credentials: returns all creds for a peer - credential file is created with 0o600 - unknown service / missing manager errors """ import json import os import stat import threading import unittest from pathlib import Path from unittest.mock import MagicMock, patch import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'api')) from account_manager import AccountManager # ── helpers ──────────────────────────────────────────────────────────────────── def _make_am(tmp_path: Path, registry=None, **managers) -> AccountManager: if registry is None: registry = _make_registry() return AccountManager(service_registry=registry, data_dir=str(tmp_path), **managers) def _make_registry(services=None): reg = MagicMock() if services is None: services = { 'email': { 'id': 'email', 'kind': 'builtin', 'accounts': {'manager': 'email_manager', 'credentials': ['password']}, 'config': {'domain': 'example.com', 'smtp_port': 25}, }, 'calendar': { 'id': 'calendar', 'kind': 'builtin', 'accounts': {'manager': 'calendar_manager', 'credentials': ['password']}, 'config': {}, }, 'files': { 'id': 'files', 'kind': 'builtin', 'accounts': {'manager': 'file_manager', 'credentials': ['password']}, 'config': {}, }, } reg.get.side_effect = lambda svc_id: services.get(svc_id) return reg def _make_email_mgr(ok=True): m = MagicMock() m.create_email_user.return_value = ok m.delete_email_user.return_value = ok return m def _make_cal_mgr(ok=True): m = MagicMock() m.create_calendar_user.return_value = ok m.delete_calendar_user.return_value = ok return m def _make_file_mgr(ok=True): m = MagicMock() m.create_user.return_value = ok m.delete_user.return_value = ok return m # ── Provision ───────────────────────────────────────────────────────────────── class TestProvision(unittest.TestCase): def setUp(self): import tempfile self.tmp = Path(tempfile.mkdtemp()) self.email_mgr = _make_email_mgr() self.cal_mgr = _make_cal_mgr() self.file_mgr = _make_file_mgr() self.am = _make_am( self.tmp, email_manager=self.email_mgr, calendar_manager=self.cal_mgr, file_manager=self.file_mgr, ) def test_provision_email_calls_create_email_user(self): self.am.provision('email', 'alice', password='s3cret') self.email_mgr.create_email_user.assert_called_once_with('alice', 'example.com', 's3cret') def test_provision_calendar_calls_create_calendar_user(self): self.am.provision('calendar', 'alice', password='s3cret') self.cal_mgr.create_calendar_user.assert_called_once_with('alice', 's3cret') def test_provision_files_calls_create_user(self): self.am.provision('files', 'alice', password='s3cret') self.file_mgr.create_user.assert_called_once_with('alice', 's3cret') def test_provision_generates_password_when_none_given(self): creds = self.am.provision('email', 'alice') self.assertIn('password', creds) self.assertTrue(len(creds['password']) >= 16) def test_provision_returns_credential_dict(self): creds = self.am.provision('email', 'alice', password='mypassword') self.assertEqual(creds, {'password': 'mypassword'}) def test_provision_stores_credentials(self): self.am.provision('email', 'alice', password='pw') stored = self.am.get_credentials('email', 'alice') self.assertEqual(stored, {'password': 'pw'}) def test_provision_multiple_peers_stored_independently(self): self.am.provision('email', 'alice', password='pw-alice') self.am.provision('email', 'bob', password='pw-bob') self.assertEqual(self.am.get_credentials('email', 'alice'), {'password': 'pw-alice'}) self.assertEqual(self.am.get_credentials('email', 'bob'), {'password': 'pw-bob'}) def test_provision_raises_for_unknown_service(self): with self.assertRaises(ValueError): self.am.provision('doesnotexist', 'alice') def test_provision_raises_when_service_has_no_accounts(self): reg = _make_registry({'nosvc': {'id': 'nosvc', 'accounts': {}, 'config': {}}}) am = _make_am(self.tmp, registry=reg, email_manager=self.email_mgr) with self.assertRaises(ValueError): am.provision('nosvc', 'alice') def test_provision_raises_when_manager_not_registered(self): am = _make_am(self.tmp) # no managers passed with self.assertRaises(ValueError): am.provision('email', 'alice') def test_provision_raises_runtime_error_when_manager_returns_false(self): am = _make_am(self.tmp, email_manager=_make_email_mgr(ok=False)) with self.assertRaises(RuntimeError): am.provision('email', 'alice') def test_provision_email_raises_when_domain_not_configured(self): reg = _make_registry({'email': { 'id': 'email', 'accounts': {'manager': 'email_manager'}, 'config': {'domain': ''}, }}) am = _make_am(self.tmp, registry=reg, email_manager=self.email_mgr) with self.assertRaises(ValueError): am.provision('email', 'alice') # ── Credential file permissions ─────────────────────────────────────────────── class TestCredentialFilePermissions(unittest.TestCase): def setUp(self): import tempfile self.tmp = Path(tempfile.mkdtemp()) self.am = _make_am(self.tmp, email_manager=_make_email_mgr()) def test_credentials_file_created_with_0600(self): self.am.provision('email', 'alice', password='pw') creds_path = self.tmp / 'peer_service_credentials.json' mode = stat.S_IMODE(creds_path.stat().st_mode) self.assertEqual(mode, 0o600, f'Expected 0o600, got {oct(mode)}') # ── Deprovision ─────────────────────────────────────────────────────────────── class TestDeprovision(unittest.TestCase): def setUp(self): import tempfile self.tmp = Path(tempfile.mkdtemp()) self.email_mgr = _make_email_mgr() self.cal_mgr = _make_cal_mgr() self.file_mgr = _make_file_mgr() self.am = _make_am( self.tmp, email_manager=self.email_mgr, calendar_manager=self.cal_mgr, file_manager=self.file_mgr, ) self.am.provision('email', 'alice', password='pw') def test_deprovision_email_calls_delete_email_user(self): self.am.deprovision('email', 'alice') self.email_mgr.delete_email_user.assert_called_once_with('alice', 'example.com') def test_deprovision_removes_stored_credentials(self): self.am.deprovision('email', 'alice') self.assertIsNone(self.am.get_credentials('email', 'alice')) def test_deprovision_returns_true_on_success(self): ok = self.am.deprovision('email', 'alice') self.assertTrue(ok) def test_deprovision_raises_for_unknown_service(self): with self.assertRaises(ValueError): self.am.deprovision('ghost', 'alice') def test_deprovision_removes_service_entry_when_last_peer_gone(self): self.am.deprovision('email', 'alice') creds_file = self.tmp / 'peer_service_credentials.json' data = json.loads(creds_file.read_text()) self.assertNotIn('email', data) def test_deprovision_calendar_calls_delete_calendar_user(self): self.am.provision('calendar', 'alice', password='pw') self.am.deprovision('calendar', 'alice') self.cal_mgr.delete_calendar_user.assert_called_once_with('alice') def test_deprovision_files_calls_delete_user(self): self.am.provision('files', 'alice', password='pw') self.am.deprovision('files', 'alice') self.file_mgr.delete_user.assert_called_once_with('alice') # ── Queries ─────────────────────────────────────────────────────────────────── class TestQueries(unittest.TestCase): def setUp(self): import tempfile self.tmp = Path(tempfile.mkdtemp()) self.am = _make_am( self.tmp, email_manager=_make_email_mgr(), calendar_manager=_make_cal_mgr(), file_manager=_make_file_mgr(), ) self.am.provision('email', 'alice', password='pw-alice-email') self.am.provision('email', 'bob', password='pw-bob-email') self.am.provision('calendar', 'alice', password='pw-alice-cal') def test_get_credentials_returns_stored(self): self.assertEqual(self.am.get_credentials('email', 'alice'), {'password': 'pw-alice-email'}) def test_get_credentials_returns_none_for_unknown_peer(self): self.assertIsNone(self.am.get_credentials('email', 'nobody')) def test_get_credentials_returns_none_for_unknown_service(self): self.assertIsNone(self.am.get_credentials('ghost', 'alice')) def test_list_accounts_returns_provisioned_peers(self): accounts = self.am.list_accounts('email') self.assertIn('alice', accounts) self.assertIn('bob', accounts) def test_list_accounts_empty_for_unprovisioned_service(self): self.assertEqual(self.am.list_accounts('files'), []) def test_list_peer_services_returns_all_services_for_peer(self): services = self.am.list_peer_services('alice') self.assertIn('email', services) self.assertIn('calendar', services) def test_list_peer_services_returns_empty_for_unknown_peer(self): self.assertEqual(self.am.list_peer_services('nobody'), []) def test_is_provisioned_true_when_account_exists(self): self.assertTrue(self.am.is_provisioned('email', 'alice')) def test_is_provisioned_false_when_no_account(self): self.assertFalse(self.am.is_provisioned('email', 'nobody')) def test_get_all_credentials_returns_all_services(self): all_creds = self.am.get_all_credentials('alice') self.assertIn('email', all_creds) self.assertIn('calendar', all_creds) self.assertEqual(all_creds['email'], {'password': 'pw-alice-email'}) def test_get_all_credentials_empty_for_unknown_peer(self): self.assertEqual(self.am.get_all_credentials('nobody'), {}) # ── Bulk deprovision ────────────────────────────────────────────────────────── class TestDeprovisionPeer(unittest.TestCase): def setUp(self): import tempfile self.tmp = Path(tempfile.mkdtemp()) self.email_mgr = _make_email_mgr() self.cal_mgr = _make_cal_mgr() self.am = _make_am( self.tmp, email_manager=self.email_mgr, calendar_manager=self.cal_mgr, file_manager=_make_file_mgr(), ) self.am.provision('email', 'alice', password='pw') self.am.provision('calendar', 'alice', password='pw') def test_deprovision_peer_removes_from_all_services(self): self.am.deprovision_peer('alice') self.assertIsNone(self.am.get_credentials('email', 'alice')) self.assertIsNone(self.am.get_credentials('calendar', 'alice')) def test_deprovision_peer_returns_results_dict(self): results = self.am.deprovision_peer('alice') self.assertIn('email', results) self.assertIn('calendar', results) self.assertTrue(results['email']) self.assertTrue(results['calendar']) def test_deprovision_peer_continues_after_one_service_fails(self): self.email_mgr.delete_email_user.side_effect = RuntimeError('smtp down') results = self.am.deprovision_peer('alice') self.assertFalse(results.get('email')) # calendar should still succeed even though email failed self.assertTrue(results.get('calendar')) def test_deprovision_peer_no_op_for_unknown_peer(self): results = self.am.deprovision_peer('nobody') self.assertEqual(results, {}) # ── Direct credential storage ───────────────────────────────────────────────── class TestStoreCredentials(unittest.TestCase): def setUp(self): import tempfile self.tmp = Path(tempfile.mkdtemp()) self.am = _make_am(self.tmp) def test_store_credentials_makes_them_retrievable(self): self.am.store_credentials('email', 'alice', {'password': 'mypassword'}) self.assertEqual(self.am.get_credentials('email', 'alice'), {'password': 'mypassword'}) def test_store_credentials_overwrites_existing(self): self.am.store_credentials('email', 'alice', {'password': 'old'}) self.am.store_credentials('email', 'alice', {'password': 'new'}) self.assertEqual(self.am.get_credentials('email', 'alice'), {'password': 'new'}) def test_store_credentials_creates_file_with_0600(self): self.am.store_credentials('email', 'alice', {'password': 'pw'}) creds_path = self.tmp / 'peer_service_credentials.json' mode = stat.S_IMODE(creds_path.stat().st_mode) self.assertEqual(mode, 0o600) # ── Thread safety ───────────────────────────────────────────────────────────── class TestThreadSafety(unittest.TestCase): def setUp(self): import tempfile self.tmp = Path(tempfile.mkdtemp()) self.am = _make_am(self.tmp) def test_concurrent_store_credentials_no_data_loss(self): errors = [] def worker(peer_name): try: self.am.store_credentials('email', peer_name, {'password': f'pw-{peer_name}'}) except Exception as e: errors.append(e) threads = [threading.Thread(target=worker, args=(f'peer{i}',)) for i in range(20)] for t in threads: t.start() for t in threads: t.join() self.assertEqual(errors, []) accounts = self.am.list_accounts('email') self.assertEqual(len(accounts), 20) class TestEdgeCases(unittest.TestCase): def setUp(self): import tempfile self.tmp = Path(tempfile.mkdtemp()) self.email_mgr = _make_email_mgr() self.am = _make_am(self.tmp, email_manager=self.email_mgr, calendar_manager=_make_cal_mgr(), file_manager=_make_file_mgr()) def test_deprovision_peer_never_provisioned_returns_empty(self): self.assertEqual(self.am.deprovision_peer('ghost'), {}) def test_deprovision_clears_credentials_even_when_manager_returns_false(self): """Credentials are removed even if underlying manager reports failure.""" self.am.provision('email', 'alice', password='pw') self.email_mgr.delete_email_user.return_value = False self.am.deprovision('email', 'alice') self.assertIsNone(self.am.get_credentials('email', 'alice')) def test_provision_twice_overwrites_credentials(self): self.am.provision('email', 'alice', password='first') self.am.provision('email', 'alice', password='second') self.assertEqual(self.am.get_credentials('email', 'alice'), {'password': 'second'}) def test_provision_twice_calls_manager_both_times(self): self.am.provision('email', 'alice', password='first') self.am.provision('email', 'alice', password='second') self.assertEqual(self.email_mgr.create_email_user.call_count, 2) def test_corrupted_credentials_file_returns_empty_and_continues(self): """A corrupted JSON file is treated as empty rather than crashing.""" creds_path = self.tmp / 'peer_service_credentials.json' creds_path.write_text('{invalid json}') result = self.am.get_all_credentials('alice') self.assertEqual(result, {}) def test_file_permissions_preserved_on_second_write(self): """0o600 must hold even after overwriting with a second provision.""" self.am.provision('email', 'alice', password='first') self.am.provision('email', 'bob', password='second') creds_path = self.tmp / 'peer_service_credentials.json' mode = stat.S_IMODE(creds_path.stat().st_mode) self.assertEqual(mode, 0o600, f'Expected 0o600 after overwrite, got {oct(mode)}') def test_generated_password_is_url_safe(self): """token_urlsafe must not produce + or / characters.""" creds = self.am.provision('email', 'alice') pwd = creds['password'] self.assertNotIn('+', pwd) self.assertNotIn('/', pwd) def test_store_then_deprovision_removes_credentials(self): """store_credentials + deprovision should cleanly remove the entry.""" self.am.store_credentials('email', 'alice', {'password': 'stored'}) self.am.deprovision('email', 'alice') 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()