Add audit log and history API endpoints (#20)
- Add audit log endpoints: GET /api/v1/audit-logs with filters, project-scoped, package-scoped
- Add upload history endpoints: package uploads, artifact uploads
- Add artifact provenance endpoint: GET /api/v1/artifact/{id}/history
- Add audit logging for project.create, package.create, tag.create, tag.update
- Add AuditLogResponse, UploadHistoryResponse, ArtifactProvenanceResponse schemas
- Add 18 integration tests for new endpoints
This commit is contained in:
309
backend/tests/test_audit_history.py
Normal file
309
backend/tests/test_audit_history.py
Normal file
@@ -0,0 +1,309 @@
|
||||
"""Integration tests for audit logs and history endpoints."""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from tests.conftest import upload_test_file
|
||||
|
||||
|
||||
class TestAuditLogsEndpoint:
|
||||
"""Tests for /api/v1/audit-logs endpoint."""
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_list_audit_logs_returns_valid_response(self, integration_client):
|
||||
"""Test that audit logs endpoint returns valid paginated response."""
|
||||
response = integration_client.get("/api/v1/audit-logs")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "pagination" in data
|
||||
assert isinstance(data["items"], list)
|
||||
|
||||
pagination = data["pagination"]
|
||||
assert "page" in pagination
|
||||
assert "limit" in pagination
|
||||
assert "total" in pagination
|
||||
assert "total_pages" in pagination
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_audit_logs_respects_pagination(self, integration_client):
|
||||
"""Test that audit logs endpoint respects limit parameter."""
|
||||
response = integration_client.get("/api/v1/audit-logs?limit=5")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert len(data["items"]) <= 5
|
||||
assert data["pagination"]["limit"] == 5
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_audit_logs_filter_by_action(self, integration_client, test_package):
|
||||
"""Test filtering audit logs by action type."""
|
||||
# Create an action that will be logged
|
||||
project_name, package_name = test_package
|
||||
|
||||
response = integration_client.get("/api/v1/audit-logs?action=project.create")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
# All items should have the filtered action
|
||||
for item in data["items"]:
|
||||
assert item["action"] == "project.create"
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_audit_log_entry_has_required_fields(
|
||||
self, integration_client, test_project
|
||||
):
|
||||
"""Test that audit log entries have all required fields."""
|
||||
# Force some audit logs by operations on test_project
|
||||
response = integration_client.get("/api/v1/audit-logs?limit=10")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
if data["items"]:
|
||||
item = data["items"][0]
|
||||
assert "id" in item
|
||||
assert "action" in item
|
||||
assert "resource" in item
|
||||
assert "user_id" in item
|
||||
assert "timestamp" in item
|
||||
|
||||
|
||||
class TestProjectAuditLogs:
|
||||
"""Tests for /api/v1/projects/{project}/audit-logs endpoint."""
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_project_audit_logs_returns_200(self, integration_client, test_project):
|
||||
"""Test that project audit logs endpoint returns 200."""
|
||||
response = integration_client.get(f"/api/v1/projects/{test_project}/audit-logs")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "pagination" in data
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_project_audit_logs_not_found(self, integration_client):
|
||||
"""Test that non-existent project returns 404."""
|
||||
response = integration_client.get(
|
||||
"/api/v1/projects/nonexistent-project/audit-logs"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestPackageAuditLogs:
|
||||
"""Tests for /api/v1/project/{project}/{package}/audit-logs endpoint."""
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_package_audit_logs_returns_200(self, integration_client, test_package):
|
||||
"""Test that package audit logs endpoint returns 200."""
|
||||
project_name, package_name = test_package
|
||||
response = integration_client.get(
|
||||
f"/api/v1/project/{project_name}/{package_name}/audit-logs"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "pagination" in data
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_package_audit_logs_project_not_found(self, integration_client):
|
||||
"""Test that non-existent project returns 404."""
|
||||
response = integration_client.get(
|
||||
"/api/v1/project/nonexistent/nonexistent/audit-logs"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_package_audit_logs_package_not_found(
|
||||
self, integration_client, test_project
|
||||
):
|
||||
"""Test that non-existent package returns 404."""
|
||||
response = integration_client.get(
|
||||
f"/api/v1/project/{test_project}/nonexistent-package/audit-logs"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestPackageUploads:
|
||||
"""Tests for /api/v1/project/{project}/{package}/uploads endpoint."""
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_package_uploads_returns_200(self, integration_client, test_package):
|
||||
"""Test that package uploads endpoint returns 200."""
|
||||
project_name, package_name = test_package
|
||||
response = integration_client.get(
|
||||
f"/api/v1/project/{project_name}/{package_name}/uploads"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "pagination" in data
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_package_uploads_after_upload(self, integration_client, test_package):
|
||||
"""Test that uploads are recorded after file upload."""
|
||||
project_name, package_name = test_package
|
||||
|
||||
# Upload a file
|
||||
upload_result = upload_test_file(
|
||||
integration_client,
|
||||
project_name,
|
||||
package_name,
|
||||
b"test upload content",
|
||||
"test.txt",
|
||||
)
|
||||
assert upload_result["artifact_id"]
|
||||
|
||||
# Check uploads endpoint
|
||||
response = integration_client.get(
|
||||
f"/api/v1/project/{project_name}/{package_name}/uploads"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert len(data["items"]) >= 1
|
||||
|
||||
# Verify upload record fields
|
||||
upload = data["items"][0]
|
||||
assert "artifact_id" in upload
|
||||
assert "package_name" in upload
|
||||
assert "project_name" in upload
|
||||
assert "uploaded_at" in upload
|
||||
assert "uploaded_by" in upload
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_package_uploads_project_not_found(self, integration_client):
|
||||
"""Test that non-existent project returns 404."""
|
||||
response = integration_client.get(
|
||||
"/api/v1/project/nonexistent/nonexistent/uploads"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestArtifactUploads:
|
||||
"""Tests for /api/v1/artifact/{id}/uploads endpoint."""
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_artifact_uploads_returns_200(self, integration_client, test_package):
|
||||
"""Test that artifact uploads endpoint returns 200."""
|
||||
project_name, package_name = test_package
|
||||
|
||||
# Upload a file
|
||||
upload_result = upload_test_file(
|
||||
integration_client,
|
||||
project_name,
|
||||
package_name,
|
||||
b"artifact upload test",
|
||||
"artifact.txt",
|
||||
)
|
||||
artifact_id = upload_result["artifact_id"]
|
||||
|
||||
response = integration_client.get(f"/api/v1/artifact/{artifact_id}/uploads")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "pagination" in data
|
||||
assert len(data["items"]) >= 1
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_artifact_uploads_not_found(self, integration_client):
|
||||
"""Test that non-existent artifact returns 404."""
|
||||
fake_hash = "a" * 64
|
||||
response = integration_client.get(f"/api/v1/artifact/{fake_hash}/uploads")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestArtifactProvenance:
|
||||
"""Tests for /api/v1/artifact/{id}/history endpoint."""
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_artifact_history_returns_200(self, integration_client, test_package):
|
||||
"""Test that artifact history endpoint returns 200."""
|
||||
project_name, package_name = test_package
|
||||
|
||||
# Upload a file
|
||||
upload_result = upload_test_file(
|
||||
integration_client,
|
||||
project_name,
|
||||
package_name,
|
||||
b"provenance test content",
|
||||
"prov.txt",
|
||||
)
|
||||
artifact_id = upload_result["artifact_id"]
|
||||
|
||||
response = integration_client.get(f"/api/v1/artifact/{artifact_id}/history")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_artifact_history_has_required_fields(
|
||||
self, integration_client, test_package
|
||||
):
|
||||
"""Test that artifact history has all required fields."""
|
||||
project_name, package_name = test_package
|
||||
|
||||
# Upload a file
|
||||
upload_result = upload_test_file(
|
||||
integration_client,
|
||||
project_name,
|
||||
package_name,
|
||||
b"provenance fields test",
|
||||
"fields.txt",
|
||||
)
|
||||
artifact_id = upload_result["artifact_id"]
|
||||
|
||||
response = integration_client.get(f"/api/v1/artifact/{artifact_id}/history")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert "artifact_id" in data
|
||||
assert "sha256" in data
|
||||
assert "size" in data
|
||||
assert "created_at" in data
|
||||
assert "created_by" in data
|
||||
assert "ref_count" in data
|
||||
assert "first_uploaded_at" in data
|
||||
assert "first_uploaded_by" in data
|
||||
assert "upload_count" in data
|
||||
assert "packages" in data
|
||||
assert "tags" in data
|
||||
assert "uploads" in data
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_artifact_history_not_found(self, integration_client):
|
||||
"""Test that non-existent artifact returns 404."""
|
||||
fake_hash = "b" * 64
|
||||
response = integration_client.get(f"/api/v1/artifact/{fake_hash}/history")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_artifact_history_with_tag(self, integration_client, test_package):
|
||||
"""Test artifact history includes tag information when tagged."""
|
||||
project_name, package_name = test_package
|
||||
|
||||
# Upload a file with a tag
|
||||
upload_result = upload_test_file(
|
||||
integration_client,
|
||||
project_name,
|
||||
package_name,
|
||||
b"tagged provenance test",
|
||||
"tagged.txt",
|
||||
tag="v1.0.0",
|
||||
)
|
||||
artifact_id = upload_result["artifact_id"]
|
||||
|
||||
response = integration_client.get(f"/api/v1/artifact/{artifact_id}/history")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
# Should have at least one tag
|
||||
assert len(data["tags"]) >= 1
|
||||
|
||||
# Tag should have required fields
|
||||
tag = data["tags"][0]
|
||||
assert "project_name" in tag
|
||||
assert "package_name" in tag
|
||||
assert "tag_name" in tag
|
||||
Reference in New Issue
Block a user