feat(api): implement API key rotation mechanisms (#168)
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
+9
-2
@@ -289,7 +289,14 @@ ftp = ftplib.FTP() # nosec B321 - Plaintext FTP intentional when configured
|
|||||||
- Applied to `app/api/files.py` (file list sort + search query parameters)
|
- Applied to `app/api/files.py` (file list sort + search query parameters)
|
||||||
- Applied to `app/api/logs.py` (task_id query filter and path parameter)
|
- Applied to `app/api/logs.py` (task_id query filter and path parameter)
|
||||||
- 30 unit tests added in `tests/test_input_validation.py`
|
- 30 unit tests added in `tests/test_input_validation.py`
|
||||||
- ⏳ **TODO:** Implement proper API key rotation mechanisms ([#168](https://github.com/christianlouis/DocuElevate/issues/168))
|
- ✅ **COMPLETED:** Implement proper API key rotation mechanisms ([#168](https://github.com/christianlouis/DocuElevate/issues/168))
|
||||||
|
- `docs/CredentialRotationGuide.md` — comprehensive rotation guide covering:
|
||||||
|
- Recommended rotation schedule for all credential types
|
||||||
|
- Per-credential rotation procedures for OpenAI, Azure, AWS S3, Dropbox, Google Drive, OneDrive, Authentik, Paperless-ngx, SMTP, IMAP, Nextcloud, FTP, SFTP, WebDAV, and admin credentials
|
||||||
|
- Onboarding instructions (creating service-specific credentials with minimal permissions)
|
||||||
|
- Offboarding instructions (revocation, rotation of shared credentials, audit log review)
|
||||||
|
- Emergency revocation procedure
|
||||||
|
- `GET /api/settings/credentials` — admin-only endpoint listing all sensitive credential settings with configured/unconfigured status and source (`env` vs `db`), enabling credential rotation audits without exposing secret values
|
||||||
|
|
||||||
### Infrastructure Security
|
### Infrastructure Security
|
||||||
- ✅ TrustedHostMiddleware configured (restricts valid hosts)
|
- ✅ TrustedHostMiddleware configured (restricts valid hosts)
|
||||||
@@ -328,7 +335,7 @@ ftp = ftplib.FTP() # nosec B321 - Plaintext FTP intentional when configured
|
|||||||
1. **Security training documentation** - For contributors
|
1. **Security training documentation** - For contributors
|
||||||
2. **Penetration testing** - Professional security assessment
|
2. **Penetration testing** - Professional security assessment
|
||||||
3. **Bug bounty program** - Community security contributions
|
3. **Bug bounty program** - Community security contributions
|
||||||
4. **API key rotation** - Automated credential rotation ([#168](https://github.com/christianlouis/DocuElevate/issues/168))
|
4. ~~**API key rotation**~~ ✅ Implemented — `docs/CredentialRotationGuide.md` documents rotation procedures, onboarding/offboarding, and emergency revocation; `GET /api/settings/credentials` provides a credential audit endpoint ([#168](https://github.com/christianlouis/DocuElevate/issues/168))
|
||||||
|
|
||||||
## Security Contact
|
## Security Contact
|
||||||
|
|
||||||
|
|||||||
@@ -187,6 +187,60 @@ async def delete_setting(key: str, request: Request, db: DbSession, admin: Admin
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/credentials")
|
||||||
|
async def list_credentials(request: Request, db: DbSession, admin: AdminUser):
|
||||||
|
"""
|
||||||
|
List all sensitive credential settings with their configured/unconfigured status.
|
||||||
|
|
||||||
|
Returns a credential audit report indicating which credentials are set and whether
|
||||||
|
each value originates from the database or an environment variable.
|
||||||
|
This endpoint is intended to support credential rotation workflows.
|
||||||
|
Admin only.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
db_settings = get_all_settings_from_db(db)
|
||||||
|
credentials = []
|
||||||
|
|
||||||
|
for key, meta in SETTING_METADATA.items():
|
||||||
|
if not meta.get("sensitive", False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
env_value = getattr(settings, key, None)
|
||||||
|
in_db = key in db_settings and db_settings[key]
|
||||||
|
|
||||||
|
if in_db:
|
||||||
|
source = "db"
|
||||||
|
configured = True
|
||||||
|
elif env_value:
|
||||||
|
source = "env"
|
||||||
|
configured = True
|
||||||
|
else:
|
||||||
|
source = None
|
||||||
|
configured = False
|
||||||
|
|
||||||
|
credentials.append(
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"category": meta.get("category", "Other"),
|
||||||
|
"description": meta.get("description", ""),
|
||||||
|
"configured": configured,
|
||||||
|
"source": source,
|
||||||
|
"restart_required": meta.get("restart_required", False),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
configured_count = sum(1 for c in credentials if c["configured"])
|
||||||
|
return {
|
||||||
|
"credentials": credentials,
|
||||||
|
"total": len(credentials),
|
||||||
|
"configured_count": configured_count,
|
||||||
|
"unconfigured_count": len(credentials) - configured_count,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error retrieving credential list: {e}")
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve credentials")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/bulk-update")
|
@router.post("/bulk-update")
|
||||||
async def bulk_update_settings(updates: list[SettingUpdate], request: Request, db: DbSession, admin: AdminUser):
|
async def bulk_update_settings(updates: list[SettingUpdate], request: Request, db: DbSession, admin: AdminUser):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
# Credential Rotation Guide
|
||||||
|
|
||||||
|
This guide documents how to rotate API keys and credentials used by DocuElevate, along with onboarding and offboarding procedures for team members and service accounts.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
DocuElevate integrates with several external services that require API keys, tokens, or passwords. Regularly rotating these credentials limits the blast radius of a potential leak and is a security best practice.
|
||||||
|
|
||||||
|
Credentials fall into two categories:
|
||||||
|
|
||||||
|
| Category | Examples |
|
||||||
|
|---|---|
|
||||||
|
| **API keys** | OpenAI API key, Azure AI key, Paperless-ngx API token, AWS access keys |
|
||||||
|
| **OAuth tokens / secrets** | Dropbox, Google Drive, OneDrive, Authentik client secrets and refresh tokens |
|
||||||
|
| **Passwords** | Admin password, Nextcloud, Email (SMTP), IMAP, FTP, SFTP, WebDAV |
|
||||||
|
| **Private keys** | SFTP private key and passphrase |
|
||||||
|
|
||||||
|
All credentials are stored either in environment variables or, when set via the Settings UI, encrypted in the database using Fernet symmetric encryption (keyed from `SESSION_SECRET`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Rotation Schedule
|
||||||
|
|
||||||
|
| Credential Type | Recommended Rotation Interval |
|
||||||
|
|---|---|
|
||||||
|
| API keys (OpenAI, Azure, AWS, Paperless) | Every 90 days |
|
||||||
|
| OAuth client secrets | Every 180 days |
|
||||||
|
| OAuth refresh tokens | Rotate on revocation / after each re-authorization |
|
||||||
|
| Passwords (SMTP, IMAP, FTP, SFTP, Nextcloud, WebDAV) | Every 90 days or on personnel change |
|
||||||
|
| Admin password | Every 90 days or on personnel change |
|
||||||
|
| `SESSION_SECRET` | On suspected compromise; note all active sessions will be invalidated |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Rotate a Credential
|
||||||
|
|
||||||
|
DocuElevate supports two rotation methods:
|
||||||
|
|
||||||
|
### Method 1 — Settings API (recommended, zero-downtime)
|
||||||
|
|
||||||
|
Use the Settings REST API to update individual credentials while the application is running. No restart is needed for most credentials (check `restart_required` in the response).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rotate the OpenAI API key
|
||||||
|
curl -X POST https://<your-host>/api/settings/openai_api_key \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-b "session=<admin-session-cookie>" \
|
||||||
|
-d '{"key": "openai_api_key", "value": "sk-new-key-here"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The endpoint returns `"restart_required": true` for settings that require an application restart to take effect (e.g., database URL, session secret). All AI-service and storage-provider credentials take effect immediately without a restart.
|
||||||
|
|
||||||
|
### Method 2 — Environment variable / `.env` file
|
||||||
|
|
||||||
|
1. Update the relevant variable in your `.env` file (or your container/Kubernetes secret).
|
||||||
|
2. Restart the application so the new value is loaded:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose restart api worker
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** Settings stored in the database take precedence over environment variables. If you previously set a credential via the Settings UI, you must also update or delete it from the database (via the Settings API) to have the environment variable take effect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-Credential Rotation Procedures
|
||||||
|
|
||||||
|
### OpenAI API Key
|
||||||
|
|
||||||
|
1. Log in to [platform.openai.com](https://platform.openai.com) → **API keys**.
|
||||||
|
2. Create a new secret key and copy it.
|
||||||
|
3. Update in DocuElevate:
|
||||||
|
```
|
||||||
|
POST /api/settings/openai_api_key {"key": "openai_api_key", "value": "<new-key>"}
|
||||||
|
```
|
||||||
|
4. Verify document processing still works (upload a test file).
|
||||||
|
5. Delete the old key in the OpenAI dashboard.
|
||||||
|
|
||||||
|
### Azure Document Intelligence Key
|
||||||
|
|
||||||
|
1. Open [portal.azure.com](https://portal.azure.com) → your Document Intelligence resource → **Keys and Endpoint**.
|
||||||
|
2. Regenerate **Key 2** while **Key 1** is still live (avoids downtime).
|
||||||
|
3. Update `azure_ai_key` in DocuElevate with the new key.
|
||||||
|
4. Verify connectivity, then regenerate **Key 1** and optionally update again.
|
||||||
|
|
||||||
|
### AWS S3 Access Keys
|
||||||
|
|
||||||
|
1. In the **IAM Console**, create a new access key for the service user.
|
||||||
|
2. Update both `aws_access_key_id` and `aws_secret_access_key` in DocuElevate together (use the bulk-update endpoint or update both settings in sequence before verifying).
|
||||||
|
3. Test an S3 upload from DocuElevate.
|
||||||
|
4. Deactivate the old IAM access key, then delete it after 24 hours.
|
||||||
|
|
||||||
|
### Dropbox App Credentials & Refresh Token
|
||||||
|
|
||||||
|
Dropbox refresh tokens are long-lived; rotate them by re-authorizing the application:
|
||||||
|
|
||||||
|
1. In [dropbox.com/developers](https://www.dropbox.com/developers), revoke the existing token.
|
||||||
|
2. Follow the Dropbox OAuth flow documented in `docs/DropboxSetup.md` to obtain a new refresh token.
|
||||||
|
3. Update `dropbox_refresh_token` (and `dropbox_app_key` / `dropbox_app_secret` if also rotating those).
|
||||||
|
|
||||||
|
### Google Drive OAuth Credentials
|
||||||
|
|
||||||
|
1. In the [Google Cloud Console](https://console.cloud.google.com), navigate to **APIs & Services → Credentials**.
|
||||||
|
2. Rotate the OAuth client secret: delete the old secret and create a new one.
|
||||||
|
3. Update `google_drive_client_secret` in DocuElevate.
|
||||||
|
4. Re-authorize to obtain a fresh refresh token and update `google_drive_refresh_token`.
|
||||||
|
|
||||||
|
For service-account credentials (`google_drive_credentials_json`):
|
||||||
|
|
||||||
|
1. Create a new service-account key in the Google Cloud Console.
|
||||||
|
2. Update `google_drive_credentials_json` with the new JSON.
|
||||||
|
3. Verify access, then delete the old key.
|
||||||
|
|
||||||
|
### OneDrive (Microsoft OAuth)
|
||||||
|
|
||||||
|
1. In **Azure App Registrations**, navigate to **Certificates & secrets** for your app.
|
||||||
|
2. Add a new client secret.
|
||||||
|
3. Update `onedrive_client_secret` in DocuElevate.
|
||||||
|
4. Re-authorize via the OAuth flow to get a fresh `onedrive_refresh_token`.
|
||||||
|
5. Delete the old client secret in Azure.
|
||||||
|
|
||||||
|
### Authentik (OIDC)
|
||||||
|
|
||||||
|
1. In your Authentik admin panel, navigate to the DocuElevate application and regenerate the client secret.
|
||||||
|
2. Update `authentik_client_secret` in DocuElevate.
|
||||||
|
3. Restart the application (this setting requires a restart: `restart_required: true`).
|
||||||
|
|
||||||
|
### Paperless-ngx API Token
|
||||||
|
|
||||||
|
1. Log in to your Paperless-ngx instance → **Settings → API Tokens**.
|
||||||
|
2. Create a new token.
|
||||||
|
3. Update `paperless_ngx_api_token` in DocuElevate.
|
||||||
|
4. Verify document routing works, then revoke the old token.
|
||||||
|
|
||||||
|
### SMTP / Email Password
|
||||||
|
|
||||||
|
1. Rotate the password in your mail server or email provider.
|
||||||
|
2. Update `email_password` in DocuElevate.
|
||||||
|
|
||||||
|
### IMAP Passwords
|
||||||
|
|
||||||
|
Update `imap1_password` and/or `imap2_password` after rotating the credentials with your email provider.
|
||||||
|
|
||||||
|
### Nextcloud Password / App Password
|
||||||
|
|
||||||
|
1. In Nextcloud → **Settings → Security**, revoke the existing app password and create a new one.
|
||||||
|
2. Update `nextcloud_password` in DocuElevate.
|
||||||
|
|
||||||
|
### FTP / SFTP / WebDAV Passwords
|
||||||
|
|
||||||
|
1. Rotate the credential on the respective server.
|
||||||
|
2. Update `ftp_password`, `sftp_password`, or `webdav_password` in DocuElevate.
|
||||||
|
|
||||||
|
### SFTP Private Key
|
||||||
|
|
||||||
|
1. Generate a new key pair:
|
||||||
|
```bash
|
||||||
|
ssh-keygen -t ed25519 -f ~/.ssh/docuelevate_sftp -C "docuelevate-sftp"
|
||||||
|
```
|
||||||
|
2. Install the new public key on the SFTP server.
|
||||||
|
3. Update `sftp_private_key` (and `sftp_private_key_passphrase` if encrypted) in DocuElevate.
|
||||||
|
4. Verify connectivity, then remove the old public key from the SFTP server.
|
||||||
|
|
||||||
|
### Admin Password
|
||||||
|
|
||||||
|
1. Update `admin_password` in DocuElevate (via the Settings UI or API).
|
||||||
|
2. Communicate the new password to any users who share it (discouraged; prefer individual accounts via OAuth).
|
||||||
|
3. Requires application restart.
|
||||||
|
|
||||||
|
### `SESSION_SECRET`
|
||||||
|
|
||||||
|
> **Warning:** Rotating `SESSION_SECRET` invalidates all active user sessions. All logged-in users will be signed out immediately.
|
||||||
|
|
||||||
|
1. Generate a new secret (minimum 32 characters):
|
||||||
|
```bash
|
||||||
|
python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
```
|
||||||
|
2. Update the environment variable or `.env` file.
|
||||||
|
3. Restart the application.
|
||||||
|
4. Existing encrypted settings stored in the database **will no longer be readable** because the encryption key is derived from `SESSION_SECRET`. You must re-enter all sensitive settings that were stored via the UI after rotating this value.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bulk Credential Audit
|
||||||
|
|
||||||
|
Use the dedicated endpoint to list all credential settings and their configured/unconfigured status:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/settings/credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"credentials": [
|
||||||
|
{
|
||||||
|
"key": "openai_api_key",
|
||||||
|
"category": "AI Services",
|
||||||
|
"description": "OpenAI API key for metadata extraction",
|
||||||
|
"configured": true,
|
||||||
|
"source": "env"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "azure_ai_key",
|
||||||
|
"category": "AI Services",
|
||||||
|
"description": "Azure AI key for document intelligence",
|
||||||
|
"configured": true,
|
||||||
|
"source": "db"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"total": 24,
|
||||||
|
"configured_count": 8,
|
||||||
|
"unconfigured_count": 16
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `source` field indicates whether the value comes from the **database** (`db`) or an **environment variable** (`env`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Onboarding a New Team Member or Service Account
|
||||||
|
|
||||||
|
1. **Identify required credentials** – Use `GET /api/settings/credentials` to see which credentials are active in the deployment.
|
||||||
|
2. **Create service-specific credentials** – For each external service (OpenAI, AWS, etc.) create a new API key or sub-account rather than sharing the existing one. This enables individual revocation without disrupting others.
|
||||||
|
3. **Set credentials via the Settings API** – Provide the new credential via `POST /api/settings/{key}`. The value is encrypted at rest.
|
||||||
|
4. **Restrict access** – Ensure the new service account has only the minimum permissions needed (e.g., an S3 IAM user with write access to the specific bucket only).
|
||||||
|
5. **Document the credential** – Record *which* system generated the credential and *when* it was created, so it can be identified during offboarding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Offboarding a Team Member or Decommissioning a Service Account
|
||||||
|
|
||||||
|
1. **Identify credentials tied to the departing user** – Review all third-party services for keys or OAuth authorizations issued under their account.
|
||||||
|
2. **Revoke credentials** – Delete or disable the API key/token in each third-party service immediately.
|
||||||
|
3. **Rotate shared credentials** – If any credential was shared (e.g., a team-wide admin password), rotate it now using the procedures above.
|
||||||
|
4. **Update DocuElevate** – Set the new credential via `POST /api/settings/{key}` or delete the old entry via `DELETE /api/settings/{key}` if the service is no longer used.
|
||||||
|
5. **Verify operations** – Trigger a test document processing run to confirm all integrations still work.
|
||||||
|
6. **Audit logs** – Review audit logs for any anomalous activity by the departing user before revoking access.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Emergency Credential Revocation
|
||||||
|
|
||||||
|
If a credential is believed to be compromised:
|
||||||
|
|
||||||
|
1. **Revoke immediately** in the external service (do not wait to have a replacement ready).
|
||||||
|
2. **Review audit logs** for unauthorized usage.
|
||||||
|
3. **Generate and deploy a replacement** credential as soon as possible.
|
||||||
|
4. **Notify stakeholders** per your incident response plan (see [SECURITY.md](../SECURITY.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Configuration Guide](ConfigurationGuide.md) — Full list of environment variables
|
||||||
|
- [Deployment Guide](DeploymentGuide.md) — Deployment and restart procedures
|
||||||
|
- [SECURITY_AUDIT.md](../SECURITY_AUDIT.md) — Security audit findings and status
|
||||||
|
- [SECURITY.md](../SECURITY.md) — Security contact and disclosure policy
|
||||||
@@ -203,3 +203,177 @@ class TestSettingModels:
|
|||||||
assert "test_key" in response.settings
|
assert "test_key" in response.settings
|
||||||
assert "General" in response.categories
|
assert "General" in response.categories
|
||||||
assert "test_key" in response.db_settings
|
assert "test_key" in response.db_settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestListCredentials:
|
||||||
|
"""Tests for the list_credentials function (GET /api/settings/credentials)."""
|
||||||
|
|
||||||
|
@patch("app.api.settings.get_all_settings_from_db")
|
||||||
|
def test_list_credentials_returns_sensitive_keys_only(self, mock_db_settings):
|
||||||
|
"""Test that list_credentials only includes keys marked sensitive in SETTING_METADATA."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.api.settings import list_credentials
|
||||||
|
from app.utils.settings_service import SETTING_METADATA
|
||||||
|
|
||||||
|
mock_db_settings.return_value = {}
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_admin = {"is_admin": True}
|
||||||
|
|
||||||
|
with patch("app.api.settings.settings") as mock_settings:
|
||||||
|
for key in SETTING_METADATA:
|
||||||
|
setattr(mock_settings, key, None)
|
||||||
|
|
||||||
|
result = asyncio.get_event_loop().run_until_complete(
|
||||||
|
list_credentials(mock_request, mock_db, mock_admin)
|
||||||
|
)
|
||||||
|
|
||||||
|
returned_keys = {c["key"] for c in result["credentials"]}
|
||||||
|
sensitive_keys = {k for k, v in SETTING_METADATA.items() if v.get("sensitive")}
|
||||||
|
assert returned_keys == sensitive_keys
|
||||||
|
|
||||||
|
@patch("app.api.settings.get_all_settings_from_db")
|
||||||
|
def test_list_credentials_source_db_when_in_database(self, mock_db_settings):
|
||||||
|
"""Test that credentials stored in the database report source='db'."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.api.settings import list_credentials
|
||||||
|
|
||||||
|
mock_db_settings.return_value = {"openai_api_key": "sk-db-key"}
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_admin = {"is_admin": True}
|
||||||
|
|
||||||
|
with patch("app.api.settings.settings") as mock_settings:
|
||||||
|
mock_settings.openai_api_key = "sk-env-key"
|
||||||
|
|
||||||
|
result = asyncio.get_event_loop().run_until_complete(
|
||||||
|
list_credentials(mock_request, mock_db, mock_admin)
|
||||||
|
)
|
||||||
|
|
||||||
|
openai_entry = next(c for c in result["credentials"] if c["key"] == "openai_api_key")
|
||||||
|
assert openai_entry["source"] == "db"
|
||||||
|
assert openai_entry["configured"] is True
|
||||||
|
|
||||||
|
@patch("app.api.settings.get_all_settings_from_db")
|
||||||
|
def test_list_credentials_source_env_when_only_in_env(self, mock_db_settings):
|
||||||
|
"""Test that credentials only in env report source='env'."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.api.settings import list_credentials
|
||||||
|
|
||||||
|
mock_db_settings.return_value = {}
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_admin = {"is_admin": True}
|
||||||
|
|
||||||
|
with patch("app.api.settings.settings") as mock_settings:
|
||||||
|
mock_settings.openai_api_key = "sk-env-key"
|
||||||
|
|
||||||
|
result = asyncio.get_event_loop().run_until_complete(
|
||||||
|
list_credentials(mock_request, mock_db, mock_admin)
|
||||||
|
)
|
||||||
|
|
||||||
|
openai_entry = next(c for c in result["credentials"] if c["key"] == "openai_api_key")
|
||||||
|
assert openai_entry["source"] == "env"
|
||||||
|
assert openai_entry["configured"] is True
|
||||||
|
|
||||||
|
@patch("app.api.settings.get_all_settings_from_db")
|
||||||
|
def test_list_credentials_unconfigured_when_no_value(self, mock_db_settings):
|
||||||
|
"""Test that credentials with no value are marked as unconfigured."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.api.settings import list_credentials
|
||||||
|
|
||||||
|
mock_db_settings.return_value = {}
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_admin = {"is_admin": True}
|
||||||
|
|
||||||
|
with patch("app.api.settings.settings") as mock_settings:
|
||||||
|
mock_settings.openai_api_key = None
|
||||||
|
|
||||||
|
result = asyncio.get_event_loop().run_until_complete(
|
||||||
|
list_credentials(mock_request, mock_db, mock_admin)
|
||||||
|
)
|
||||||
|
|
||||||
|
openai_entry = next(c for c in result["credentials"] if c["key"] == "openai_api_key")
|
||||||
|
assert openai_entry["configured"] is False
|
||||||
|
assert openai_entry["source"] is None
|
||||||
|
|
||||||
|
@patch("app.api.settings.get_all_settings_from_db")
|
||||||
|
def test_list_credentials_counts_are_accurate(self, mock_db_settings):
|
||||||
|
"""Test that configured_count and unconfigured_count are correct."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.api.settings import list_credentials
|
||||||
|
|
||||||
|
mock_db_settings.return_value = {"openai_api_key": "sk-db-key"}
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_admin = {"is_admin": True}
|
||||||
|
|
||||||
|
with patch("app.api.settings.settings") as mock_settings:
|
||||||
|
# Most keys will be None, one will be set via db_settings mock
|
||||||
|
mock_settings.openai_api_key = "sk-key"
|
||||||
|
|
||||||
|
result = asyncio.get_event_loop().run_until_complete(
|
||||||
|
list_credentials(mock_request, mock_db, mock_admin)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["total"] == len(result["credentials"])
|
||||||
|
assert result["configured_count"] + result["unconfigured_count"] == result["total"]
|
||||||
|
|
||||||
|
@patch("app.api.settings.get_all_settings_from_db")
|
||||||
|
def test_list_credentials_raises_500_on_exception(self, mock_db_settings):
|
||||||
|
"""Test that list_credentials raises HTTP 500 on unexpected errors."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.api.settings import list_credentials
|
||||||
|
|
||||||
|
mock_db_settings.side_effect = Exception("DB failure")
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_admin = {"is_admin": True}
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
asyncio.get_event_loop().run_until_complete(
|
||||||
|
list_credentials(mock_request, mock_db, mock_admin)
|
||||||
|
)
|
||||||
|
assert exc_info.value.status_code == 500
|
||||||
|
|
||||||
|
def test_list_credentials_endpoint_requires_admin(self, client):
|
||||||
|
"""Test that GET /api/settings/credentials requires admin access."""
|
||||||
|
response = client.get("/api/settings/credentials")
|
||||||
|
assert response.status_code in [302, 401, 403]
|
||||||
|
|
||||||
|
@patch("app.api.settings.get_all_settings_from_db")
|
||||||
|
def test_list_credentials_response_has_required_fields(self, mock_db_settings):
|
||||||
|
"""Test that each credential entry has the required fields."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.api.settings import list_credentials
|
||||||
|
|
||||||
|
mock_db_settings.return_value = {}
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_admin = {"is_admin": True}
|
||||||
|
|
||||||
|
with patch("app.api.settings.settings") as mock_settings:
|
||||||
|
mock_settings.openai_api_key = None
|
||||||
|
|
||||||
|
result = asyncio.get_event_loop().run_until_complete(
|
||||||
|
list_credentials(mock_request, mock_db, mock_admin)
|
||||||
|
)
|
||||||
|
|
||||||
|
for cred in result["credentials"]:
|
||||||
|
assert "key" in cred
|
||||||
|
assert "category" in cred
|
||||||
|
assert "description" in cred
|
||||||
|
assert "configured" in cred
|
||||||
|
assert "source" in cred
|
||||||
|
assert "restart_required" in cred
|
||||||
|
|||||||
Reference in New Issue
Block a user