Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

tests: add unit tests for vaults #3

Merged
merged 17 commits into from
Oct 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Run unit tests

on:
push:
branches:
- main
pull_request:
branches:
- "*"
env:
PYTHONPATH: ./src # Needed for tests to discover whispr package
jobs:
test:
runs-on: ubuntu-22.04

steps:
- name: Checkout repository
uses: actions/checkout@v3

- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.10"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements_test.txt
pip install coveralls

- name: Run Test
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
pytest --cov=whispr tests
coveralls
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ lib64/
# Secrets
.env
*.creds
.coverage*
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
[![Downloads](https://static.pepy.tech/badge/whispr/month)](https://pepy.tech/project/whispr)
[![Coverage Status](https://coveralls.io/repos/github/narenaryan/whispr/badge.svg)](https://coveralls.io/github/narenaryan/whispr)

# Whispr

![Logo](./logo.png)

Whispr (Pronounced as whisp-r) is a CLI tool to safely inject secrets from your favorite secret vault (Ex: AWS Secrets Manager, Azure Key Vault etc.) into your app's environment. This is very useful for enabling secure local software development.
Whispr (Pronounced as whisper) is a CLI tool to safely inject secrets from your favorite secret vault (Ex: AWS Secrets Manager, Azure Key Vault etc.) into your app's environment. This is very useful for enabling secure local software development.

Whispr uses keys (with empty values) specified in a `.env` file and fetches respective secrets from a vault, and sets them as environment variables before launching an application.

Expand Down Expand Up @@ -45,7 +46,7 @@ pip install whispr

Run `whispr init <vault_type>` in your terminal to create a `whispr.yaml` file in your project root. This file will store your configuration settings.

The available vault types are: `aws`, `azure`, and `gcp`.
The available vault types are: `aws`, `azure`, and `gcp`.

**Example whispr.yaml contents (For: AWS):**
```yaml
Expand All @@ -71,7 +72,7 @@ POSTGRES_PASSWORD=

* Authenticate to AWS using Short-term credentials.
* Alternatively, set temporary AWS credentials using a config file or environment variables.

**Note**: Use respective authentication methods for other vaults.

## Launch any Application using Whispr
Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ dependencies = [
"structlog==24.4.0",
"azure-keyvault==4.2.0",
"azure-identity==1.19.0",
"hvac==2.3.0"
"hvac==2.3.0",
]
[project.urls]
Documentation = "https://github.com/narenaryan/whispr/blob/main/README.md"
Expand All @@ -60,9 +60,10 @@ python = ["3.8", "3.9", "3.10", "3.11", "3.12"]
dependencies = ["mypy>=1.0.0"]

[tool.coverage.run]
source_pkgs = ["whispr", "tests"]
source_pkgs = ["whispr"]
branch = true
parallel = true
relative_files = true
omit = ["src/whispr/__about__.py"]

[tool.coverage.paths]
Expand Down
4 changes: 4 additions & 0 deletions requirements_test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-r requirements.txt
coverage==7.6.4
pytest==8.3.3
pytest-cov==5.0.0
Empty file removed tests/.gitkeep
Empty file.
79 changes: 79 additions & 0 deletions tests/test_aws.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Tests for AWS module"""

import unittest
from unittest.mock import Mock, MagicMock, patch

import botocore.exceptions
import structlog

from whispr.vault import SimpleVault
from whispr.aws import AWSVault


class AWSVaultTestCase(unittest.TestCase):
"""Unit tests for AWSVault class, which fetches secrets from AWS Secrets Manager."""

def setUp(self):
"""Set up mocks for logger and AWS client before each test."""
self.mock_logger = MagicMock()
self.mock_client = MagicMock()
self.vault = AWSVault(logger=self.mock_logger, client=self.mock_client)

def test_initialization(self):
"""Test that AWSVault initializes with logger and client correctly."""
self.assertEqual(self.vault.logger, self.mock_logger)
self.assertEqual(self.vault.client, self.mock_client)

def test_fetch_secrets_success(self):
"""Test successful fetch of secrets from AWS Secrets Manager."""
self.mock_client.get_secret_value.return_value = {
"SecretString": '{"key": "value"}'
}
result = self.vault.fetch_secrets("test_secret")
self.assertEqual(result, '{"key": "value"}')
self.mock_client.get_secret_value.assert_called_with(SecretId="test_secret")

def test_fetch_secrets_resource_not_found(self):
"""Test fetch_secrets handles ResourceNotFoundException gracefully."""
# Set up the client to raise ResourceNotFoundException
self.mock_client.get_secret_value.side_effect = botocore.exceptions.ClientError(
{"Error": {"Code": "ResourceNotFoundException"}}, "get_secret_value"
)

result = self.vault.fetch_secrets("non_existent_secret")
self.assertEqual(result, "")
self.mock_logger.error.assert_called_with(
"The secret is not found on AWS. Did you set the right AWS_DEFAULT_REGION ?",
secret_name="non_existent_secret",
)

@patch("whispr.aws.AWSVault.fetch_secrets")
def test_fetch_secrets_unrecognized_client_exception(self, mock_fetch_secrets):
"""Test fetch_secrets handles UnrecognizedClientException gracefully."""
mock_fetch_secrets.side_effect = botocore.exceptions.ClientError(
{"Error": {"Code": "UnrecognizedClientException"}}, "get_secret_value"
)

with self.assertRaises(botocore.exceptions.ClientError):
result = self.vault.fetch_secrets("incorrect_credentials_secret")
self.assertEqual(result, "")
self.mock_logger.error.assert_called_with(
"Incorrect AWS credentials set for operation. Please verify them and retry."
)

def test_fetch_secrets_generic_exception(self):
"""Test fetch_secrets raises exception and logs an error for generic exceptions."""
# Set up the client to raise a generic exception
exception_message = "Some generic error"
self.mock_client.get_secret_value.side_effect = Exception(exception_message)

with self.assertRaises(Exception) as context:
self.vault.fetch_secrets("generic_error_secret")
self.assertEqual(str(context.exception), exception_message)

# Extract the actual call to the logger and check its arguments
self.assertTrue(self.mock_logger.error.called)
error_call = self.mock_logger.error.call_args
self.assertEqual(error_call[0][0], "Error fetching secret")
self.assertIsInstance(error_call[1]["error"], Exception)
self.assertEqual(str(error_call[1]["error"]), exception_message)
74 changes: 74 additions & 0 deletions tests/test_azure.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Tests for Azure module"""

import unittest
from unittest.mock import Mock, MagicMock

import structlog
from azure.core.exceptions import ResourceNotFoundError

from whispr.vault import SimpleVault
from whispr.azure import AzureVault


class AzureVaultTestCase(unittest.TestCase):
"""Unit tests for AzureVault class, which fetches secrets from Azure Key Vault."""

def setUp(self):
"""Set up mocks for logger, Azure client, and vault URL before each test."""
self.mock_logger = MagicMock()
self.mock_client = MagicMock()
self.vault_url = "https://example-vault.vault.azure.net/"
self.vault = AzureVault(
logger=self.mock_logger, client=self.mock_client, vault_url=self.vault_url
)

def test_initialization(self):
"""Test that AzureVault initializes with logger, client, and vault_url correctly."""
self.assertEqual(self.vault.logger, self.mock_logger)
self.assertEqual(self.vault.client, self.mock_client)
self.assertEqual(self.vault.vault_url, self.vault_url)

def test_fetch_secrets_success(self):
"""Test successful fetch of secrets from Azure Key Vault."""
# Mock the client response
mock_secret = MagicMock()
mock_secret.value = '{"key": "value"}'
self.mock_client.get_secret.return_value = mock_secret

result = self.vault.fetch_secrets("test_secret")
self.assertEqual(result, '{"key": "value"}')
self.mock_logger.info.assert_called_with(
"Successfully fetched secret: test_secret"
)
self.mock_client.get_secret.assert_called_with("test_secret")

def test_fetch_secrets_resource_not_found(self):
"""Test fetch_secrets handles ResourceNotFoundError gracefully."""
# Set up the client to raise ResourceNotFoundError
self.mock_client.get_secret.side_effect = ResourceNotFoundError(
"Secret not found"
)

result = self.vault.fetch_secrets("non_existent_secret")
self.assertEqual(result, "")
self.mock_logger.error.assert_called_with(
"The given secret: non_existent_secret is not found on azure vault. Please check the secret name, vault name or subscription ID."
)

def test_fetch_secrets_generic_exception(self):
"""Test fetch_secrets raises exception and logs an error for generic exceptions."""
# Set up the client to raise a generic exception
exception_message = "Some generic error"
self.mock_client.get_secret.side_effect = Exception(exception_message)

with self.assertRaises(Exception) as context:
self.vault.fetch_secrets("generic_error_secret")
self.assertEqual(str(context.exception), exception_message)

# Extract the actual call to the logger and check its arguments
self.assertTrue(self.mock_logger.error.called)
error_call = self.mock_logger.error.call_args
self.assertEqual(
error_call[0][0],
f"Error fetching secret: generic_error_secret, Error: {exception_message}",
)
72 changes: 72 additions & 0 deletions tests/test_gcp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Tests for GCP module"""

import unittest
from unittest.mock import Mock, patch, MagicMock

import google.api_core.exceptions
import structlog

from whispr.vault import SimpleVault
from whispr.gcp import GCPVault


class GCPVaultTestCase(unittest.TestCase):
"""Unit tests for GCPVault class, which fetches secrets from GCP Secrets Manager."""

def setUp(self):
"""Set up mocks for logger, GCP client, and project_id before each test."""
self.mock_logger = MagicMock()
self.mock_client = MagicMock()
self.project_id = "test_project_id"
self.vault = GCPVault(
logger=self.mock_logger, client=self.mock_client, project_id=self.project_id
)

def test_initialization(self):
"""Test that GCPVault initializes with logger, client, and project_id correctly."""
self.assertEqual(self.vault.logger, self.mock_logger)
self.assertEqual(self.vault.client, self.mock_client)
self.assertEqual(self.vault.project_id, self.project_id)

def test_fetch_secrets_success(self):
"""Test successful fetch of secrets from GCP Secrets Manager."""
# Mock the client response
mock_response = MagicMock()
mock_response.payload.data.decode.return_value = '{"key": "value"}'
self.mock_client.access_secret_version.return_value = mock_response

result = self.vault.fetch_secrets("test_secret")
self.assertEqual(result, '{"key": "value"}')
self.mock_logger.info.assert_called_with(
"Successfully fetched gcp secret: projects/test_project_id/secrets/test_secret/versions/latest"
)
self.mock_client.access_secret_version.assert_called_with(
name="projects/test_project_id/secrets/test_secret/versions/latest"
)

def test_fetch_secrets_not_found(self):
"""Test fetch_secrets handles NotFound exception gracefully."""
# Set up the client to raise NotFound exception
self.mock_client.access_secret_version.side_effect = (
google.api_core.exceptions.NotFound("Secret not found")
)

result = self.vault.fetch_secrets("non_existent_secret")
self.assertEqual(result, "")
self.mock_logger.error.assert_called_with(
"The given secret: projects/test_project_id/secrets/non_existent_secret/versions/latest is not found on gcp vault."
)

def test_fetch_secrets_generic_exception(self):
"""Test fetch_secrets handles generic exceptions gracefully."""
# Set up the client to raise a generic exception
exception_message = "Some generic error"
self.mock_client.access_secret_version.side_effect = Exception(
exception_message
)

result = self.vault.fetch_secrets("generic_error_secret")
self.assertEqual(result, "")
self.mock_logger.error.assert_called_with(
f"Error encountered while fetching secret: projects/test_project_id/secrets/generic_error_secret/versions/latest, Error: {exception_message}"
)
39 changes: 39 additions & 0 deletions tests/test_vault.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Tests for Vault module"""

import unittest
from unittest.mock import Mock, patch

import structlog

from whispr.vault import SimpleVault


class SimpleVaultTestCase(unittest.TestCase):
"""Tests for Vault"""

def setUp(self):
# Mock logger and client to use in tests
self.mock_logger = Mock(spec=structlog.BoundLogger)
self.mock_client = Mock()

# Subclass SimpleVault since it's abstract, only for testing
class TestVault(SimpleVault):
def fetch_secrets(self, secret_name: str) -> str:
# Provide a simple implementation for the abstract method
return "test_secret"

self.vault = TestVault(logger=self.mock_logger, client=self.mock_client)

@patch.object(
SimpleVault, "__abstractmethods__", set()
) # This allows instantiation of SimpleVault directly if needed
def test_initialization(self):
"""Test if the SimpleVault initializes with logger and client."""
self.assertEqual(self.vault.logger, self.mock_logger)
self.assertEqual(self.vault.client, self.mock_client)

def test_fetch_secrets(self):
"""Test the fetch_secrets method to ensure it returns the expected result."""
secret_name = "my_secret"
result = self.vault.fetch_secrets(secret_name)
self.assertEqual(result, "test_secret")
Loading