From 88fff1e71ef5615e6cd3ddccfe41003307cc6519 Mon Sep 17 00:00:00 2001 From: Allisson Azevedo Date: Wed, 25 Feb 2026 19:01:21 -0300 Subject: [PATCH] feat: add listing endpoints for secrets, transit, and tokenization keys This commit introduces comprehensive listing capabilities across all core cryptographic modules, supporting pagination via `limit` and `offset` query parameters. Key additions: - **Secrets Module**: - Added `GET /v1/secrets` HTTP handler. - Implemented `List` usecase operations with metrics decorators. - Added `List` methods to both PostgreSQL and MySQL repositories. - Created `ListSecretsResponse` DTO for consistent API responses. - **Transit Module**: - Added `GET /v1/transit/keys` HTTP handler. - Implemented `List` usecase operations with metrics decorators. - Added `List` methods to both PostgreSQL and MySQL repositories. - Created `ListTransitKeysResponse` DTO. - **Tokenization Module**: - Added `GET /v1/tokenization/keys` HTTP handler. - Implemented `List` usecase operations with metrics decorators. - Added `List` methods to PostgreSQL and MySQL repositories. - Created `ListTokenizationKeysResponse` DTO. - **Routing & Config**: - Registered the new GET endpoints in internal/http/server.go. - Bumped application version to `0.16.0` in cmd/app/main.go. - **Documentation & Release Prep**: - Documented new routes, DTOs, and parameters in docs/openapi.yaml. - Updated API markdown documentation in `docs/api/data/`. - Updated `CHANGELOG.md`, `docs/releases/RELEASES.md`, and compatibility matrices. - Refreshed documentation metadata (`docs/metadata.json`). - **Testing**: - Maintained strict test coverage matching project guidelines. - Added fully-native integration tests for all new MySQL and PostgreSQL repository `List` methods. - Included unit tests for all new DTO mappers, usecases, and HTTP handlers. --- CHANGELOG.md | 6 + GEMINI.md | 7 + cmd/app/main.go | 2 +- docs/README.md | 2 +- docs/api/data/secrets.md | 32 ++++ docs/api/data/tokenization.md | 29 ++++ docs/api/data/transit.md | 28 ++++ docs/metadata.json | 2 +- docs/openapi.yaml | 97 ++++++++++++ .../deployment/production-rollout.md | 4 +- docs/releases/RELEASES.md | 14 +- docs/releases/compatibility-matrix.md | 6 + internal/http/server.go | 16 ++ .../secrets/http/dto/list_secrets_response.go | 27 ++++ .../http/dto/list_secrets_response_test.go | 43 +++++ internal/secrets/http/secret_handler.go | 36 +++++ internal/secrets/http/secret_handler_test.go | 54 +++++++ .../repository/mysql_secret_repository.go | 69 ++++++++ .../mysql_secret_repository_test.go | 59 +++++++ .../postgresql_secret_repository.go | 58 +++++++ .../postgresql_secret_repository_test.go | 59 +++++++ internal/secrets/usecase/interface.go | 8 + internal/secrets/usecase/metrics_decorator.go | 19 +++ internal/secrets/usecase/mocks/mocks.go | 148 ++++++++++++++++++ internal/secrets/usecase/secret_usecase.go | 5 + .../secrets/usecase/secret_usecase_test.go | 93 +++++++++++ .../dto/list_tokenization_keys_response.go | 25 +++ .../list_tokenization_keys_response_test.go | 51 ++++++ .../http/tokenization_key_handler.go | 37 +++++ .../http/tokenization_key_handler_test.go | 47 ++++++ .../repository/mysql_repository.go | 72 +++++++++ .../repository/mysql_repository_test.go | 134 ++++++++++++++++ .../repository/postgresql_repository.go | 63 ++++++++ .../repository/postgresql_repository_test.go | 134 ++++++++++++++++ internal/tokenization/usecase/interface.go | 8 + internal/tokenization/usecase/mocks/mocks.go | 148 ++++++++++++++++++ .../tokenization_key_metrics_decorator.go | 19 +++ .../usecase/tokenization_key_usecase.go | 8 + .../usecase/tokenization_key_usecase_test.go | 87 ++++++++++ .../http/dto/list_transit_keys_response.go | 22 +++ .../dto/list_transit_keys_response_test.go | 43 +++++ internal/transit/http/transit_key_handler.go | 37 +++++ .../transit/http/transit_key_handler_test.go | 47 ++++++ .../mysql_transit_key_repository.go | 68 ++++++++ .../mysql_transit_key_repository_test.go | 56 +++++++ .../postgresql_transit_key_repository.go | 57 +++++++ .../postgresql_transit_key_repository_test.go | 56 +++++++ internal/transit/usecase/interface.go | 8 + internal/transit/usecase/metrics_decorator.go | 19 +++ internal/transit/usecase/mocks/mocks.go | 148 ++++++++++++++++++ .../transit/usecase/transit_key_usecase.go | 8 + .../usecase/transit_key_usecase_test.go | 91 +++++++++++ 52 files changed, 2410 insertions(+), 6 deletions(-) create mode 100644 internal/secrets/http/dto/list_secrets_response.go create mode 100644 internal/secrets/http/dto/list_secrets_response_test.go create mode 100644 internal/tokenization/http/dto/list_tokenization_keys_response.go create mode 100644 internal/tokenization/http/dto/list_tokenization_keys_response_test.go create mode 100644 internal/transit/http/dto/list_transit_keys_response.go create mode 100644 internal/transit/http/dto/list_transit_keys_response_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 01dcf5a..c04623e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.16.0] - 2026-02-25 + +### Added +- Added listing endpoints with pagination (`offset`, `limit`) for secrets (`GET /v1/secrets`), transit keys (`GET /v1/transit/keys`), and tokenization keys (`GET /v1/tokenization/keys`) +- Added list DTO structures for consistent API responses across modules + ## [0.15.0] - 2026-02-25 ### Added diff --git a/GEMINI.md b/GEMINI.md index a634d19..3d12e9e 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -53,6 +53,12 @@ Configuration is managed via environment variables (see `internal/config/config. ### Testing Practices - **Parallel Tests:** Unit tests should be able to run in parallel. - **Integration Tests:** Located in `test/integration/`. Use `make test-with-db` to run them locally. + - **CRITICAL:** Every new repository method (e.g., `Create`, `Get`, `List`, `Delete`) MUST have corresponding tests written natively in BOTH its `mysql_..._repository_test.go` and `postgresql_..._repository_test.go` files, and must be validated to pass against the test databases via `make test-with-db`. +- **HTTP/DTO Tests:** + - **CRITICAL:** Every new HTTP handler (e.g., `ListHandler`, `CreateHandler`) MUST have corresponding unit tests in its `..._handler_test.go` file. + - **CRITICAL:** Every new mapping DTO (e.g., `MapSecretsToListResponse`) MUST have corresponding unit tests in its package (e.g., `list_secrets_response_test.go`) to ensure accurate payload mapping. +- **Usecase Tests:** + - **CRITICAL:** Every new usecase method MUST have corresponding unit tests written natively in its `..._usecase_test.go` file to ensure core business logic is tested independently. - **Coverage:** Aim for high coverage in `usecase` and `domain` layers. ### Contribution Guidelines @@ -63,6 +69,7 @@ Configuration is managed via environment variables (see `internal/config/config. 3. **Changelog:** Every new version MUST be added to the high-level `CHANGELOG.md` in the root directory. 4. **Main Version:** The `version` variable in `cmd/app/main.go` MUST be updated to match the new release version. 5. **Docs Linting:** The command `make docs-lint` MUST be executed and all issues resolved. + 6. **OpenAPI Spec:** Any new API handler or configuration change MUST be reflected in `docs/openapi.yaml`. - **Migrations:** New database changes must include both `up` and `down` SQL scripts for both MySQL and PostgreSQL. ### Tooling diff --git a/cmd/app/main.go b/cmd/app/main.go index 0e26b38..bff0e8e 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -14,7 +14,7 @@ import ( // Build-time version information (injected via ldflags during build). var ( - version = "v0.15.0" // Semantic version with "v" prefix (e.g., "v0.12.0") + version = "v0.16.0" // Semantic version with "v" prefix (e.g., "v0.12.0") buildDate = "unknown" // ISO 8601 build timestamp commitSHA = "unknown" // Git commit SHA ) diff --git a/docs/README.md b/docs/README.md index 63836df..0ef675b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -101,7 +101,7 @@ Welcome to the full documentation for Secrets. Pick a path and dive in 🚀 OpenAPI scope note: -- `openapi.yaml` is a baseline subset for common API flows in the current release (v0.15.0, see `docs/metadata.json`) +- `openapi.yaml` is a baseline subset for common API flows in the current release (v0.16.0, see `docs/metadata.json`) - Full endpoint behavior is documented in the endpoint pages under `docs/api/` - Tokenization endpoints are included in `openapi.yaml` for the current release diff --git a/docs/api/data/secrets.md b/docs/api/data/secrets.md index 28b359c..f69b4f8 100644 --- a/docs/api/data/secrets.md +++ b/docs/api/data/secrets.md @@ -17,6 +17,7 @@ All endpoints require Bearer authentication. - `POST /v1/secrets/*path` (create or update) - `GET /v1/secrets/*path` (read latest) +- `GET /v1/secrets` (list secrets with pagination) - `DELETE /v1/secrets/*path` (soft delete latest) ## Status Code Quick Reference @@ -25,6 +26,7 @@ All endpoints require Bearer authentication. | --- | --- | --- | | `POST /v1/secrets/*path` | `201` | `401`, `403`, `422`, `429` | | `GET /v1/secrets/*path` | `200` | `401`, `403`, `404`, `429` | +| `GET /v1/secrets` | `200` | `401`, `403`, `422`, `429` | | `DELETE /v1/secrets/*path` | `204` | `401`, `403`, `404`, `429` | ## Create or Update Secret @@ -70,6 +72,35 @@ Example response (`200 OK`): } ``` +## List Secrets + +```bash +curl "http://localhost:8080/v1/secrets?offset=0&limit=50" \ + -H "Authorization: Bearer " +``` + +Pagination parameters: + +- `offset`: Defaults to `0` +- `limit`: Defaults to `50` (max `100`) + +Example response (`200 OK`): + +```json +{ + "items": [ + { + "id": "0194f4a5-73fe-7a7d-a3a0-6fbe9b5ef8f3", + "path": "/app/prod/database-password", + "version": 3, + "created_at": "2026-02-14T18:22:00Z" + } + ] +} +``` + +> **Note**: Secret values are deliberately excluded from list responses. Use `GET /v1/secrets/*path` to retrieve specific secret payloads. + ## Delete Secret ```bash @@ -144,6 +175,7 @@ Expected result: write returns `201 Created`; read returns `200 OK` with base64 - `POST /v1/secrets/*path` -> `encrypt` - `GET /v1/secrets/*path` -> `decrypt` +- `GET /v1/secrets` -> `read` - `DELETE /v1/secrets/*path` -> `delete` Wildcard matcher semantics reference: diff --git a/docs/api/data/tokenization.md b/docs/api/data/tokenization.md index 029a5a8..8277b40 100644 --- a/docs/api/data/tokenization.md +++ b/docs/api/data/tokenization.md @@ -23,6 +23,7 @@ All endpoints require `Authorization: Bearer `. Key management: +- `GET /v1/tokenization/keys` (list keys) - `POST /v1/tokenization/keys` (create key) - `POST /v1/tokenization/keys/:name/rotate` (rotate key) - `DELETE /v1/tokenization/keys/:id` (soft delete key) @@ -38,6 +39,7 @@ Capability mapping: | Endpoint | Required capability | | --- | --- | +| `GET /v1/tokenization/keys` | `read` | | `POST /v1/tokenization/keys` | `write` | | `POST /v1/tokenization/keys/:name/rotate` | `rotate` | | `DELETE /v1/tokenization/keys/:id` | `delete` | @@ -50,6 +52,7 @@ Capability mapping: | Endpoint | Success | Common error statuses | | --- | --- | --- | +| `GET /v1/tokenization/keys` | `200` | `401`, `403`, `422`, `429` | | `POST /v1/tokenization/keys` | `201` | `401`, `403`, `409`, `422`, `429` | | `POST /v1/tokenization/keys/:name/rotate` | `201` | `401`, `403`, `404`, `422`, `429` | | `DELETE /v1/tokenization/keys/:id` | `204` | `401`, `403`, `404`, `422`, `429` | @@ -58,6 +61,32 @@ Capability mapping: | `POST /v1/tokenization/validate` | `200` | `401`, `403`, `422`, `429` | | `POST /v1/tokenization/revoke` | `204` | `401`, `403`, `404`, `422`, `429` | +## List Tokenization Keys + +Retrieves a paginated list of tokenization keys, showing the latest version for each. + +```bash +curl "http://localhost:8080/v1/tokenization/keys?offset=0&limit=50" \ + -H "Authorization: Bearer " +``` + +Example response (`200 OK`): + +```json +{ + "items": [ + { + "id": "0194f4a6-7ec7-78e6-9fe7-5ca35fef48db", + "name": "payment-cards", + "version": 1, + "format_type": "luhn-preserving", + "is_deterministic": true, + "created_at": "2026-02-18T10:30:00Z" + } + ] +} +``` + ## Create Tokenization Key Creates the initial tokenization key version (`version = 1`) for a key name. diff --git a/docs/api/data/transit.md b/docs/api/data/transit.md index 94a6d89..8b25836 100644 --- a/docs/api/data/transit.md +++ b/docs/api/data/transit.md @@ -28,6 +28,7 @@ All endpoints require Bearer authentication. ## Endpoints +- `GET /v1/transit/keys` (list keys) - `POST /v1/transit/keys` (create key) - `POST /v1/transit/keys/:name/rotate` (rotate key) - `DELETE /v1/transit/keys/:id` (soft delete key) @@ -36,6 +37,7 @@ All endpoints require Bearer authentication. Capability mapping: +- `GET /v1/transit/keys` -> `read` - `POST /v1/transit/keys` -> `write` - `POST /v1/transit/keys/:name/rotate` -> `rotate` - `DELETE /v1/transit/keys/:id` -> `delete` @@ -50,12 +52,38 @@ Wildcard matcher semantics reference: | Endpoint | Success | Common error statuses | | --- | --- | --- | +| `GET /v1/transit/keys` | `200` | `401`, `403`, `422`, `429` | | `POST /v1/transit/keys` | `201` | `401`, `403`, `409`, `422`, `429` | | `POST /v1/transit/keys/:name/rotate` | `200` | `401`, `403`, `404`, `422`, `429` | | `POST /v1/transit/keys/:name/encrypt` | `200` | `401`, `403`, `404`, `422`, `429` | | `POST /v1/transit/keys/:name/decrypt` | `200` | `401`, `403`, `404`, `422`, `429` | | `DELETE /v1/transit/keys/:id` | `204` | `401`, `403`, `404`, `422`, `429` | +## List Transit Keys + +```bash +curl "http://localhost:8080/v1/transit/keys?offset=0&limit=50" \ + -H "Authorization: Bearer " +``` + +Retrieves a paginated list of transit keys. Only the latest active version of each key name is returned. + +Example response (`200 OK`): + +```json +{ + "items": [ + { + "id": "0194f4a6-7ec7-78e6-9fe7-5ca35fef48db", + "name": "payment-data", + "algorithm": "aes-gcm", + "version": 2, + "created_at": "2026-02-15T10:30:00Z" + } + ] +} +``` + ## Create Transit Key Creates the initial transit key version (`version = 1`) for a key name. diff --git a/docs/metadata.json b/docs/metadata.json index 56b0544..f1a2244 100644 --- a/docs/metadata.json +++ b/docs/metadata.json @@ -1,5 +1,5 @@ { - "current_release": "v0.15.0", + "current_release": "v0.16.0", "api_version": "v1", "last_docs_refresh": "2026-02-25" } \ No newline at end of file diff --git a/docs/openapi.yaml b/docs/openapi.yaml index da20436..3c26490 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -141,6 +141,39 @@ paths: $ref: "#/components/schemas/ErrorResponse" "429": $ref: "#/components/responses/TooManyRequests" + /v1/secrets: + get: + tags: [secrets] + summary: List secrets + security: + - bearerAuth: [] + parameters: + - name: offset + in: query + schema: + type: integer + - name: limit + in: query + schema: + type: integer + responses: + "200": + description: Secrets list + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/SecretWriteResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "429": + $ref: "#/components/responses/TooManyRequests" /v1/secrets/{path}: parameters: - name: path @@ -219,6 +252,38 @@ paths: "429": $ref: "#/components/responses/TooManyRequests" /v1/transit/keys: + get: + tags: [transit] + summary: List transit keys + security: + - bearerAuth: [] + parameters: + - name: offset + in: query + schema: + type: integer + - name: limit + in: query + schema: + type: integer + responses: + "200": + description: Transit keys list + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/TransitKeyResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "429": + $ref: "#/components/responses/TooManyRequests" post: tags: [transit] summary: Create transit key @@ -360,6 +425,38 @@ paths: "429": $ref: "#/components/responses/TooManyRequests" /v1/tokenization/keys: + get: + tags: [tokenization] + summary: List tokenization keys + security: + - bearerAuth: [] + parameters: + - name: offset + in: query + schema: + type: integer + - name: limit + in: query + schema: + type: integer + responses: + "200": + description: Tokenization keys list + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/TokenizationKeyResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "429": + $ref: "#/components/responses/TooManyRequests" post: tags: [tokenization] summary: Create tokenization key diff --git a/docs/operations/deployment/production-rollout.md b/docs/operations/deployment/production-rollout.md index 6911b1d..6c3b1c3 100644 --- a/docs/operations/deployment/production-rollout.md +++ b/docs/operations/deployment/production-rollout.md @@ -186,7 +186,7 @@ docker run -d --name secrets-api \ ```bash # Update docker-compose.yml to use previous version -sed -i.bak 's|allisson/secrets:v0.15.0|allisson/secrets:v|' docker-compose.yml +sed -i.bak 's|allisson/secrets:v0.16.0|allisson/secrets:v|' docker-compose.yml # Restart service docker-compose up -d secrets-api @@ -239,7 +239,7 @@ docker run -d --name secrets-api \ --network secrets-net \ --env-file .env \ -p 8080:8080 \ - allisson/secrets:v0.15.0 server + allisson/secrets:v0.16.0 server # Verify health and functionality (repeat Step 3 checks) diff --git a/docs/releases/RELEASES.md b/docs/releases/RELEASES.md index 7e52bb5..91385a3 100644 --- a/docs/releases/RELEASES.md +++ b/docs/releases/RELEASES.md @@ -8,10 +8,12 @@ For the compatibility matrix across versions, see [compatibility-matrix.md](comp ## 📑 Quick Navigation -**Latest Release**: [v0.15.0](#0150---2026-02-25) +**Latest Release**: [v0.16.0](#0160---2026-02-25) **All Releases**: +- [v0.16.0 (2026-02-25)](#0160---2026-02-25) - Listing Endpoints + - [v0.15.0 (2026-02-25)](#0150---2026-02-25) - Goreleaser support - [v0.14.1 (2026-02-25)](#0141---2026-02-25) - KEK bug fixes @@ -50,6 +52,16 @@ For the compatibility matrix across versions, see [compatibility-matrix.md](comp --- +## [0.16.0] - 2026-02-25 + +### Added + +- Added `/v1/secrets` endpoint for listing secrets with pagination (does not return secret data) +- Added `/v1/transit/keys` endpoint for listing transit keys with pagination +- Added `/v1/tokenization/keys` endpoint for listing tokenization keys with pagination + +--- + ## [0.15.0] - 2026-02-25 ### Added diff --git a/docs/releases/compatibility-matrix.md b/docs/releases/compatibility-matrix.md index d6f4133..1d2033c 100644 --- a/docs/releases/compatibility-matrix.md +++ b/docs/releases/compatibility-matrix.md @@ -14,6 +14,7 @@ If you need upgrade guidance for older versions, consult the full release histor | From -> To | Schema migration impact | Runtime/default changes | Required operator action | | --- | --- | --- | --- | +| `v0.15.0 -> v0.16.0` | No schema migration required | Added list endpoints for secrets, transit keys, and tokenization keys | Verify capability mapping for read-only roles | | `v0.14.1 -> v0.15.0` | No schema migration required | Added Goreleaser support for automated builds | None (backward compatible, no runtime changes) | | `v0.14.0 -> v0.14.1` | No schema migration required | Fixed empty KEK chain bug | None (backward compatible, no runtime changes) | | `v0.13.0 -> v0.14.0` | No schema migration required | `/metrics` endpoint moved from port `8080` to `8081`. New `METRICS_PORT` env var (default: `8081`). | Update Prometheus/monitoring scrape configs to use port `8081`. Expose port `8081` in container orchestration if necessary. | @@ -32,6 +33,11 @@ If you need upgrade guidance for older versions, consult the full release histor ## Upgrade verification by target +For `v0.16.0`: + +1. `./bin/app --version` shows `v0.16.0` +2. `GET /v1/secrets`, `GET /v1/transit/keys`, and `GET /v1/tokenization/keys` return `200 OK` + For `v0.15.0`: 1. `./bin/app --version` shows `v0.15.0` diff --git a/internal/http/server.go b/internal/http/server.go index bac0ef0..3aca5cf 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -197,6 +197,10 @@ func (s *Server) SetupRouter( secrets.Use(rateLimitMiddleware) // Apply rate limiting to authenticated clients } { + secrets.GET("", + authHTTP.AuthorizationMiddleware(authDomain.ReadCapability, auditLogUseCase, s.logger), + secretHandler.ListHandler, + ) secrets.POST("/*path", authHTTP.AuthorizationMiddleware(authDomain.EncryptCapability, auditLogUseCase, s.logger), secretHandler.CreateOrUpdateHandler, @@ -220,6 +224,12 @@ func (s *Server) SetupRouter( { keys := transit.Group("/keys") { + // List transit keys + keys.GET("", + authHTTP.AuthorizationMiddleware(authDomain.ReadCapability, auditLogUseCase, s.logger), + transitKeyHandler.ListHandler, + ) + // Create new transit key keys.POST("", authHTTP.AuthorizationMiddleware(authDomain.WriteCapability, auditLogUseCase, s.logger), @@ -261,6 +271,12 @@ func (s *Server) SetupRouter( { keys := tokenization.Group("/keys") { + // List tokenization keys + keys.GET("", + authHTTP.AuthorizationMiddleware(authDomain.ReadCapability, auditLogUseCase, s.logger), + tokenizationKeyHandler.ListHandler, + ) + // Create new tokenization key keys.POST("", authHTTP.AuthorizationMiddleware(authDomain.WriteCapability, auditLogUseCase, s.logger), diff --git a/internal/secrets/http/dto/list_secrets_response.go b/internal/secrets/http/dto/list_secrets_response.go new file mode 100644 index 0000000..4915c13 --- /dev/null +++ b/internal/secrets/http/dto/list_secrets_response.go @@ -0,0 +1,27 @@ +package dto + +import ( + secretsDomain "github.com/allisson/secrets/internal/secrets/domain" +) + +// ListSecretsResponse represents a paginated list of secrets in API responses. +type ListSecretsResponse struct { + Data []SecretResponse `json:"data"` +} + +// MapSecretsToListResponse converts a slice of domain secrets to a list response. +func MapSecretsToListResponse(secrets []*secretsDomain.Secret) ListSecretsResponse { + data := make([]SecretResponse, 0, len(secrets)) + for _, secret := range secrets { + data = append(data, SecretResponse{ + ID: secret.ID.String(), + Path: secret.Path, + Version: secret.Version, + CreatedAt: secret.CreatedAt, + }) + } + + return ListSecretsResponse{ + Data: data, + } +} diff --git a/internal/secrets/http/dto/list_secrets_response_test.go b/internal/secrets/http/dto/list_secrets_response_test.go new file mode 100644 index 0000000..d4e895c --- /dev/null +++ b/internal/secrets/http/dto/list_secrets_response_test.go @@ -0,0 +1,43 @@ +package dto_test + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + secretsDomain "github.com/allisson/secrets/internal/secrets/domain" + "github.com/allisson/secrets/internal/secrets/http/dto" +) + +func TestMapSecretsToListResponse(t *testing.T) { + now := time.Now().UTC() + secrets := []*secretsDomain.Secret{ + { + ID: uuid.Must(uuid.NewV7()), + Path: "/test/1", + Version: 1, + CreatedAt: now, + }, + { + ID: uuid.Must(uuid.NewV7()), + Path: "/test/2", + Version: 2, + CreatedAt: now, + }, + } + + response := dto.MapSecretsToListResponse(secrets) + + assert.Len(t, response.Data, 2) + assert.Equal(t, secrets[0].ID.String(), response.Data[0].ID) + assert.Equal(t, secrets[0].Path, response.Data[0].Path) + assert.Equal(t, secrets[0].Version, response.Data[0].Version) + assert.Equal(t, secrets[0].CreatedAt, response.Data[0].CreatedAt) + + assert.Equal(t, secrets[1].ID.String(), response.Data[1].ID) + assert.Equal(t, secrets[1].Path, response.Data[1].Path) + assert.Equal(t, secrets[1].Version, response.Data[1].Version) + assert.Equal(t, secrets[1].CreatedAt, response.Data[1].CreatedAt) +} diff --git a/internal/secrets/http/secret_handler.go b/internal/secrets/http/secret_handler.go index 85aba34..c76bc41 100644 --- a/internal/secrets/http/secret_handler.go +++ b/internal/secrets/http/secret_handler.go @@ -166,3 +166,39 @@ func (h *SecretHandler) DeleteHandler(c *gin.Context) { // Return 204 No Content with empty body c.Data(http.StatusNoContent, "application/json", nil) } + +// ListHandler retrieves secrets with pagination support. +// GET /v1/secrets?offset=0&limit=50 - Requires ReadCapability. +// Returns 200 OK with paginated secret list (excludes plaintext value for security). +func (h *SecretHandler) ListHandler(c *gin.Context) { + // Parse offset query parameter (default: 0) + offsetStr := c.DefaultQuery("offset", "0") + offset, err := strconv.Atoi(offsetStr) + if err != nil || offset < 0 { + httputil.HandleValidationErrorGin(c, + fmt.Errorf("invalid offset parameter: must be a non-negative integer"), + h.logger) + return + } + + // Parse limit query parameter (default: 50, max: 100) + limitStr := c.DefaultQuery("limit", "50") + limit, err := strconv.Atoi(limitStr) + if err != nil || limit < 1 || limit > 100 { + httputil.HandleValidationErrorGin(c, + fmt.Errorf("invalid limit parameter: must be between 1 and 100"), + h.logger) + return + } + + // Call use case + secrets, err := h.secretUseCase.List(c.Request.Context(), offset, limit) + if err != nil { + httputil.HandleErrorGin(c, err, h.logger) + return + } + + // Map to response + response := dto.MapSecretsToListResponse(secrets) + c.JSON(http.StatusOK, response) +} diff --git a/internal/secrets/http/secret_handler_test.go b/internal/secrets/http/secret_handler_test.go index 3a5d92f..9b913de 100644 --- a/internal/secrets/http/secret_handler_test.go +++ b/internal/secrets/http/secret_handler_test.go @@ -440,3 +440,57 @@ func TestSecretHandler_DeleteHandler(t *testing.T) { assert.Contains(t, response["message"], "path cannot be empty") }) } + +func TestSecretHandler_ListHandler(t *testing.T) { + t.Run("Success_ListSecrets", func(t *testing.T) { + handler, mockUseCase := setupTestHandler(t) + + now := time.Now().UTC() + expectedSecrets := []*secretsDomain.Secret{ + { + ID: uuid.Must(uuid.NewV7()), + Path: "a/a", + Version: 1, + CreatedAt: now, + }, + { + ID: uuid.Must(uuid.NewV7()), + Path: "b/b", + Version: 2, + CreatedAt: now, + }, + } + + mockUseCase.EXPECT(). + List(mock.Anything, 0, 100). + Return(expectedSecrets, nil). + Once() + + c, w := createTestContext(http.MethodGet, "/v1/secrets?offset=0&limit=100", nil) + + handler.ListHandler(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var response dto.ListSecretsResponse + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Len(t, response.Data, 2) + assert.Equal(t, "a/a", response.Data[0].Path) + assert.Equal(t, "b/b", response.Data[1].Path) + }) + + t.Run("Error_InvalidPaginationParams", func(t *testing.T) { + handler, _ := setupTestHandler(t) + + c, w := createTestContext(http.MethodGet, "/v1/secrets?offset=invalid", nil) + + handler.ListHandler(c) + + assert.Equal(t, http.StatusBadRequest, w.Code) + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "validation_error", response["error"]) + }) +} diff --git a/internal/secrets/repository/mysql_secret_repository.go b/internal/secrets/repository/mysql_secret_repository.go index 8279ef9..95dc7a9 100644 --- a/internal/secrets/repository/mysql_secret_repository.go +++ b/internal/secrets/repository/mysql_secret_repository.go @@ -167,6 +167,75 @@ func (m *MySQLSecretRepository) Delete(ctx context.Context, secretID uuid.UUID) return nil } +// List retrieves secrets ordered by path ascending with pagination. +func (m *MySQLSecretRepository) List( + ctx context.Context, + offset, limit int, +) ([]*secretsDomain.Secret, error) { + querier := database.GetTx(ctx, m.db) + + query := ` + SELECT s.id, s.path, s.version, s.dek_id, s.ciphertext, s.nonce, s.created_at, s.deleted_at + FROM secrets s + INNER JOIN ( + SELECT path, MAX(version) as max_version + FROM secrets + WHERE deleted_at IS NULL + GROUP BY path + ORDER BY path ASC + LIMIT ? OFFSET ? + ) latest ON s.path = latest.path AND s.version = latest.max_version + ORDER BY s.path ASC` + + rows, err := querier.QueryContext(ctx, query, limit, offset) + if err != nil { + return nil, apperrors.Wrap(err, "failed to list secrets") + } + defer func() { + _ = rows.Close() + }() + + var secrets []*secretsDomain.Secret + for rows.Next() { + var secret secretsDomain.Secret + var id, dekID []byte + + err := rows.Scan( + &id, + &secret.Path, + &secret.Version, + &dekID, + &secret.Ciphertext, + &secret.Nonce, + &secret.CreatedAt, + &secret.DeletedAt, + ) + if err != nil { + return nil, apperrors.Wrap(err, "failed to scan secret") + } + + if err := secret.ID.UnmarshalBinary(id); err != nil { + return nil, apperrors.Wrap(err, "failed to unmarshal secret id") + } + + if err := secret.DekID.UnmarshalBinary(dekID); err != nil { + return nil, apperrors.Wrap(err, "failed to unmarshal dek id") + } + + secrets = append(secrets, &secret) + } + + if err := rows.Err(); err != nil { + return nil, apperrors.Wrap(err, "error iterating secrets") + } + + if secrets == nil { + secrets = make([]*secretsDomain.Secret, 0) + } + + return secrets, nil +} + // NewMySQLSecretRepository creates a new MySQL Secret repository instance. func NewMySQLSecretRepository(db *sql.DB) *MySQLSecretRepository { return &MySQLSecretRepository{db: db} diff --git a/internal/secrets/repository/mysql_secret_repository_test.go b/internal/secrets/repository/mysql_secret_repository_test.go index d0d6ffc..7d12629 100644 --- a/internal/secrets/repository/mysql_secret_repository_test.go +++ b/internal/secrets/repository/mysql_secret_repository_test.go @@ -1400,3 +1400,62 @@ func createMySQLDek(t *testing.T, db *sql.DB, kekID uuid.UUID) uuid.UUID { return dekID } + +func TestMySQLSecretRepository_List(t *testing.T) { + db := testutil.SetupMySQLDB(t) + defer testutil.TeardownDB(t, db) + defer testutil.CleanupMySQLDB(t, db) + + repo := NewMySQLSecretRepository(db) + ctx := context.Background() + _, dekID := createMySQLKekAndDek(t, db) + + // Create a few secrets + for i := 0; i < 5; i++ { + time.Sleep(time.Millisecond) // Ensure ordering + secret := &secretsDomain.Secret{ + ID: uuid.Must(uuid.NewV7()), + Path: fmt.Sprintf("/app/secret-%02d", i), + Version: 1, + DekID: dekID, + Ciphertext: []byte("encrypted-data"), + Nonce: []byte("nonce"), + CreatedAt: time.Now().UTC(), + } + err := repo.Create(ctx, secret) + require.NoError(t, err) + + // Create a second version for the same path + time.Sleep(time.Millisecond) + secretV2 := &secretsDomain.Secret{ + ID: uuid.Must(uuid.NewV7()), + Path: fmt.Sprintf("/app/secret-%02d", i), + Version: 2, + DekID: dekID, + Ciphertext: []byte("encrypted-data-v2"), + Nonce: []byte("nonce-v2"), + CreatedAt: time.Now().UTC(), + } + err = repo.Create(ctx, secretV2) + require.NoError(t, err) + } + + // Test pagination + secrets, err := repo.List(ctx, 0, 3) + require.NoError(t, err) + assert.Len(t, secrets, 3) + assert.Equal(t, "/app/secret-00", secrets[0].Path) + assert.Equal(t, uint(2), secrets[0].Version) + assert.Equal(t, "/app/secret-01", secrets[1].Path) + assert.Equal(t, uint(2), secrets[1].Version) + assert.Equal(t, "/app/secret-02", secrets[2].Path) + assert.Equal(t, uint(2), secrets[2].Version) + + secrets, err = repo.List(ctx, 3, 3) + require.NoError(t, err) + assert.Len(t, secrets, 2) + assert.Equal(t, "/app/secret-03", secrets[0].Path) + assert.Equal(t, uint(2), secrets[0].Version) + assert.Equal(t, "/app/secret-04", secrets[1].Path) + assert.Equal(t, uint(2), secrets[1].Version) +} diff --git a/internal/secrets/repository/postgresql_secret_repository.go b/internal/secrets/repository/postgresql_secret_repository.go index 312081d..e915b7d 100644 --- a/internal/secrets/repository/postgresql_secret_repository.go +++ b/internal/secrets/repository/postgresql_secret_repository.go @@ -133,6 +133,64 @@ func (p *PostgreSQLSecretRepository) Delete(ctx context.Context, secretID uuid.U return nil } +// List retrieves secrets ordered by path ascending with pagination. +func (p *PostgreSQLSecretRepository) List( + ctx context.Context, + offset, limit int, +) ([]*secretsDomain.Secret, error) { + querier := database.GetTx(ctx, p.db) + + query := ` + SELECT s.id, s.path, s.version, s.dek_id, s.ciphertext, s.nonce, s.created_at, s.deleted_at + FROM secrets s + INNER JOIN ( + SELECT path, MAX(version) as max_version + FROM secrets + WHERE deleted_at IS NULL + GROUP BY path + ORDER BY path ASC + LIMIT $1 OFFSET $2 + ) latest ON s.path = latest.path AND s.version = latest.max_version + ORDER BY s.path ASC` + + rows, err := querier.QueryContext(ctx, query, limit, offset) + if err != nil { + return nil, apperrors.Wrap(err, "failed to list secrets") + } + defer func() { + _ = rows.Close() + }() + + var secrets []*secretsDomain.Secret + for rows.Next() { + var secret secretsDomain.Secret + err := rows.Scan( + &secret.ID, + &secret.Path, + &secret.Version, + &secret.DekID, + &secret.Ciphertext, + &secret.Nonce, + &secret.CreatedAt, + &secret.DeletedAt, + ) + if err != nil { + return nil, apperrors.Wrap(err, "failed to scan secret") + } + secrets = append(secrets, &secret) + } + + if err := rows.Err(); err != nil { + return nil, apperrors.Wrap(err, "error iterating secrets") + } + + if secrets == nil { + secrets = make([]*secretsDomain.Secret, 0) + } + + return secrets, nil +} + // NewPostgreSQLSecretRepository creates a new PostgreSQL Secret repository instance. func NewPostgreSQLSecretRepository(db *sql.DB) *PostgreSQLSecretRepository { return &PostgreSQLSecretRepository{db: db} diff --git a/internal/secrets/repository/postgresql_secret_repository_test.go b/internal/secrets/repository/postgresql_secret_repository_test.go index b27c5af..7abfc24 100644 --- a/internal/secrets/repository/postgresql_secret_repository_test.go +++ b/internal/secrets/repository/postgresql_secret_repository_test.go @@ -1346,3 +1346,62 @@ func TestPostgreSQLSecretRepository_GetByPathAndVersion_WithTransaction(t *testi assert.Equal(t, uint(1), retrievedSecret.Version) assert.Equal(t, []byte("encrypted-v1"), retrievedSecret.Ciphertext) } + +func TestPostgreSQLSecretRepository_List(t *testing.T) { + db := testutil.SetupPostgresDB(t) + defer testutil.TeardownDB(t, db) + defer testutil.CleanupPostgresDB(t, db) + + repo := NewPostgreSQLSecretRepository(db) + ctx := context.Background() + _, dekID := createKekAndDek(t, db) + + // Create a few secrets + for i := 0; i < 5; i++ { + time.Sleep(time.Millisecond) // Ensure ordering + secret := &secretsDomain.Secret{ + ID: uuid.Must(uuid.NewV7()), + Path: fmt.Sprintf("/app/secret-%02d", i), + Version: 1, + DekID: dekID, + Ciphertext: []byte("encrypted-data"), + Nonce: []byte("nonce"), + CreatedAt: time.Now().UTC(), + } + err := repo.Create(ctx, secret) + require.NoError(t, err) + + // Create a second version for the same path + time.Sleep(time.Millisecond) + secretV2 := &secretsDomain.Secret{ + ID: uuid.Must(uuid.NewV7()), + Path: fmt.Sprintf("/app/secret-%02d", i), + Version: 2, + DekID: dekID, + Ciphertext: []byte("encrypted-data-v2"), + Nonce: []byte("nonce-v2"), + CreatedAt: time.Now().UTC(), + } + err = repo.Create(ctx, secretV2) + require.NoError(t, err) + } + + // Test pagination + secrets, err := repo.List(ctx, 0, 3) + require.NoError(t, err) + assert.Len(t, secrets, 3) + assert.Equal(t, "/app/secret-00", secrets[0].Path) + assert.Equal(t, uint(2), secrets[0].Version) + assert.Equal(t, "/app/secret-01", secrets[1].Path) + assert.Equal(t, uint(2), secrets[1].Version) + assert.Equal(t, "/app/secret-02", secrets[2].Path) + assert.Equal(t, uint(2), secrets[2].Version) + + secrets, err = repo.List(ctx, 3, 3) + require.NoError(t, err) + assert.Len(t, secrets, 2) + assert.Equal(t, "/app/secret-03", secrets[0].Path) + assert.Equal(t, uint(2), secrets[0].Version) + assert.Equal(t, "/app/secret-04", secrets[1].Path) + assert.Equal(t, uint(2), secrets[1].Version) +} diff --git a/internal/secrets/usecase/interface.go b/internal/secrets/usecase/interface.go index db8ca90..529a4fb 100644 --- a/internal/secrets/usecase/interface.go +++ b/internal/secrets/usecase/interface.go @@ -34,6 +34,10 @@ type SecretRepository interface { // GetByPathAndVersion retrieves a specific version of a secret. Returns ErrSecretNotFound if not found. GetByPathAndVersion(ctx context.Context, path string, version uint) (*secretsDomain.Secret, error) + + // List retrieves secrets ordered by path ascending with pagination. + // Returns the latest version for each secret. Uses offset and limit for pagination. + List(ctx context.Context, offset, limit int) ([]*secretsDomain.Secret, error) } // SecretUseCase defines the interface for secret management business logic. @@ -57,4 +61,8 @@ type SecretUseCase interface { // Delete soft deletes all versions of a secret by path, marking them with DeletedAt timestamp. // Preserves encrypted data for audit purposes while preventing future access. Delete(ctx context.Context, path string) error + + // List retrieves secrets without their values, ordered by path with pagination. + // Returns empty slice if no secrets found. + List(ctx context.Context, offset, limit int) ([]*secretsDomain.Secret, error) } diff --git a/internal/secrets/usecase/metrics_decorator.go b/internal/secrets/usecase/metrics_decorator.go index 193f59e..6ed91bb 100644 --- a/internal/secrets/usecase/metrics_decorator.go +++ b/internal/secrets/usecase/metrics_decorator.go @@ -93,3 +93,22 @@ func (s *secretUseCaseWithMetrics) Delete(ctx context.Context, path string) erro return err } + +// List records metrics for secret listing operations. +func (s *secretUseCaseWithMetrics) List( + ctx context.Context, + offset, limit int, +) ([]*secretsDomain.Secret, error) { + start := time.Now() + secrets, err := s.next.List(ctx, offset, limit) + + status := "success" + if err != nil { + status = "error" + } + + s.metrics.RecordOperation(ctx, "secrets", "secret_list", status) + s.metrics.RecordDuration(ctx, "secrets", "secret_list", time.Since(start), status) + + return secrets, err +} diff --git a/internal/secrets/usecase/mocks/mocks.go b/internal/secrets/usecase/mocks/mocks.go index a2b220c..e6fe236 100644 --- a/internal/secrets/usecase/mocks/mocks.go +++ b/internal/secrets/usecase/mocks/mocks.go @@ -448,6 +448,80 @@ func (_c *MockSecretRepository_GetByPathAndVersion_Call) RunAndReturn(run func(c return _c } +// List provides a mock function for the type MockSecretRepository +func (_mock *MockSecretRepository) List(ctx context.Context, offset int, limit int) ([]*domain0.Secret, error) { + ret := _mock.Called(ctx, offset, limit) + + if len(ret) == 0 { + panic("no return value specified for List") + } + + var r0 []*domain0.Secret + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) ([]*domain0.Secret, error)); ok { + return returnFunc(ctx, offset, limit) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) []*domain0.Secret); ok { + r0 = returnFunc(ctx, offset, limit) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*domain0.Secret) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, int, int) error); ok { + r1 = returnFunc(ctx, offset, limit) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockSecretRepository_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' +type MockSecretRepository_List_Call struct { + *mock.Call +} + +// List is a helper method to define mock.On call +// - ctx context.Context +// - offset int +// - limit int +func (_e *MockSecretRepository_Expecter) List(ctx interface{}, offset interface{}, limit interface{}) *MockSecretRepository_List_Call { + return &MockSecretRepository_List_Call{Call: _e.mock.On("List", ctx, offset, limit)} +} + +func (_c *MockSecretRepository_List_Call) Run(run func(ctx context.Context, offset int, limit int)) *MockSecretRepository_List_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 int + if args[1] != nil { + arg1 = args[1].(int) + } + var arg2 int + if args[2] != nil { + arg2 = args[2].(int) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockSecretRepository_List_Call) Return(secrets []*domain0.Secret, err error) *MockSecretRepository_List_Call { + _c.Call.Return(secrets, err) + return _c +} + +func (_c *MockSecretRepository_List_Call) RunAndReturn(run func(ctx context.Context, offset int, limit int) ([]*domain0.Secret, error)) *MockSecretRepository_List_Call { + _c.Call.Return(run) + return _c +} + // NewMockSecretUseCase creates a new instance of MockSecretUseCase. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMockSecretUseCase(t interface { @@ -747,3 +821,77 @@ func (_c *MockSecretUseCase_GetByVersion_Call) RunAndReturn(run func(ctx context _c.Call.Return(run) return _c } + +// List provides a mock function for the type MockSecretUseCase +func (_mock *MockSecretUseCase) List(ctx context.Context, offset int, limit int) ([]*domain0.Secret, error) { + ret := _mock.Called(ctx, offset, limit) + + if len(ret) == 0 { + panic("no return value specified for List") + } + + var r0 []*domain0.Secret + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) ([]*domain0.Secret, error)); ok { + return returnFunc(ctx, offset, limit) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) []*domain0.Secret); ok { + r0 = returnFunc(ctx, offset, limit) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*domain0.Secret) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, int, int) error); ok { + r1 = returnFunc(ctx, offset, limit) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockSecretUseCase_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' +type MockSecretUseCase_List_Call struct { + *mock.Call +} + +// List is a helper method to define mock.On call +// - ctx context.Context +// - offset int +// - limit int +func (_e *MockSecretUseCase_Expecter) List(ctx interface{}, offset interface{}, limit interface{}) *MockSecretUseCase_List_Call { + return &MockSecretUseCase_List_Call{Call: _e.mock.On("List", ctx, offset, limit)} +} + +func (_c *MockSecretUseCase_List_Call) Run(run func(ctx context.Context, offset int, limit int)) *MockSecretUseCase_List_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 int + if args[1] != nil { + arg1 = args[1].(int) + } + var arg2 int + if args[2] != nil { + arg2 = args[2].(int) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockSecretUseCase_List_Call) Return(secrets []*domain0.Secret, err error) *MockSecretUseCase_List_Call { + _c.Call.Return(secrets, err) + return _c +} + +func (_c *MockSecretUseCase_List_Call) RunAndReturn(run func(ctx context.Context, offset int, limit int) ([]*domain0.Secret, error)) *MockSecretUseCase_List_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/secrets/usecase/secret_usecase.go b/internal/secrets/usecase/secret_usecase.go index b66788d..1580430 100644 --- a/internal/secrets/usecase/secret_usecase.go +++ b/internal/secrets/usecase/secret_usecase.go @@ -199,6 +199,11 @@ func (s *secretUseCase) Delete(ctx context.Context, path string) error { return s.secretRepo.Delete(ctx, secret.ID) } +// List retrieves secrets without their values, ordered by path with pagination. +func (s *secretUseCase) List(ctx context.Context, offset, limit int) ([]*secretsDomain.Secret, error) { + return s.secretRepo.List(ctx, offset, limit) +} + // NewSecretUseCase creates a new secret use case instance with the provided dependencies. func NewSecretUseCase( txManager database.TxManager, diff --git a/internal/secrets/usecase/secret_usecase_test.go b/internal/secrets/usecase/secret_usecase_test.go index c61cee8..a5196c0 100644 --- a/internal/secrets/usecase/secret_usecase_test.go +++ b/internal/secrets/usecase/secret_usecase_test.go @@ -956,6 +956,99 @@ func TestSecretUseCase_Delete(t *testing.T) { }) } +// TestSecretUseCase_List tests the List method of secretUseCase. +func TestSecretUseCase_List(t *testing.T) { + ctx := context.Background() + + t.Run("Success_ListSecrets", func(t *testing.T) { + // Setup mocks + mockTxManager := databaseMocks.NewMockTxManager(t) + mockDekRepo := secretsUsecaseMocks.NewMockDekRepository(t) + mockSecretRepo := secretsUsecaseMocks.NewMockSecretRepository(t) + mockAEADManager := cryptoServiceMocks.NewMockAEADManager(t) + mockKeyManager := cryptoServiceMocks.NewMockKeyManager(t) + + kekChain := createKekChain([]*cryptoDomain.Kek{}) + defer kekChain.Close() + + expectedSecrets := []*secretsDomain.Secret{ + { + ID: uuid.Must(uuid.NewV7()), + Path: "sec-1", + Version: 1, + }, + { + ID: uuid.Must(uuid.NewV7()), + Path: "sec-2", + Version: 2, + }, + } + + mockSecretRepo.EXPECT(). + List(ctx, 0, 10). + Return(expectedSecrets, nil). + Once() + + // Execute + uc := NewSecretUseCase( + mockTxManager, + mockDekRepo, + mockSecretRepo, + kekChain, + mockAEADManager, + mockKeyManager, + cryptoDomain.AESGCM, + ) + + secrets, err := uc.List(ctx, 0, 10) + + // Assert + assert.NoError(t, err) + assert.Len(t, secrets, 2) + assert.Equal(t, "sec-1", secrets[0].Path) + assert.Equal(t, uint(1), secrets[0].Version) + assert.Equal(t, "sec-2", secrets[1].Path) + assert.Equal(t, uint(2), secrets[1].Version) + }) + + t.Run("Error_RepositoryFails", func(t *testing.T) { + // Setup mocks + mockTxManager := databaseMocks.NewMockTxManager(t) + mockDekRepo := secretsUsecaseMocks.NewMockDekRepository(t) + mockSecretRepo := secretsUsecaseMocks.NewMockSecretRepository(t) + mockAEADManager := cryptoServiceMocks.NewMockAEADManager(t) + mockKeyManager := cryptoServiceMocks.NewMockKeyManager(t) + + kekChain := createKekChain([]*cryptoDomain.Kek{}) + defer kekChain.Close() + + expectedErr := errors.New("db error") + + mockSecretRepo.EXPECT(). + List(ctx, 0, 10). + Return(nil, expectedErr). + Once() + + // Execute + uc := NewSecretUseCase( + mockTxManager, + mockDekRepo, + mockSecretRepo, + kekChain, + mockAEADManager, + mockKeyManager, + cryptoDomain.AESGCM, + ) + + secrets, err := uc.List(ctx, 0, 10) + + // Assert + assert.Error(t, err) + assert.Nil(t, secrets) + assert.Equal(t, expectedErr, err) + }) +} + // createKekChain is a helper function to create a KEK chain for testing. func createKekChain(keks []*cryptoDomain.Kek) *cryptoDomain.KekChain { if len(keks) == 0 { diff --git a/internal/tokenization/http/dto/list_tokenization_keys_response.go b/internal/tokenization/http/dto/list_tokenization_keys_response.go new file mode 100644 index 0000000..ce696eb --- /dev/null +++ b/internal/tokenization/http/dto/list_tokenization_keys_response.go @@ -0,0 +1,25 @@ +package dto + +import ( + tokenizationDomain "github.com/allisson/secrets/internal/tokenization/domain" +) + +// ListTokenizationKeysResponse represents the response for listing tokenization keys. +type ListTokenizationKeysResponse struct { + Items []TokenizationKeyResponse `json:"items"` +} + +// MapTokenizationKeysToListResponse maps a slice of TokenizationKey domain entities to a ListTokenizationKeysResponse DTO. +// Returns an empty list instead of null when there are no items to match API conventions. +func MapTokenizationKeysToListResponse( + keys []*tokenizationDomain.TokenizationKey, +) ListTokenizationKeysResponse { + items := make([]TokenizationKeyResponse, 0, len(keys)) + for _, key := range keys { + items = append(items, MapTokenizationKeyToResponse(key)) + } + + return ListTokenizationKeysResponse{ + Items: items, + } +} diff --git a/internal/tokenization/http/dto/list_tokenization_keys_response_test.go b/internal/tokenization/http/dto/list_tokenization_keys_response_test.go new file mode 100644 index 0000000..030075e --- /dev/null +++ b/internal/tokenization/http/dto/list_tokenization_keys_response_test.go @@ -0,0 +1,51 @@ +package dto_test + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + tokenizationDomain "github.com/allisson/secrets/internal/tokenization/domain" + "github.com/allisson/secrets/internal/tokenization/http/dto" +) + +func TestMapTokenizationKeysToListResponse(t *testing.T) { + now := time.Now().UTC() + keys := []*tokenizationDomain.TokenizationKey{ + { + ID: uuid.Must(uuid.NewV7()), + Name: "key-1", + Version: 1, + FormatType: tokenizationDomain.FormatUUID, + IsDeterministic: false, + CreatedAt: now, + }, + { + ID: uuid.Must(uuid.NewV7()), + Name: "key-2", + Version: 2, + FormatType: tokenizationDomain.FormatNumeric, + IsDeterministic: true, + CreatedAt: now, + }, + } + + response := dto.MapTokenizationKeysToListResponse(keys) + + assert.Len(t, response.Items, 2) + assert.Equal(t, keys[0].ID.String(), response.Items[0].ID) + assert.Equal(t, keys[0].Name, response.Items[0].Name) + assert.Equal(t, keys[0].Version, response.Items[0].Version) + assert.Equal(t, string(keys[0].FormatType), response.Items[0].FormatType) + assert.Equal(t, keys[0].IsDeterministic, response.Items[0].IsDeterministic) + assert.Equal(t, keys[0].CreatedAt, response.Items[0].CreatedAt) + + assert.Equal(t, keys[1].ID.String(), response.Items[1].ID) + assert.Equal(t, keys[1].Name, response.Items[1].Name) + assert.Equal(t, keys[1].Version, response.Items[1].Version) + assert.Equal(t, string(keys[1].FormatType), response.Items[1].FormatType) + assert.Equal(t, keys[1].IsDeterministic, response.Items[1].IsDeterministic) + assert.Equal(t, keys[1].CreatedAt, response.Items[1].CreatedAt) +} diff --git a/internal/tokenization/http/tokenization_key_handler.go b/internal/tokenization/http/tokenization_key_handler.go index df2cee4..f80bc6b 100644 --- a/internal/tokenization/http/tokenization_key_handler.go +++ b/internal/tokenization/http/tokenization_key_handler.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "net/http" + "strconv" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -162,3 +163,39 @@ func (h *TokenizationKeyHandler) DeleteHandler(c *gin.Context) { // Return 204 No Content c.Data(http.StatusNoContent, "application/json", nil) } + +// ListHandler retrieves tokenization keys with pagination support. +// GET /v1/tokenization/keys?offset=0&limit=50 - Requires ReadCapability. +// Returns 200 OK with paginated tokenization key list. +func (h *TokenizationKeyHandler) ListHandler(c *gin.Context) { + // Parse offset query parameter (default: 0) + offsetStr := c.DefaultQuery("offset", "0") + offset, err := strconv.Atoi(offsetStr) + if err != nil || offset < 0 { + httputil.HandleValidationErrorGin(c, + fmt.Errorf("invalid offset parameter: must be a non-negative integer"), + h.logger) + return + } + + // Parse limit query parameter (default: 50, max: 100) + limitStr := c.DefaultQuery("limit", "50") + limit, err := strconv.Atoi(limitStr) + if err != nil || limit < 1 || limit > 100 { + httputil.HandleValidationErrorGin(c, + fmt.Errorf("invalid limit parameter: must be between 1 and 100"), + h.logger) + return + } + + // Call use case + keys, err := h.keyUseCase.List(c.Request.Context(), offset, limit) + if err != nil { + httputil.HandleErrorGin(c, err, h.logger) + return + } + + // Map to response + response := dto.MapTokenizationKeysToListResponse(keys) + c.JSON(http.StatusOK, response) +} diff --git a/internal/tokenization/http/tokenization_key_handler_test.go b/internal/tokenization/http/tokenization_key_handler_test.go index 7b1ece3..6e21207 100644 --- a/internal/tokenization/http/tokenization_key_handler_test.go +++ b/internal/tokenization/http/tokenization_key_handler_test.go @@ -411,3 +411,50 @@ func TestTokenizationKeyHandler_DeleteHandler(t *testing.T) { assert.Equal(t, http.StatusInternalServerError, w.Code) }) } + +func TestTokenizationKeyHandler_ListHandler(t *testing.T) { + t.Run("Success_ListTokenizationKeys", func(t *testing.T) { + handler, mockUseCase := setupTestKeyHandler(t) + + now := time.Now().UTC() + expectedKeys := []*tokenizationDomain.TokenizationKey{ + { + ID: uuid.Must(uuid.NewV7()), + Name: "tok-key-1", + Version: 1, + CreatedAt: now, + }, + } + + mockUseCase.EXPECT(). + List(mock.Anything, 0, 100). + Return(expectedKeys, nil). + Once() + + c, w := createTestContext(http.MethodGet, "/v1/tokenization/keys?offset=0&limit=100", nil) + + handler.ListHandler(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var response dto.ListTokenizationKeysResponse + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Len(t, response.Items, 1) + assert.Equal(t, "tok-key-1", response.Items[0].Name) + }) + + t.Run("Error_InvalidPaginationParams", func(t *testing.T) { + handler, _ := setupTestKeyHandler(t) + + c, w := createTestContext(http.MethodGet, "/v1/tokenization/keys?offset=invalid", nil) + + handler.ListHandler(c) + + assert.Equal(t, http.StatusBadRequest, w.Code) + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "validation_error", response["error"]) + }) +} diff --git a/internal/tokenization/repository/mysql_repository.go b/internal/tokenization/repository/mysql_repository.go index da87602..77d3439 100644 --- a/internal/tokenization/repository/mysql_repository.go +++ b/internal/tokenization/repository/mysql_repository.go @@ -216,6 +216,78 @@ func (m *MySQLTokenizationKeyRepository) GetByNameAndVersion( return &key, nil } +// List retrieves tokenization keys ordered by name ascending with pagination. +// Returns the latest version for each key. +func (m *MySQLTokenizationKeyRepository) List( + ctx context.Context, + offset, limit int, +) ([]*tokenizationDomain.TokenizationKey, error) { + querier := database.GetTx(ctx, m.db) + + query := ` + SELECT tk.id, tk.name, tk.version, tk.format_type, tk.is_deterministic, tk.dek_id, tk.created_at, tk.deleted_at + FROM tokenization_keys tk + INNER JOIN ( + SELECT name, MAX(version) as max_version + FROM tokenization_keys + WHERE deleted_at IS NULL + GROUP BY name + ORDER BY name ASC + LIMIT ? OFFSET ? + ) latest ON tk.name = latest.name AND tk.version = latest.max_version + ORDER BY tk.name ASC` + + rows, err := querier.QueryContext(ctx, query, limit, offset) + if err != nil { + return nil, apperrors.Wrap(err, "failed to list tokenization keys") + } + defer func() { + _ = rows.Close() + }() + + var keys []*tokenizationDomain.TokenizationKey + for rows.Next() { + var key tokenizationDomain.TokenizationKey + var id, dekID []byte + var formatType string + + err := rows.Scan( + &id, + &key.Name, + &key.Version, + &formatType, + &key.IsDeterministic, + &dekID, + &key.CreatedAt, + &key.DeletedAt, + ) + if err != nil { + return nil, apperrors.Wrap(err, "failed to scan tokenization key") + } + + if err := key.ID.UnmarshalBinary(id); err != nil { + return nil, apperrors.Wrap(err, "failed to unmarshal tokenization key id") + } + + if err := key.DekID.UnmarshalBinary(dekID); err != nil { + return nil, apperrors.Wrap(err, "failed to unmarshal dek id") + } + + key.FormatType = tokenizationDomain.FormatType(formatType) + keys = append(keys, &key) + } + + if err := rows.Err(); err != nil { + return nil, apperrors.Wrap(err, "error iterating tokenization keys") + } + + if keys == nil { + keys = make([]*tokenizationDomain.TokenizationKey, 0) + } + + return keys, nil +} + // NewMySQLTokenizationKeyRepository creates a new MySQL tokenization key repository instance. func NewMySQLTokenizationKeyRepository(db *sql.DB) *MySQLTokenizationKeyRepository { return &MySQLTokenizationKeyRepository{db: db} diff --git a/internal/tokenization/repository/mysql_repository_test.go b/internal/tokenization/repository/mysql_repository_test.go index 6101dca..edc7726 100644 --- a/internal/tokenization/repository/mysql_repository_test.go +++ b/internal/tokenization/repository/mysql_repository_test.go @@ -3,6 +3,7 @@ package repository import ( "context" "database/sql" + "fmt" "testing" "time" @@ -439,3 +440,136 @@ func TestMySQLTokenRepository_DeleteExpired_ZeroTime(t *testing.T) { assert.Equal(t, int64(0), count) assert.Contains(t, err.Error(), "olderThan timestamp cannot be zero") } + +func TestMySQLTokenizationKeyRepository_List(t *testing.T) { + db := testutil.SetupMySQLDB(t) + defer testutil.TeardownDB(t, db) + defer testutil.CleanupMySQLDB(t, db) + + repo := NewMySQLTokenizationKeyRepository(db) + ctx := context.Background() + + _, dekID := createKekAndDekMySQL(t, db) + + for i := 0; i < 5; i++ { + time.Sleep(time.Millisecond) + key := &tokenizationDomain.TokenizationKey{ + ID: uuid.Must(uuid.NewV7()), + Name: fmt.Sprintf("tok-key-%02d", i), + Version: 1, + FormatType: tokenizationDomain.FormatUUID, + IsDeterministic: false, + DekID: dekID, + CreatedAt: time.Now().UTC(), + } + err := repo.Create(ctx, key) + require.NoError(t, err) + + time.Sleep(time.Millisecond) + keyV2 := &tokenizationDomain.TokenizationKey{ + ID: uuid.Must(uuid.NewV7()), + Name: fmt.Sprintf("tok-key-%02d", i), + Version: 2, + FormatType: tokenizationDomain.FormatUUID, + IsDeterministic: false, + DekID: dekID, + CreatedAt: time.Now().UTC(), + } + err = repo.Create(ctx, keyV2) + require.NoError(t, err) + } + + keys, err := repo.List(ctx, 0, 3) + require.NoError(t, err) + assert.Len(t, keys, 3) + assert.Equal(t, "tok-key-00", keys[0].Name) + assert.Equal(t, uint(2), keys[0].Version) + assert.Equal(t, "tok-key-01", keys[1].Name) + assert.Equal(t, "tok-key-02", keys[2].Name) + + keys, err = repo.List(ctx, 3, 3) + require.NoError(t, err) + assert.Len(t, keys, 2) + assert.Equal(t, "tok-key-03", keys[0].Name) + assert.Equal(t, "tok-key-04", keys[1].Name) +} + +func TestMySQLTokenizationKeyRepository_Get(t *testing.T) { + db := testutil.SetupMySQLDB(t) + defer testutil.TeardownDB(t, db) + defer testutil.CleanupMySQLDB(t, db) + + repo := NewMySQLTokenizationKeyRepository(db) + ctx := context.Background() + _, dekID := createKekAndDekMySQL(t, db) + + key := &tokenizationDomain.TokenizationKey{ + ID: uuid.Must(uuid.NewV7()), + Name: "get-test-key", + Version: 1, + FormatType: tokenizationDomain.FormatUUID, + IsDeterministic: false, + DekID: dekID, + CreatedAt: time.Now().UTC(), + } + err := repo.Create(ctx, key) + require.NoError(t, err) + + retrieved, err := repo.Get(ctx, key.ID) + require.NoError(t, err) + assert.Equal(t, key.ID, retrieved.ID) + assert.Equal(t, key.Name, retrieved.Name) +} + +func TestMySQLTokenizationKeyRepository_GetByNameAndVersion(t *testing.T) { + db := testutil.SetupMySQLDB(t) + defer testutil.TeardownDB(t, db) + defer testutil.CleanupMySQLDB(t, db) + + repo := NewMySQLTokenizationKeyRepository(db) + ctx := context.Background() + _, dekID := createKekAndDekMySQL(t, db) + + key := &tokenizationDomain.TokenizationKey{ + ID: uuid.Must(uuid.NewV7()), + Name: "get-name-version-key", + Version: 2, + FormatType: tokenizationDomain.FormatUUID, + IsDeterministic: false, + DekID: dekID, + CreatedAt: time.Now().UTC(), + } + err := repo.Create(ctx, key) + require.NoError(t, err) + + retrieved, err := repo.GetByNameAndVersion(ctx, key.Name, key.Version) + require.NoError(t, err) + assert.Equal(t, key.ID, retrieved.ID) + assert.Equal(t, key.Version, retrieved.Version) +} + +func TestMySQLTokenRepository_GetByToken(t *testing.T) { + db := testutil.SetupMySQLDB(t) + defer testutil.TeardownDB(t, db) + defer testutil.CleanupMySQLDB(t, db) + + tokenRepo := NewMySQLTokenRepository(db) + ctx := context.Background() + keyID := createTokenizationKeyMySQL(t, db) + + token := &tokenizationDomain.Token{ + ID: uuid.Must(uuid.NewV7()), + TokenizationKeyID: keyID, + Token: "tok_getbytoken", + Ciphertext: []byte("encrypted"), + Nonce: []byte("nonce"), + CreatedAt: time.Now().UTC(), + } + err := tokenRepo.Create(ctx, token) + require.NoError(t, err) + + retrieved, err := tokenRepo.GetByToken(ctx, token.Token) + require.NoError(t, err) + assert.Equal(t, token.ID, retrieved.ID) + assert.Equal(t, token.Token, retrieved.Token) +} diff --git a/internal/tokenization/repository/postgresql_repository.go b/internal/tokenization/repository/postgresql_repository.go index d42f0c2..d7822e5 100644 --- a/internal/tokenization/repository/postgresql_repository.go +++ b/internal/tokenization/repository/postgresql_repository.go @@ -171,6 +171,69 @@ func (p *PostgreSQLTokenizationKeyRepository) GetByNameAndVersion( return &key, nil } +// List retrieves tokenization keys ordered by name ascending with pagination. +// Returns the latest version for each key. +func (p *PostgreSQLTokenizationKeyRepository) List( + ctx context.Context, + offset, limit int, +) ([]*tokenizationDomain.TokenizationKey, error) { + querier := database.GetTx(ctx, p.db) + + query := ` + SELECT tk.id, tk.name, tk.version, tk.format_type, tk.is_deterministic, tk.dek_id, tk.created_at, tk.deleted_at + FROM tokenization_keys tk + INNER JOIN ( + SELECT name, MAX(version) as max_version + FROM tokenization_keys + WHERE deleted_at IS NULL + GROUP BY name + ORDER BY name ASC + LIMIT $1 OFFSET $2 + ) latest ON tk.name = latest.name AND tk.version = latest.max_version + ORDER BY tk.name ASC` + + rows, err := querier.QueryContext(ctx, query, limit, offset) + if err != nil { + return nil, apperrors.Wrap(err, "failed to list tokenization keys") + } + defer func() { + _ = rows.Close() + }() + + var keys []*tokenizationDomain.TokenizationKey + for rows.Next() { + var key tokenizationDomain.TokenizationKey + var formatType string + + err := rows.Scan( + &key.ID, + &key.Name, + &key.Version, + &formatType, + &key.IsDeterministic, + &key.DekID, + &key.CreatedAt, + &key.DeletedAt, + ) + if err != nil { + return nil, apperrors.Wrap(err, "failed to scan tokenization key") + } + + key.FormatType = tokenizationDomain.FormatType(formatType) + keys = append(keys, &key) + } + + if err := rows.Err(); err != nil { + return nil, apperrors.Wrap(err, "error iterating tokenization keys") + } + + if keys == nil { + keys = make([]*tokenizationDomain.TokenizationKey, 0) + } + + return keys, nil +} + // NewPostgreSQLTokenizationKeyRepository creates a new PostgreSQL tokenization key repository instance. func NewPostgreSQLTokenizationKeyRepository(db *sql.DB) *PostgreSQLTokenizationKeyRepository { return &PostgreSQLTokenizationKeyRepository{db: db} diff --git a/internal/tokenization/repository/postgresql_repository_test.go b/internal/tokenization/repository/postgresql_repository_test.go index e072588..ba1bb36 100644 --- a/internal/tokenization/repository/postgresql_repository_test.go +++ b/internal/tokenization/repository/postgresql_repository_test.go @@ -3,6 +3,7 @@ package repository import ( "context" "database/sql" + "fmt" "testing" "time" @@ -439,3 +440,136 @@ func TestPostgreSQLTokenRepository_DeleteExpired_ZeroTime(t *testing.T) { assert.Equal(t, int64(0), count) assert.Contains(t, err.Error(), "olderThan timestamp cannot be zero") } + +func TestPostgreSQLTokenizationKeyRepository_List(t *testing.T) { + db := testutil.SetupPostgresDB(t) + defer testutil.TeardownDB(t, db) + defer testutil.CleanupPostgresDB(t, db) + + repo := NewPostgreSQLTokenizationKeyRepository(db) + ctx := context.Background() + + _, dekID := createKekAndDek(t, db) + + for i := 0; i < 5; i++ { + time.Sleep(time.Millisecond) + key := &tokenizationDomain.TokenizationKey{ + ID: uuid.Must(uuid.NewV7()), + Name: fmt.Sprintf("tok-key-%02d", i), + Version: 1, + FormatType: tokenizationDomain.FormatUUID, + IsDeterministic: false, + DekID: dekID, + CreatedAt: time.Now().UTC(), + } + err := repo.Create(ctx, key) + require.NoError(t, err) + + time.Sleep(time.Millisecond) + keyV2 := &tokenizationDomain.TokenizationKey{ + ID: uuid.Must(uuid.NewV7()), + Name: fmt.Sprintf("tok-key-%02d", i), + Version: 2, + FormatType: tokenizationDomain.FormatUUID, + IsDeterministic: false, + DekID: dekID, + CreatedAt: time.Now().UTC(), + } + err = repo.Create(ctx, keyV2) + require.NoError(t, err) + } + + keys, err := repo.List(ctx, 0, 3) + require.NoError(t, err) + assert.Len(t, keys, 3) + assert.Equal(t, "tok-key-00", keys[0].Name) + assert.Equal(t, uint(2), keys[0].Version) + assert.Equal(t, "tok-key-01", keys[1].Name) + assert.Equal(t, "tok-key-02", keys[2].Name) + + keys, err = repo.List(ctx, 3, 3) + require.NoError(t, err) + assert.Len(t, keys, 2) + assert.Equal(t, "tok-key-03", keys[0].Name) + assert.Equal(t, "tok-key-04", keys[1].Name) +} + +func TestPostgreSQLTokenizationKeyRepository_Get(t *testing.T) { + db := testutil.SetupPostgresDB(t) + defer testutil.TeardownDB(t, db) + defer testutil.CleanupPostgresDB(t, db) + + repo := NewPostgreSQLTokenizationKeyRepository(db) + ctx := context.Background() + _, dekID := createKekAndDek(t, db) + + key := &tokenizationDomain.TokenizationKey{ + ID: uuid.Must(uuid.NewV7()), + Name: "get-test-key", + Version: 1, + FormatType: tokenizationDomain.FormatUUID, + IsDeterministic: false, + DekID: dekID, + CreatedAt: time.Now().UTC(), + } + err := repo.Create(ctx, key) + require.NoError(t, err) + + retrieved, err := repo.Get(ctx, key.ID) + require.NoError(t, err) + assert.Equal(t, key.ID, retrieved.ID) + assert.Equal(t, key.Name, retrieved.Name) +} + +func TestPostgreSQLTokenizationKeyRepository_GetByNameAndVersion(t *testing.T) { + db := testutil.SetupPostgresDB(t) + defer testutil.TeardownDB(t, db) + defer testutil.CleanupPostgresDB(t, db) + + repo := NewPostgreSQLTokenizationKeyRepository(db) + ctx := context.Background() + _, dekID := createKekAndDek(t, db) + + key := &tokenizationDomain.TokenizationKey{ + ID: uuid.Must(uuid.NewV7()), + Name: "get-name-version-key", + Version: 2, + FormatType: tokenizationDomain.FormatUUID, + IsDeterministic: false, + DekID: dekID, + CreatedAt: time.Now().UTC(), + } + err := repo.Create(ctx, key) + require.NoError(t, err) + + retrieved, err := repo.GetByNameAndVersion(ctx, key.Name, key.Version) + require.NoError(t, err) + assert.Equal(t, key.ID, retrieved.ID) + assert.Equal(t, key.Version, retrieved.Version) +} + +func TestPostgreSQLTokenRepository_GetByToken(t *testing.T) { + db := testutil.SetupPostgresDB(t) + defer testutil.TeardownDB(t, db) + defer testutil.CleanupPostgresDB(t, db) + + tokenRepo := NewPostgreSQLTokenRepository(db) + ctx := context.Background() + keyID := createTokenizationKey(t, db) + + token := &tokenizationDomain.Token{ + ID: uuid.Must(uuid.NewV7()), + TokenizationKeyID: keyID, + Token: "tok_getbytoken", + Ciphertext: []byte("encrypted"), + Nonce: []byte("nonce"), + CreatedAt: time.Now().UTC(), + } + err := tokenRepo.Create(ctx, token) + require.NoError(t, err) + + retrieved, err := tokenRepo.GetByToken(ctx, token.Token) + require.NoError(t, err) + assert.Equal(t, token.ID, retrieved.ID) + assert.Equal(t, token.Token, retrieved.Token) +} diff --git a/internal/tokenization/usecase/interface.go b/internal/tokenization/usecase/interface.go index a9b142b..f68de36 100644 --- a/internal/tokenization/usecase/interface.go +++ b/internal/tokenization/usecase/interface.go @@ -29,6 +29,10 @@ type TokenizationKeyRepository interface { name string, version uint, ) (*tokenizationDomain.TokenizationKey, error) + + // List retrieves tokenization keys ordered by name ascending with pagination. + // Returns the latest version for each key. + List(ctx context.Context, offset, limit int) ([]*tokenizationDomain.TokenizationKey, error) } // TokenRepository defines the interface for token mapping persistence. @@ -73,6 +77,10 @@ type TokenizationKeyUseCase interface { // Delete soft deletes a tokenization key and all its versions by key ID. Delete(ctx context.Context, keyID uuid.UUID) error + + // List retrieves tokenization keys ordered by name ascending with pagination. + // Returns the latest version for each key. + List(ctx context.Context, offset, limit int) ([]*tokenizationDomain.TokenizationKey, error) } // TokenizationUseCase defines the interface for token generation and management operations. diff --git a/internal/tokenization/usecase/mocks/mocks.go b/internal/tokenization/usecase/mocks/mocks.go index 44116e1..1918698 100644 --- a/internal/tokenization/usecase/mocks/mocks.go +++ b/internal/tokenization/usecase/mocks/mocks.go @@ -595,6 +595,80 @@ func (_c *MockTokenizationKeyRepository_GetByNameAndVersion_Call) RunAndReturn(r return _c } +// List provides a mock function for the type MockTokenizationKeyRepository +func (_mock *MockTokenizationKeyRepository) List(ctx context.Context, offset int, limit int) ([]*domain0.TokenizationKey, error) { + ret := _mock.Called(ctx, offset, limit) + + if len(ret) == 0 { + panic("no return value specified for List") + } + + var r0 []*domain0.TokenizationKey + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) ([]*domain0.TokenizationKey, error)); ok { + return returnFunc(ctx, offset, limit) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) []*domain0.TokenizationKey); ok { + r0 = returnFunc(ctx, offset, limit) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*domain0.TokenizationKey) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, int, int) error); ok { + r1 = returnFunc(ctx, offset, limit) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockTokenizationKeyRepository_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' +type MockTokenizationKeyRepository_List_Call struct { + *mock.Call +} + +// List is a helper method to define mock.On call +// - ctx context.Context +// - offset int +// - limit int +func (_e *MockTokenizationKeyRepository_Expecter) List(ctx interface{}, offset interface{}, limit interface{}) *MockTokenizationKeyRepository_List_Call { + return &MockTokenizationKeyRepository_List_Call{Call: _e.mock.On("List", ctx, offset, limit)} +} + +func (_c *MockTokenizationKeyRepository_List_Call) Run(run func(ctx context.Context, offset int, limit int)) *MockTokenizationKeyRepository_List_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 int + if args[1] != nil { + arg1 = args[1].(int) + } + var arg2 int + if args[2] != nil { + arg2 = args[2].(int) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockTokenizationKeyRepository_List_Call) Return(tokenizationKeys []*domain0.TokenizationKey, err error) *MockTokenizationKeyRepository_List_Call { + _c.Call.Return(tokenizationKeys, err) + return _c +} + +func (_c *MockTokenizationKeyRepository_List_Call) RunAndReturn(run func(ctx context.Context, offset int, limit int) ([]*domain0.TokenizationKey, error)) *MockTokenizationKeyRepository_List_Call { + _c.Call.Return(run) + return _c +} + // NewMockTokenRepository creates a new instance of MockTokenRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMockTokenRepository(t interface { @@ -1180,6 +1254,80 @@ func (_c *MockTokenizationKeyUseCase_Delete_Call) RunAndReturn(run func(ctx cont return _c } +// List provides a mock function for the type MockTokenizationKeyUseCase +func (_mock *MockTokenizationKeyUseCase) List(ctx context.Context, offset int, limit int) ([]*domain0.TokenizationKey, error) { + ret := _mock.Called(ctx, offset, limit) + + if len(ret) == 0 { + panic("no return value specified for List") + } + + var r0 []*domain0.TokenizationKey + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) ([]*domain0.TokenizationKey, error)); ok { + return returnFunc(ctx, offset, limit) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) []*domain0.TokenizationKey); ok { + r0 = returnFunc(ctx, offset, limit) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*domain0.TokenizationKey) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, int, int) error); ok { + r1 = returnFunc(ctx, offset, limit) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockTokenizationKeyUseCase_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' +type MockTokenizationKeyUseCase_List_Call struct { + *mock.Call +} + +// List is a helper method to define mock.On call +// - ctx context.Context +// - offset int +// - limit int +func (_e *MockTokenizationKeyUseCase_Expecter) List(ctx interface{}, offset interface{}, limit interface{}) *MockTokenizationKeyUseCase_List_Call { + return &MockTokenizationKeyUseCase_List_Call{Call: _e.mock.On("List", ctx, offset, limit)} +} + +func (_c *MockTokenizationKeyUseCase_List_Call) Run(run func(ctx context.Context, offset int, limit int)) *MockTokenizationKeyUseCase_List_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 int + if args[1] != nil { + arg1 = args[1].(int) + } + var arg2 int + if args[2] != nil { + arg2 = args[2].(int) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockTokenizationKeyUseCase_List_Call) Return(tokenizationKeys []*domain0.TokenizationKey, err error) *MockTokenizationKeyUseCase_List_Call { + _c.Call.Return(tokenizationKeys, err) + return _c +} + +func (_c *MockTokenizationKeyUseCase_List_Call) RunAndReturn(run func(ctx context.Context, offset int, limit int) ([]*domain0.TokenizationKey, error)) *MockTokenizationKeyUseCase_List_Call { + _c.Call.Return(run) + return _c +} + // Rotate provides a mock function for the type MockTokenizationKeyUseCase func (_mock *MockTokenizationKeyUseCase) Rotate(ctx context.Context, name string, formatType domain0.FormatType, isDeterministic bool, alg domain.Algorithm) (*domain0.TokenizationKey, error) { ret := _mock.Called(ctx, name, formatType, isDeterministic, alg) diff --git a/internal/tokenization/usecase/tokenization_key_metrics_decorator.go b/internal/tokenization/usecase/tokenization_key_metrics_decorator.go index e6c10a7..2abd920 100644 --- a/internal/tokenization/usecase/tokenization_key_metrics_decorator.go +++ b/internal/tokenization/usecase/tokenization_key_metrics_decorator.go @@ -87,3 +87,22 @@ func (t *tokenizationKeyUseCaseWithMetrics) Delete(ctx context.Context, tokeniza return err } + +// List records metrics for tokenization key listing operations. +func (t *tokenizationKeyUseCaseWithMetrics) List( + ctx context.Context, + offset, limit int, +) ([]*tokenizationDomain.TokenizationKey, error) { + start := time.Now() + keys, err := t.next.List(ctx, offset, limit) + + status := "success" + if err != nil { + status = "error" + } + + t.metrics.RecordOperation(ctx, "tokenization", "tokenization_key_list", status) + t.metrics.RecordDuration(ctx, "tokenization", "tokenization_key_list", time.Since(start), status) + + return keys, err +} diff --git a/internal/tokenization/usecase/tokenization_key_usecase.go b/internal/tokenization/usecase/tokenization_key_usecase.go index 257952b..7d252be 100644 --- a/internal/tokenization/usecase/tokenization_key_usecase.go +++ b/internal/tokenization/usecase/tokenization_key_usecase.go @@ -161,6 +161,14 @@ func (t *tokenizationKeyUseCase) Delete(ctx context.Context, keyID uuid.UUID) er return t.tokenizationKeyRepo.Delete(ctx, keyID) } +// List retrieves tokenization keys ordered by name ascending with pagination. +func (t *tokenizationKeyUseCase) List( + ctx context.Context, + offset, limit int, +) ([]*tokenizationDomain.TokenizationKey, error) { + return t.tokenizationKeyRepo.List(ctx, offset, limit) +} + // NewTokenizationKeyUseCase creates a new tokenization key use case instance. func NewTokenizationKeyUseCase( txManager database.TxManager, diff --git a/internal/tokenization/usecase/tokenization_key_usecase_test.go b/internal/tokenization/usecase/tokenization_key_usecase_test.go index 84bf4bb..3f29170 100644 --- a/internal/tokenization/usecase/tokenization_key_usecase_test.go +++ b/internal/tokenization/usecase/tokenization_key_usecase_test.go @@ -613,3 +613,90 @@ func TestTokenizationKeyUseCase_Delete(t *testing.T) { assert.Equal(t, expectedError, err) }) } + +// TestTokenizationKeyUseCase_List tests the List method of tokenizationKeyUseCase. +func TestTokenizationKeyUseCase_List(t *testing.T) { + ctx := context.Background() + + t.Run("Success_ListTokenizationKeys", func(t *testing.T) { + // Setup mocks + mockTxManager := databaseMocks.NewMockTxManager(t) + mockTokenizationKeyRepo := tokenizationMocks.NewMockTokenizationKeyRepository(t) + mockDekRepo := tokenizationMocks.NewMockDekRepository(t) + mockKeyManager := cryptoServiceMocks.NewMockKeyManager(t) + + kekChain := cryptoDomain.NewKekChain([]*cryptoDomain.Kek{}) + defer kekChain.Close() + + expectedKeys := []*tokenizationDomain.TokenizationKey{ + { + ID: uuid.Must(uuid.NewV7()), + Name: "tok-1", + Version: 1, + }, + { + ID: uuid.Must(uuid.NewV7()), + Name: "tok-2", + Version: 2, + }, + } + + mockTokenizationKeyRepo.EXPECT(). + List(ctx, 0, 10). + Return(expectedKeys, nil). + Once() + + // Execute + uc := NewTokenizationKeyUseCase( + mockTxManager, + mockTokenizationKeyRepo, + mockDekRepo, + mockKeyManager, + kekChain, + ) + + keys, err := uc.List(ctx, 0, 10) + + // Assert + assert.NoError(t, err) + assert.Len(t, keys, 2) + assert.Equal(t, "tok-1", keys[0].Name) + assert.Equal(t, uint(1), keys[0].Version) + assert.Equal(t, "tok-2", keys[1].Name) + assert.Equal(t, uint(2), keys[1].Version) + }) + + t.Run("Error_RepositoryFails", func(t *testing.T) { + // Setup mocks + mockTxManager := databaseMocks.NewMockTxManager(t) + mockTokenizationKeyRepo := tokenizationMocks.NewMockTokenizationKeyRepository(t) + mockDekRepo := tokenizationMocks.NewMockDekRepository(t) + mockKeyManager := cryptoServiceMocks.NewMockKeyManager(t) + + kekChain := cryptoDomain.NewKekChain([]*cryptoDomain.Kek{}) + defer kekChain.Close() + + expectedErr := errors.New("db error") + + mockTokenizationKeyRepo.EXPECT(). + List(ctx, 0, 10). + Return(nil, expectedErr). + Once() + + // Execute + uc := NewTokenizationKeyUseCase( + mockTxManager, + mockTokenizationKeyRepo, + mockDekRepo, + mockKeyManager, + kekChain, + ) + + keys, err := uc.List(ctx, 0, 10) + + // Assert + assert.Error(t, err) + assert.Nil(t, keys) + assert.Equal(t, expectedErr, err) + }) +} diff --git a/internal/transit/http/dto/list_transit_keys_response.go b/internal/transit/http/dto/list_transit_keys_response.go new file mode 100644 index 0000000..acc6278 --- /dev/null +++ b/internal/transit/http/dto/list_transit_keys_response.go @@ -0,0 +1,22 @@ +package dto + +import ( + transitDomain "github.com/allisson/secrets/internal/transit/domain" +) + +// ListTransitKeysResponse represents a paginated list of transit keys in API responses. +type ListTransitKeysResponse struct { + Data []TransitKeyResponse `json:"data"` +} + +// MapTransitKeysToListResponse converts a slice of domain transit keys to a list response. +func MapTransitKeysToListResponse(transitKeys []*transitDomain.TransitKey) ListTransitKeysResponse { + data := make([]TransitKeyResponse, 0, len(transitKeys)) + for _, tk := range transitKeys { + data = append(data, MapTransitKeyToResponse(tk)) + } + + return ListTransitKeysResponse{ + Data: data, + } +} diff --git a/internal/transit/http/dto/list_transit_keys_response_test.go b/internal/transit/http/dto/list_transit_keys_response_test.go new file mode 100644 index 0000000..a27c652 --- /dev/null +++ b/internal/transit/http/dto/list_transit_keys_response_test.go @@ -0,0 +1,43 @@ +package dto_test + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + transitDomain "github.com/allisson/secrets/internal/transit/domain" + "github.com/allisson/secrets/internal/transit/http/dto" +) + +func TestMapTransitKeysToListResponse(t *testing.T) { + now := time.Now().UTC() + keys := []*transitDomain.TransitKey{ + { + ID: uuid.Must(uuid.NewV7()), + Name: "key-1", + Version: 1, + CreatedAt: now, + }, + { + ID: uuid.Must(uuid.NewV7()), + Name: "key-2", + Version: 2, + CreatedAt: now, + }, + } + + response := dto.MapTransitKeysToListResponse(keys) + + assert.Len(t, response.Data, 2) + assert.Equal(t, keys[0].ID.String(), response.Data[0].ID) + assert.Equal(t, keys[0].Name, response.Data[0].Name) + assert.Equal(t, keys[0].Version, response.Data[0].Version) + assert.Equal(t, keys[0].CreatedAt, response.Data[0].CreatedAt) + + assert.Equal(t, keys[1].ID.String(), response.Data[1].ID) + assert.Equal(t, keys[1].Name, response.Data[1].Name) + assert.Equal(t, keys[1].Version, response.Data[1].Version) + assert.Equal(t, keys[1].CreatedAt, response.Data[1].CreatedAt) +} diff --git a/internal/transit/http/transit_key_handler.go b/internal/transit/http/transit_key_handler.go index dee2885..7893ad4 100644 --- a/internal/transit/http/transit_key_handler.go +++ b/internal/transit/http/transit_key_handler.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "net/http" + "strconv" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -144,3 +145,39 @@ func (h *TransitKeyHandler) DeleteHandler(c *gin.Context) { // Return 204 No Content with empty body c.Data(http.StatusNoContent, "application/json", nil) } + +// ListHandler retrieves transit keys with pagination support. +// GET /v1/transit/keys?offset=0&limit=50 - Requires ReadCapability. +// Returns 200 OK with paginated transit key list. +func (h *TransitKeyHandler) ListHandler(c *gin.Context) { + // Parse offset query parameter (default: 0) + offsetStr := c.DefaultQuery("offset", "0") + offset, err := strconv.Atoi(offsetStr) + if err != nil || offset < 0 { + httputil.HandleValidationErrorGin(c, + fmt.Errorf("invalid offset parameter: must be a non-negative integer"), + h.logger) + return + } + + // Parse limit query parameter (default: 50, max: 100) + limitStr := c.DefaultQuery("limit", "50") + limit, err := strconv.Atoi(limitStr) + if err != nil || limit < 1 || limit > 100 { + httputil.HandleValidationErrorGin(c, + fmt.Errorf("invalid limit parameter: must be between 1 and 100"), + h.logger) + return + } + + // Call use case + transitKeys, err := h.transitKeyUseCase.List(c.Request.Context(), offset, limit) + if err != nil { + httputil.HandleErrorGin(c, err, h.logger) + return + } + + // Map to response + response := dto.MapTransitKeysToListResponse(transitKeys) + c.JSON(http.StatusOK, response) +} diff --git a/internal/transit/http/transit_key_handler_test.go b/internal/transit/http/transit_key_handler_test.go index c08bf33..1e5e1e3 100644 --- a/internal/transit/http/transit_key_handler_test.go +++ b/internal/transit/http/transit_key_handler_test.go @@ -344,3 +344,50 @@ func TestTransitKeyHandler_DeleteHandler(t *testing.T) { assert.Equal(t, http.StatusNotFound, w.Code) }) } + +func TestTransitKeyHandler_ListHandler(t *testing.T) { + t.Run("Success_ListTransitKeys", func(t *testing.T) { + handler, mockUseCase := setupTestTransitKeyHandler(t) + + now := time.Now().UTC() + expectedKeys := []*transitDomain.TransitKey{ + { + ID: uuid.Must(uuid.NewV7()), + Name: "key-1", + Version: 1, + CreatedAt: now, + }, + } + + mockUseCase.EXPECT(). + List(mock.Anything, 0, 100). + Return(expectedKeys, nil). + Once() + + c, w := createTestContext(http.MethodGet, "/v1/transit/keys?offset=0&limit=100", nil) + + handler.ListHandler(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var response dto.ListTransitKeysResponse + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Len(t, response.Data, 1) + assert.Equal(t, "key-1", response.Data[0].Name) + }) + + t.Run("Error_InvalidPaginationParams", func(t *testing.T) { + handler, _ := setupTestTransitKeyHandler(t) + + c, w := createTestContext(http.MethodGet, "/v1/transit/keys?offset=invalid", nil) + + handler.ListHandler(c) + + assert.Equal(t, http.StatusBadRequest, w.Code) + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "validation_error", response["error"]) + }) +} diff --git a/internal/transit/repository/mysql_transit_key_repository.go b/internal/transit/repository/mysql_transit_key_repository.go index 46f8690..73a847f 100644 --- a/internal/transit/repository/mysql_transit_key_repository.go +++ b/internal/transit/repository/mysql_transit_key_repository.go @@ -154,6 +154,74 @@ func (m *MySQLTransitKeyRepository) GetByNameAndVersion( return &transitKey, nil } +// List retrieves transit keys ordered by name ascending with pagination. +// Returns the latest version for each key. +func (m *MySQLTransitKeyRepository) List( + ctx context.Context, + offset, limit int, +) ([]*transitDomain.TransitKey, error) { + querier := database.GetTx(ctx, m.db) + + query := ` + SELECT tk.id, tk.name, tk.version, tk.dek_id, tk.created_at, tk.deleted_at + FROM transit_keys tk + INNER JOIN ( + SELECT name, MAX(version) as max_version + FROM transit_keys + WHERE deleted_at IS NULL + GROUP BY name + ORDER BY name ASC + LIMIT ? OFFSET ? + ) latest ON tk.name = latest.name AND tk.version = latest.max_version + ORDER BY tk.name ASC` + + rows, err := querier.QueryContext(ctx, query, limit, offset) + if err != nil { + return nil, apperrors.Wrap(err, "failed to list transit keys") + } + defer func() { + _ = rows.Close() + }() + + var transitKeys []*transitDomain.TransitKey + for rows.Next() { + var transitKey transitDomain.TransitKey + var id, dekID []byte + + err := rows.Scan( + &id, + &transitKey.Name, + &transitKey.Version, + &dekID, + &transitKey.CreatedAt, + &transitKey.DeletedAt, + ) + if err != nil { + return nil, apperrors.Wrap(err, "failed to scan transit key") + } + + if err := transitKey.ID.UnmarshalBinary(id); err != nil { + return nil, apperrors.Wrap(err, "failed to unmarshal transit key id") + } + + if err := transitKey.DekID.UnmarshalBinary(dekID); err != nil { + return nil, apperrors.Wrap(err, "failed to unmarshal dek id") + } + + transitKeys = append(transitKeys, &transitKey) + } + + if err := rows.Err(); err != nil { + return nil, apperrors.Wrap(err, "error iterating transit keys") + } + + if transitKeys == nil { + transitKeys = make([]*transitDomain.TransitKey, 0) + } + + return transitKeys, nil +} + // NewMySQLTransitKeyRepository creates a new MySQL transit key repository instance. func NewMySQLTransitKeyRepository(db *sql.DB) *MySQLTransitKeyRepository { return &MySQLTransitKeyRepository{db: db} diff --git a/internal/transit/repository/mysql_transit_key_repository_test.go b/internal/transit/repository/mysql_transit_key_repository_test.go index baa880b..1c47207 100644 --- a/internal/transit/repository/mysql_transit_key_repository_test.go +++ b/internal/transit/repository/mysql_transit_key_repository_test.go @@ -3,6 +3,7 @@ package repository import ( "context" "database/sql" + "fmt" "testing" "time" @@ -854,3 +855,58 @@ func createTestDekMySQL(t *testing.T, db *sql.DB) uuid.UUID { return dekID } + +func TestMySQLTransitKeyRepository_List(t *testing.T) { + db := testutil.SetupMySQLDB(t) + defer testutil.TeardownDB(t, db) + defer testutil.CleanupMySQLDB(t, db) + + repo := NewMySQLTransitKeyRepository(db) + ctx := context.Background() + + dekID := createTestDekMySQL(t, db) + + // Create a few keys + for i := 0; i < 5; i++ { + time.Sleep(time.Millisecond) + key := &transitDomain.TransitKey{ + ID: uuid.Must(uuid.NewV7()), + Name: fmt.Sprintf("key-%02d", i), + Version: 1, + DekID: dekID, + CreatedAt: time.Now().UTC(), + } + err := repo.Create(ctx, key) + require.NoError(t, err) + + time.Sleep(time.Millisecond) + keyV2 := &transitDomain.TransitKey{ + ID: uuid.Must(uuid.NewV7()), + Name: fmt.Sprintf("key-%02d", i), + Version: 2, + DekID: dekID, + CreatedAt: time.Now().UTC(), + } + err = repo.Create(ctx, keyV2) + require.NoError(t, err) + } + + // Test pagination + keys, err := repo.List(ctx, 0, 3) + require.NoError(t, err) + assert.Len(t, keys, 3) + assert.Equal(t, "key-00", keys[0].Name) + assert.Equal(t, uint(2), keys[0].Version) + assert.Equal(t, "key-01", keys[1].Name) + assert.Equal(t, uint(2), keys[1].Version) + assert.Equal(t, "key-02", keys[2].Name) + assert.Equal(t, uint(2), keys[2].Version) + + keys, err = repo.List(ctx, 3, 3) + require.NoError(t, err) + assert.Len(t, keys, 2) + assert.Equal(t, "key-03", keys[0].Name) + assert.Equal(t, uint(2), keys[0].Version) + assert.Equal(t, "key-04", keys[1].Name) + assert.Equal(t, uint(2), keys[1].Version) +} diff --git a/internal/transit/repository/postgresql_transit_key_repository.go b/internal/transit/repository/postgresql_transit_key_repository.go index 58d858e..9bdf1fa 100644 --- a/internal/transit/repository/postgresql_transit_key_repository.go +++ b/internal/transit/repository/postgresql_transit_key_repository.go @@ -122,6 +122,63 @@ func (p *PostgreSQLTransitKeyRepository) GetByNameAndVersion( return &transitKey, nil } +// List retrieves transit keys ordered by name ascending with pagination. +// Returns the latest version for each key. +func (p *PostgreSQLTransitKeyRepository) List( + ctx context.Context, + offset, limit int, +) ([]*transitDomain.TransitKey, error) { + querier := database.GetTx(ctx, p.db) + + query := ` + SELECT tk.id, tk.name, tk.version, tk.dek_id, tk.created_at, tk.deleted_at + FROM transit_keys tk + INNER JOIN ( + SELECT name, MAX(version) as max_version + FROM transit_keys + WHERE deleted_at IS NULL + GROUP BY name + ORDER BY name ASC + LIMIT $1 OFFSET $2 + ) latest ON tk.name = latest.name AND tk.version = latest.max_version + ORDER BY tk.name ASC` + + rows, err := querier.QueryContext(ctx, query, limit, offset) + if err != nil { + return nil, apperrors.Wrap(err, "failed to list transit keys") + } + defer func() { + _ = rows.Close() + }() + + var transitKeys []*transitDomain.TransitKey + for rows.Next() { + var transitKey transitDomain.TransitKey + err := rows.Scan( + &transitKey.ID, + &transitKey.Name, + &transitKey.Version, + &transitKey.DekID, + &transitKey.CreatedAt, + &transitKey.DeletedAt, + ) + if err != nil { + return nil, apperrors.Wrap(err, "failed to scan transit key") + } + transitKeys = append(transitKeys, &transitKey) + } + + if err := rows.Err(); err != nil { + return nil, apperrors.Wrap(err, "error iterating transit keys") + } + + if transitKeys == nil { + transitKeys = make([]*transitDomain.TransitKey, 0) + } + + return transitKeys, nil +} + // NewPostgreSQLTransitKeyRepository creates a new PostgreSQL transit key repository instance. func NewPostgreSQLTransitKeyRepository(db *sql.DB) *PostgreSQLTransitKeyRepository { return &PostgreSQLTransitKeyRepository{db: db} diff --git a/internal/transit/repository/postgresql_transit_key_repository_test.go b/internal/transit/repository/postgresql_transit_key_repository_test.go index 6a4036e..a2c032e 100644 --- a/internal/transit/repository/postgresql_transit_key_repository_test.go +++ b/internal/transit/repository/postgresql_transit_key_repository_test.go @@ -3,6 +3,7 @@ package repository import ( "context" "database/sql" + "fmt" "testing" "time" @@ -803,3 +804,58 @@ func createTestDek(t *testing.T, db *sql.DB) uuid.UUID { return dekID } + +func TestPostgreSQLTransitKeyRepository_List(t *testing.T) { + db := testutil.SetupPostgresDB(t) + defer testutil.TeardownDB(t, db) + defer testutil.CleanupPostgresDB(t, db) + + repo := NewPostgreSQLTransitKeyRepository(db) + ctx := context.Background() + + dekID := createTestDek(t, db) + + // Create a few keys + for i := 0; i < 5; i++ { + time.Sleep(time.Millisecond) + key := &transitDomain.TransitKey{ + ID: uuid.Must(uuid.NewV7()), + Name: fmt.Sprintf("key-%02d", i), + Version: 1, + DekID: dekID, + CreatedAt: time.Now().UTC(), + } + err := repo.Create(ctx, key) + require.NoError(t, err) + + time.Sleep(time.Millisecond) + keyV2 := &transitDomain.TransitKey{ + ID: uuid.Must(uuid.NewV7()), + Name: fmt.Sprintf("key-%02d", i), + Version: 2, + DekID: dekID, + CreatedAt: time.Now().UTC(), + } + err = repo.Create(ctx, keyV2) + require.NoError(t, err) + } + + // Test pagination + keys, err := repo.List(ctx, 0, 3) + require.NoError(t, err) + assert.Len(t, keys, 3) + assert.Equal(t, "key-00", keys[0].Name) + assert.Equal(t, uint(2), keys[0].Version) + assert.Equal(t, "key-01", keys[1].Name) + assert.Equal(t, uint(2), keys[1].Version) + assert.Equal(t, "key-02", keys[2].Name) + assert.Equal(t, uint(2), keys[2].Version) + + keys, err = repo.List(ctx, 3, 3) + require.NoError(t, err) + assert.Len(t, keys, 2) + assert.Equal(t, "key-03", keys[0].Name) + assert.Equal(t, uint(2), keys[0].Version) + assert.Equal(t, "key-04", keys[1].Name) + assert.Equal(t, uint(2), keys[1].Version) +} diff --git a/internal/transit/usecase/interface.go b/internal/transit/usecase/interface.go index 0cc2ab1..ea7b0c6 100644 --- a/internal/transit/usecase/interface.go +++ b/internal/transit/usecase/interface.go @@ -33,6 +33,10 @@ type TransitKeyRepository interface { // GetByNameAndVersion retrieves a specific version of a transit key. Returns ErrTransitKeyNotFound if not found. GetByNameAndVersion(ctx context.Context, name string, version uint) (*transitDomain.TransitKey, error) + + // List retrieves transit keys ordered by name ascending with pagination. + // Returns the latest version for each key. + List(ctx context.Context, offset, limit int) ([]*transitDomain.TransitKey, error) } // TransitKeyUseCase defines the interface for transit encryption operations. @@ -58,4 +62,8 @@ type TransitKeyUseCase interface { // Security Note: The returned EncryptedBlob contains plaintext data in the Plaintext field. // Callers MUST zero this data after use by calling cryptoDomain.Zero(blob.Plaintext). Decrypt(ctx context.Context, name string, ciphertext string) (*transitDomain.EncryptedBlob, error) + + // List retrieves transit keys ordered by name ascending with pagination. + // Returns the latest version for each key. + List(ctx context.Context, offset, limit int) ([]*transitDomain.TransitKey, error) } diff --git a/internal/transit/usecase/metrics_decorator.go b/internal/transit/usecase/metrics_decorator.go index 6452a70..3086048 100644 --- a/internal/transit/usecase/metrics_decorator.go +++ b/internal/transit/usecase/metrics_decorator.go @@ -120,3 +120,22 @@ func (t *transitKeyUseCaseWithMetrics) Decrypt( return blob, err } + +// List records metrics for transit listing operations. +func (t *transitKeyUseCaseWithMetrics) List( + ctx context.Context, + offset, limit int, +) ([]*transitDomain.TransitKey, error) { + start := time.Now() + keys, err := t.next.List(ctx, offset, limit) + + status := "success" + if err != nil { + status = "error" + } + + t.metrics.RecordOperation(ctx, "transit", "transit_key_list", status) + t.metrics.RecordDuration(ctx, "transit", "transit_key_list", time.Since(start), status) + + return keys, err +} diff --git a/internal/transit/usecase/mocks/mocks.go b/internal/transit/usecase/mocks/mocks.go index 6e85423..fbdb644 100644 --- a/internal/transit/usecase/mocks/mocks.go +++ b/internal/transit/usecase/mocks/mocks.go @@ -448,6 +448,80 @@ func (_c *MockTransitKeyRepository_GetByNameAndVersion_Call) RunAndReturn(run fu return _c } +// List provides a mock function for the type MockTransitKeyRepository +func (_mock *MockTransitKeyRepository) List(ctx context.Context, offset int, limit int) ([]*domain0.TransitKey, error) { + ret := _mock.Called(ctx, offset, limit) + + if len(ret) == 0 { + panic("no return value specified for List") + } + + var r0 []*domain0.TransitKey + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) ([]*domain0.TransitKey, error)); ok { + return returnFunc(ctx, offset, limit) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) []*domain0.TransitKey); ok { + r0 = returnFunc(ctx, offset, limit) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*domain0.TransitKey) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, int, int) error); ok { + r1 = returnFunc(ctx, offset, limit) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockTransitKeyRepository_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' +type MockTransitKeyRepository_List_Call struct { + *mock.Call +} + +// List is a helper method to define mock.On call +// - ctx context.Context +// - offset int +// - limit int +func (_e *MockTransitKeyRepository_Expecter) List(ctx interface{}, offset interface{}, limit interface{}) *MockTransitKeyRepository_List_Call { + return &MockTransitKeyRepository_List_Call{Call: _e.mock.On("List", ctx, offset, limit)} +} + +func (_c *MockTransitKeyRepository_List_Call) Run(run func(ctx context.Context, offset int, limit int)) *MockTransitKeyRepository_List_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 int + if args[1] != nil { + arg1 = args[1].(int) + } + var arg2 int + if args[2] != nil { + arg2 = args[2].(int) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockTransitKeyRepository_List_Call) Return(transitKeys []*domain0.TransitKey, err error) *MockTransitKeyRepository_List_Call { + _c.Call.Return(transitKeys, err) + return _c +} + +func (_c *MockTransitKeyRepository_List_Call) RunAndReturn(run func(ctx context.Context, offset int, limit int) ([]*domain0.TransitKey, error)) *MockTransitKeyRepository_List_Call { + _c.Call.Return(run) + return _c +} + // NewMockTransitKeyUseCase creates a new instance of MockTransitKeyUseCase. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMockTransitKeyUseCase(t interface { @@ -754,6 +828,80 @@ func (_c *MockTransitKeyUseCase_Encrypt_Call) RunAndReturn(run func(ctx context. return _c } +// List provides a mock function for the type MockTransitKeyUseCase +func (_mock *MockTransitKeyUseCase) List(ctx context.Context, offset int, limit int) ([]*domain0.TransitKey, error) { + ret := _mock.Called(ctx, offset, limit) + + if len(ret) == 0 { + panic("no return value specified for List") + } + + var r0 []*domain0.TransitKey + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) ([]*domain0.TransitKey, error)); ok { + return returnFunc(ctx, offset, limit) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) []*domain0.TransitKey); ok { + r0 = returnFunc(ctx, offset, limit) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*domain0.TransitKey) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, int, int) error); ok { + r1 = returnFunc(ctx, offset, limit) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockTransitKeyUseCase_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' +type MockTransitKeyUseCase_List_Call struct { + *mock.Call +} + +// List is a helper method to define mock.On call +// - ctx context.Context +// - offset int +// - limit int +func (_e *MockTransitKeyUseCase_Expecter) List(ctx interface{}, offset interface{}, limit interface{}) *MockTransitKeyUseCase_List_Call { + return &MockTransitKeyUseCase_List_Call{Call: _e.mock.On("List", ctx, offset, limit)} +} + +func (_c *MockTransitKeyUseCase_List_Call) Run(run func(ctx context.Context, offset int, limit int)) *MockTransitKeyUseCase_List_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 int + if args[1] != nil { + arg1 = args[1].(int) + } + var arg2 int + if args[2] != nil { + arg2 = args[2].(int) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockTransitKeyUseCase_List_Call) Return(transitKeys []*domain0.TransitKey, err error) *MockTransitKeyUseCase_List_Call { + _c.Call.Return(transitKeys, err) + return _c +} + +func (_c *MockTransitKeyUseCase_List_Call) RunAndReturn(run func(ctx context.Context, offset int, limit int) ([]*domain0.TransitKey, error)) *MockTransitKeyUseCase_List_Call { + _c.Call.Return(run) + return _c +} + // Rotate provides a mock function for the type MockTransitKeyUseCase func (_mock *MockTransitKeyUseCase) Rotate(ctx context.Context, name string, alg domain.Algorithm) (*domain0.TransitKey, error) { ret := _mock.Called(ctx, name, alg) diff --git a/internal/transit/usecase/transit_key_usecase.go b/internal/transit/usecase/transit_key_usecase.go index 01f38b8..cab98c4 100644 --- a/internal/transit/usecase/transit_key_usecase.go +++ b/internal/transit/usecase/transit_key_usecase.go @@ -271,6 +271,14 @@ func (t *transitKeyUseCase) Decrypt( }, nil } +// List retrieves transit keys ordered by name ascending with pagination. +func (t *transitKeyUseCase) List( + ctx context.Context, + offset, limit int, +) ([]*transitDomain.TransitKey, error) { + return t.transitRepo.List(ctx, offset, limit) +} + // NewTransitKeyUseCase creates a new TransitKeyUseCase with injected dependencies. func NewTransitKeyUseCase( txManager database.TxManager, diff --git a/internal/transit/usecase/transit_key_usecase_test.go b/internal/transit/usecase/transit_key_usecase_test.go index f18e7ef..fe10fe0 100644 --- a/internal/transit/usecase/transit_key_usecase_test.go +++ b/internal/transit/usecase/transit_key_usecase_test.go @@ -1255,3 +1255,94 @@ func TestTransitKeyUseCase_Decrypt(t *testing.T) { assert.True(t, apperrors.Is(err, cryptoDomain.ErrDecryptionFailed)) }) } + +// TestTransitKeyUseCase_List tests the List method of transitKeyUseCase. +func TestTransitKeyUseCase_List(t *testing.T) { + ctx := context.Background() + + t.Run("Success_ListTransitKeys", func(t *testing.T) { + // Setup mocks + mockTxManager := databaseMocks.NewMockTxManager(t) + mockTransitRepo := usecaseMocks.NewMockTransitKeyRepository(t) + mockDekRepo := usecaseMocks.NewMockDekRepository(t) + mockAeadManager := serviceMocks.NewMockAEADManager(t) + mockKeyManager := serviceMocks.NewMockKeyManager(t) + + kekChain := cryptoDomain.NewKekChain([]*cryptoDomain.Kek{}) + defer kekChain.Close() + + expectedKeys := []*transitDomain.TransitKey{ + { + ID: uuid.Must(uuid.NewV7()), + Name: "key-1", + Version: 1, + }, + { + ID: uuid.Must(uuid.NewV7()), + Name: "key-2", + Version: 2, + }, + } + + mockTransitRepo.EXPECT(). + List(ctx, 0, 10). + Return(expectedKeys, nil). + Once() + + // Execute + uc := NewTransitKeyUseCase( + mockTxManager, + mockTransitRepo, + mockDekRepo, + mockKeyManager, + mockAeadManager, + kekChain, + ) + + keys, err := uc.List(ctx, 0, 10) + + // Assert + assert.NoError(t, err) + assert.Len(t, keys, 2) + assert.Equal(t, "key-1", keys[0].Name) + assert.Equal(t, uint(1), keys[0].Version) + assert.Equal(t, "key-2", keys[1].Name) + assert.Equal(t, uint(2), keys[1].Version) + }) + + t.Run("Error_RepositoryFails", func(t *testing.T) { + // Setup mocks + mockTxManager := databaseMocks.NewMockTxManager(t) + mockTransitRepo := usecaseMocks.NewMockTransitKeyRepository(t) + mockDekRepo := usecaseMocks.NewMockDekRepository(t) + mockAeadManager := serviceMocks.NewMockAEADManager(t) + mockKeyManager := serviceMocks.NewMockKeyManager(t) + + kekChain := cryptoDomain.NewKekChain([]*cryptoDomain.Kek{}) + defer kekChain.Close() + + expectedErr := errors.New("db error") + + mockTransitRepo.EXPECT(). + List(ctx, 0, 10). + Return(nil, expectedErr). + Once() + + // Execute + uc := NewTransitKeyUseCase( + mockTxManager, + mockTransitRepo, + mockDekRepo, + mockKeyManager, + mockAeadManager, + kekChain, + ) + + keys, err := uc.List(ctx, 0, 10) + + // Assert + assert.Error(t, err) + assert.Nil(t, keys) + assert.Equal(t, expectedErr, err) + }) +}