#!/usr/bin/env python3 """ test_bw_helper.py -- Tests for Bitwarden credential lifecycle. Tests the create-read-update cycle with explicit assertions. The delete operation is intentionally absent -- it does not exist in BitwardenHelper and never should. Run inside the provisioner container: python3 -m pytest test_bw_helper.py -v """ import json import os import sys import pytest sys.path.insert(0, os.path.dirname(__file__)) from bw_helper import BitwardenHelper @pytest.fixture(scope="module") def bw(): """Create a connected BitwardenHelper instance.""" helper = BitwardenHelper( client_id=os.environ["BW_CLIENTID"], client_secret=os.environ["BW_CLIENTSECRET"], password=os.environ["BW_PASSWORD"], server_url=os.environ.get("BW_SERVER", ""), ) helper.login() return helper @pytest.fixture def test_item_name(): """Test prefix to avoid collision with real credentials.""" return "TEST-LIFECYCLE-CREDENTIAL" class TestCredentialLifecycle: """Test the full credential lifecycle: create, read, update, no-delete.""" def test_create_item(self, bw, test_item_name): """Create a credential and verify it exists.""" # Clean up if a previous test left something item_id = bw.get_item_id(test_item_name) if item_id: # Use BW CLI directly -- helper has no delete method by design bw._run_bw(["delete", "item", item_id]) item_id = bw.create_item( name=test_item_name, username="test-user@example.com", password="OriginalPassword123!", uris=["https://test.example.com"], collection_name="test", ) assert item_id, "create_item should return an item ID" assert bw.item_exists(test_item_name), "item should exist after creation" def test_read_password(self, bw, test_item_name): """Read back the password we just created.""" pw = bw.get_item_password(test_item_name) assert pw == "OriginalPassword123!", "password should match what was created" def test_create_duplicate_rejected(self, bw, test_item_name): """create_item must refuse to create a duplicate.""" with pytest.raises(RuntimeError, match="already exists"): bw.create_item( name=test_item_name, username="other@example.com", password="DifferentPassword!", uris=["https://other.example.com"], collection_name="test", ) def test_update_password(self, bw, test_item_name): """Update the password in place -- no new item created.""" original_id = bw.get_item_id(test_item_name) updated_id = bw.update_item( test_item_name, password="UpdatedPassword456@", ) assert updated_id == original_id, "update must preserve the same item ID" pw = bw.get_item_password(test_item_name) assert pw == "UpdatedPassword456@", "password should be updated" def test_update_totp(self, bw, test_item_name): """Add TOTP secret to existing item without creating duplicate.""" original_id = bw.get_item_id(test_item_name) totp_secret = "JBSWY3DPEHPK3PXP" updated_id = bw.update_item(test_item_name, totp_secret=totp_secret) assert updated_id == original_id, "update must preserve the same item ID" item = bw.get_item(test_item_name) assert item["login"]["totp"] == totp_secret, "TOTP should be set" def test_update_preserves_other_fields(self, bw, test_item_name): """Updating one field must not blank out others.""" # Update only password bw.update_item(test_item_name, password="FinalPassword789!") item = bw.get_item(test_item_name) # Username should be unchanged assert item["login"]["username"] == "test-user@example.com", \ "username must be preserved across password update" # Password should be the new one assert item["login"]["password"] == "FinalPassword789!", \ "password should be the updated value" # TOTP should still be there from previous test assert item["login"].get("totp") == "JBSWY3DPEHPK3PXP", \ "TOTP must be preserved across password update" def test_get_item_id_ambiguous_raises(self, bw, test_item_name): """get_item_id must raise if multiple items share a name.""" # This test verifies the safeguard; we can't easily create a duplicate # through the API (create_item blocks it), so we test the logic # by checking it works for a unique name item_id = bw.get_item_id(test_item_name) assert item_id is not None, "should find the test item" def test_item_exists_returns_bool(self, bw, test_item_name): """item_exists returns True for existing, False for missing.""" assert bw.item_exists(test_item_name) is True assert bw.item_exists("NONEXISTENT-ITEM-12345") is False def test_cleanup(self, bw, test_item_name): """Remove the test item using BW CLI directly (test-only).""" item_id = bw.get_item_id(test_item_name) if item_id: bw._run_bw(["delete", "item", item_id]) assert not bw.item_exists(test_item_name), "test item should be cleaned up" class TestNoDeleteMethod: """Verify that BitwardenHelper has no delete capability by design.""" def test_no_delete_item_method(self): """BitwardenHelper must not expose a delete_item method.""" assert not hasattr(BitwardenHelper, "delete_item"), \ "BitwardenHelper must NEVER have a delete_item method. " \ "Credential deletion is a manual operation only."