diff --git a/conductor/archive/tokenization_key_retrieval_20260307/index.md b/conductor/archive/tokenization_key_retrieval_20260307/index.md new file mode 100644 index 0000000..2b40a28 --- /dev/null +++ b/conductor/archive/tokenization_key_retrieval_20260307/index.md @@ -0,0 +1,5 @@ +# Track tokenization_key_retrieval_20260307 Context + +- [Specification](./spec.md) +- [Implementation Plan](./plan.md) +- [Metadata](./metadata.json) diff --git a/conductor/archive/tokenization_key_retrieval_20260307/metadata.json b/conductor/archive/tokenization_key_retrieval_20260307/metadata.json new file mode 100644 index 0000000..911e87d --- /dev/null +++ b/conductor/archive/tokenization_key_retrieval_20260307/metadata.json @@ -0,0 +1,8 @@ +{ + "track_id": "tokenization_key_retrieval_20260307", + "type": "feature", + "status": "new", + "created_at": "2026-03-07T14:45:00Z", + "updated_at": "2026-03-07T14:45:00Z", + "description": "Add Individual Key Retrieval Endpoint for tokenization module (by name)" +} diff --git a/conductor/archive/tokenization_key_retrieval_20260307/plan.md b/conductor/archive/tokenization_key_retrieval_20260307/plan.md new file mode 100644 index 0000000..ecca96e --- /dev/null +++ b/conductor/archive/tokenization_key_retrieval_20260307/plan.md @@ -0,0 +1,25 @@ +# Implementation Plan: Tokenization Key Retrieval by Name + +This plan outlines the steps to add a new endpoint `GET /v1/tokenization/keys/:name` to retrieve a single tokenization key by its name. + +## Phase 1: Domain and Use Case Layer [checkpoint: 7385039] +Add the `GetByName` functionality to the use case layer. + +- [x] Task: Add `GetByName` to `TokenizationKeyUseCase` interface. 2fe4a7a +- [x] Task: Implement `GetByName` in `tokenizationKeyUseCase` struct. 3ae4bf7 +- [x] Task: Conductor - User Manual Verification 'Phase 1: Domain and Use Case Layer' (Protocol in workflow.md) d906fd8 + +## Phase 2: HTTP Layer [checkpoint: 1f25be5] +Expose the new functionality through a REST endpoint. + +- [x] Task: Add `GetByNameHandler` to `TokenizationKeyHandler`. 7e55e0d +- [x] Task: Register the new route `GET /v1/tokenization/keys/:name`. b8170b6 +- [x] Task: Conductor - User Manual Verification 'Phase 2: HTTP Layer' (Protocol in workflow.md) 5047107 + +## Phase 3: Integration and Documentation [checkpoint: 8ab39bd] +Ensure end-to-end functionality and update documentation. + +- [x] Task: Update integration tests in `test/integration/tokenization_flow_test.go`. d506864 +- [x] Task: Update project documentation `docs/engines/tokenization.md`. cca1f42 +- [x] Task: Update OpenAPI specification `docs/openapi.yaml`. 17dc1eb +- [x] Task: Conductor - User Manual Verification 'Phase 3: Integration and Documentation' (Protocol in workflow.md) 046ad84 diff --git a/conductor/archive/tokenization_key_retrieval_20260307/spec.md b/conductor/archive/tokenization_key_retrieval_20260307/spec.md new file mode 100644 index 0000000..dd88cae --- /dev/null +++ b/conductor/archive/tokenization_key_retrieval_20260307/spec.md @@ -0,0 +1,35 @@ +# Specification: Tokenization Key Retrieval by Name + +## Overview +Currently, the tokenization module supports listing keys and rotating keys, but it lacks a direct endpoint to retrieve a single key's metadata by its name. This track adds a new `GET` endpoint to the tokenization key API to allow efficient lookup of individual keys. + +## Functional Requirements +- **Endpoint:** `GET /v1/tokenization/keys/:name` +- **Capability:** `tokenization:read` +- **Input:** `name` (string) as a path parameter. +- **Output:** Returns the latest version of the tokenization key metadata. +- **Status Codes:** + - `200 OK`: Key found. Returns key metadata (ID, Name, Version, FormatType, IsDeterministic, CreatedAt). + - `404 Not Found`: Key with the given name does not exist or has been soft-deleted. + - `401 Unauthorized`: Missing or invalid authentication token. + - `403 Forbidden`: Authenticated client lacks `tokenization:read` capability. + +## Non-Functional Requirements +- **Consistency:** The response format must match the existing `TokenizationKeyResponse` DTO. +- **Performance:** Direct lookup by name should be efficient (indexed in the database). + +## Acceptance Criteria +- [ ] A new method `GetByName` is added to `TokenizationKeyUseCase`. +- [ ] A new handler `GetByNameHandler` is added to `TokenizationKeyHandler`. +- [ ] The route `GET /v1/tokenization/keys/:name` is registered in the application. +- [ ] The endpoint requires the `tokenization:read` capability. +- [ ] Unit tests for the use case and handler are implemented. +- [ ] Integration tests verify the end-to-end functionality (MySQL and PostgreSQL). +- [ ] Updated integration tests in `test/integration/tokenization_flow_test.go`. +- [ ] Updated documentation: `docs/engines/tokenization.md` +- [ ] Updated OpenAPI documentation: `docs/openapi.yaml` + +## Out of Scope +- Retrieving specific versions of a key (only the latest version is returned). +- Retrieving soft-deleted keys. +- Modifying or deleting keys via this endpoint. diff --git a/conductor/tracks.md b/conductor/tracks.md index bae9481..0b5c54e 100644 --- a/conductor/tracks.md +++ b/conductor/tracks.md @@ -3,4 +3,3 @@ This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder. --- - diff --git a/docs/engines/tokenization.md b/docs/engines/tokenization.md index f2b5c87..7ce6605 100644 --- a/docs/engines/tokenization.md +++ b/docs/engines/tokenization.md @@ -29,9 +29,9 @@ All endpoints require `Authorization: Bearer `. - **Body**: `name`, `format_type` (`uuid`, `numeric`, `luhn-preserving`, `alphanumeric`), `is_deterministic`, `algorithm`. ```bash -curl -X POST http://localhost:8080/v1/tokenization/keys - -H "Authorization: Bearer " - -H "Content-Type: application/json" +curl -X POST http://localhost:8080/v1/tokenization/keys \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ -d '{ "name": "payment-cards", "format_type": "luhn-preserving", @@ -52,9 +52,9 @@ curl -X POST http://localhost:8080/v1/tokenization/keys - **Body**: `plaintext` (base64), `metadata` (optional object), `ttl` (optional seconds). ```bash -curl -X POST http://localhost:8080/v1/tokenization/keys/payment-cards/tokenize - -H "Authorization: Bearer " - -H "Content-Type: application/json" +curl -X POST http://localhost:8080/v1/tokenization/keys/payment-cards/tokenize \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ -d '{ "plaintext": "NDUzMjAxNTExMjgzMDM2Ng==", "metadata": { "last_four": "0366" } @@ -125,7 +125,8 @@ Example response (`200 OK`): { "id": "0194f4c1-82de-7f9a-c2b3-9def1a7bc5d8", "name": "customer-ids", - "algorithm": "uuid_v7", + "format_type": "uuid", + "algorithm": "aes-gcm", "is_deterministic": true, "version": 2, "created_at": "2026-02-27T20:10:00Z", @@ -134,7 +135,8 @@ Example response (`200 OK`): { "id": "0194f4d3-a5bc-7e2f-d8a1-4bef2c9ad7e1", "name": "payment-tokens", - "algorithm": "luhn_preserving", + "format_type": "luhn-preserving", + "algorithm": "chacha20-poly1305", "is_deterministic": false, "version": 1, "created_at": "2026-02-27T21:45:00Z", @@ -147,9 +149,30 @@ Example response (`200 OK`): **Note**: The `next_cursor` field is only present when there are more pages available. +#### Get Tokenization Key by Name + +- **Endpoint**: `GET /v1/tokenization/keys/:name` +- **Capability**: `read` +- **Success**: `200 OK` + +Example response (`200 OK`): + +```json +{ + "id": "0194f4c1-82de-7f9a-c2b3-9def1a7bc5d8", + "name": "customer-ids", + "format_type": "uuid", + "algorithm": "aes-gcm", + "is_deterministic": true, + "version": 2, + "created_at": "2026-02-27T20:10:00Z", + "updated_at": "2026-02-28T10:30:00Z" +} +``` + #### Delete Tokenization Key -- **Endpoint**: `DELETE /v1/tokenization/keys/:name` +- **Endpoint**: `DELETE /v1/tokenization/keys/:id` - **Capability**: `delete` - **Success**: `204 No Content` diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 88d463c..5f88c48 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -780,6 +780,37 @@ paths: $ref: "#/components/responses/ValidationError" "429": $ref: "#/components/responses/TooManyRequests" + /v1/tokenization/keys/{name}: + parameters: + - name: name + in: path + required: true + schema: + type: string + get: + tags: [tokenization] + summary: Get tokenization key details + security: + - bearerAuth: [] + responses: + "200": + description: Tokenization key details + content: + application/json: + schema: + $ref: "#/components/schemas/TokenizationKeyResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + description: Tokenization key not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "429": + $ref: "#/components/responses/TooManyRequests" /v1/tokenization/keys/{name}/rotate: post: tags: [tokenization] diff --git a/internal/http/server.go b/internal/http/server.go index 49167d0..a26a79e 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -379,6 +379,12 @@ func (s *Server) registerTokenizationRoutes( tokenizationKeyHandler.ListHandler, ) + // Get individual tokenization key + keys.GET("/:name", + authHTTP.AuthorizationMiddleware(authDomain.ReadCapability, auditLogUseCase, s.logger), + tokenizationKeyHandler.GetByNameHandler, + ) + // Create new tokenization key keys.POST("", authHTTP.AuthorizationMiddleware(authDomain.WriteCapability, auditLogUseCase, s.logger), diff --git a/internal/tokenization/http/tokenization_key_handler.go b/internal/tokenization/http/tokenization_key_handler.go index 129443f..95c20b9 100644 --- a/internal/tokenization/http/tokenization_key_handler.go +++ b/internal/tokenization/http/tokenization_key_handler.go @@ -163,6 +163,31 @@ func (h *TokenizationKeyHandler) DeleteHandler(c *gin.Context) { c.Data(http.StatusNoContent, "application/json", nil) } +// GetByNameHandler retrieves a single tokenization key by its name. +// GET /v1/tokenization/keys/:name - Requires ReadCapability. +// Returns 200 OK with key details. +func (h *TokenizationKeyHandler) GetByNameHandler(c *gin.Context) { + // Get key name from URL parameter + keyName := c.Param("name") + if keyName == "" { + httputil.HandleBadRequestGin(c, + fmt.Errorf("key name is required in URL path"), + h.logger) + return + } + + // Call use case + key, err := h.keyUseCase.GetByName(c.Request.Context(), keyName) + if err != nil { + httputil.HandleErrorGin(c, err, h.logger) + return + } + + // Map to response + response := dto.MapTokenizationKeyToResponse(key) + c.JSON(http.StatusOK, response) +} + // ListHandler retrieves tokenization keys with cursor-based pagination support. // GET /v1/tokenization/keys?after_name=key-name&limit=50 - Requires ReadCapability. // Returns 200 OK with paginated tokenization key list ordered by name ascending. diff --git a/internal/tokenization/http/tokenization_key_handler_test.go b/internal/tokenization/http/tokenization_key_handler_test.go index 68193f4..17778f0 100644 --- a/internal/tokenization/http/tokenization_key_handler_test.go +++ b/internal/tokenization/http/tokenization_key_handler_test.go @@ -470,3 +470,91 @@ func TestTokenizationKeyHandler_ListHandler(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) }) } + +func TestTokenizationKeyHandler_GetByNameHandler(t *testing.T) { + t.Run("Success_GetKeyByName", func(t *testing.T) { + handler, mockUseCase := setupTestKeyHandler(t) + + keyID := uuid.Must(uuid.NewV7()) + expectedKey := &tokenizationDomain.TokenizationKey{ + ID: keyID, + Name: "test-key", + Version: 1, + FormatType: tokenizationDomain.FormatUUID, + IsDeterministic: false, + DekID: uuid.Must(uuid.NewV7()), + CreatedAt: time.Now().UTC(), + } + + mockUseCase.EXPECT(). + GetByName(mock.Anything, "test-key"). + Return(expectedKey, nil). + Once() + + c, w := createTestContext(http.MethodGet, "/v1/tokenization/keys/test-key", nil) + c.Params = gin.Params{{Key: "name", Value: "test-key"}} + + handler.GetByNameHandler(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var response dto.TokenizationKeyResponse + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, keyID.String(), response.ID) + assert.Equal(t, "test-key", response.Name) + assert.Equal(t, uint(1), response.Version) + assert.Equal(t, "uuid", response.FormatType) + assert.False(t, response.IsDeterministic) + }) + + t.Run("Error_MissingKeyNameInURL", func(t *testing.T) { + handler, _ := setupTestKeyHandler(t) + + c, w := createTestContext(http.MethodGet, "/v1/tokenization/keys/", nil) + c.Params = gin.Params{{Key: "name", Value: ""}} + + handler.GetByNameHandler(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, "bad_request", response["error"]) + }) + + t.Run("Error_KeyNotFound", func(t *testing.T) { + handler, mockUseCase := setupTestKeyHandler(t) + + mockUseCase.EXPECT(). + GetByName(mock.Anything, "nonexistent-key"). + Return(nil, tokenizationDomain.ErrTokenizationKeyNotFound). + Once() + + c, w := createTestContext(http.MethodGet, "/v1/tokenization/keys/nonexistent-key", nil) + c.Params = gin.Params{{Key: "name", Value: "nonexistent-key"}} + + handler.GetByNameHandler(c) + + assert.Equal(t, http.StatusNotFound, w.Code) + }) + + t.Run("Error_UseCaseError", func(t *testing.T) { + handler, mockUseCase := setupTestKeyHandler(t) + + dbError := errors.New("database error") + + mockUseCase.EXPECT(). + GetByName(mock.Anything, "test-key"). + Return(nil, dbError). + Once() + + c, w := createTestContext(http.MethodGet, "/v1/tokenization/keys/test-key", nil) + c.Params = gin.Params{{Key: "name", Value: "test-key"}} + + handler.GetByNameHandler(c) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + }) +} diff --git a/internal/tokenization/usecase/interface.go b/internal/tokenization/usecase/interface.go index 8b29f0c..01682ca 100644 --- a/internal/tokenization/usecase/interface.go +++ b/internal/tokenization/usecase/interface.go @@ -91,6 +91,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 + // GetByName retrieves a single tokenization key by its name. + // Returns the latest version for the key. Filters out soft-deleted keys. + GetByName(ctx context.Context, name string) (*tokenizationDomain.TokenizationKey, error) + // ListCursor retrieves tokenization keys ordered by name ascending with cursor-based pagination. // If afterName is provided, returns keys with name greater than afterName (ASC order). // Returns the latest version for each key. Filters out soft-deleted keys. diff --git a/internal/tokenization/usecase/mocks/mocks.go b/internal/tokenization/usecase/mocks/mocks.go index 5452ccf..2ae320a 100644 --- a/internal/tokenization/usecase/mocks/mocks.go +++ b/internal/tokenization/usecase/mocks/mocks.go @@ -1332,6 +1332,74 @@ func (_c *MockTokenizationKeyUseCase_Delete_Call) RunAndReturn(run func(ctx cont return _c } +// GetByName provides a mock function for the type MockTokenizationKeyUseCase +func (_mock *MockTokenizationKeyUseCase) GetByName(ctx context.Context, name string) (*domain0.TokenizationKey, error) { + ret := _mock.Called(ctx, name) + + if len(ret) == 0 { + panic("no return value specified for GetByName") + } + + var r0 *domain0.TokenizationKey + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*domain0.TokenizationKey, error)); ok { + return returnFunc(ctx, name) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) *domain0.TokenizationKey); ok { + r0 = returnFunc(ctx, name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*domain0.TokenizationKey) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, name) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockTokenizationKeyUseCase_GetByName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByName' +type MockTokenizationKeyUseCase_GetByName_Call struct { + *mock.Call +} + +// GetByName is a helper method to define mock.On call +// - ctx context.Context +// - name string +func (_e *MockTokenizationKeyUseCase_Expecter) GetByName(ctx interface{}, name interface{}) *MockTokenizationKeyUseCase_GetByName_Call { + return &MockTokenizationKeyUseCase_GetByName_Call{Call: _e.mock.On("GetByName", ctx, name)} +} + +func (_c *MockTokenizationKeyUseCase_GetByName_Call) Run(run func(ctx context.Context, name string)) *MockTokenizationKeyUseCase_GetByName_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockTokenizationKeyUseCase_GetByName_Call) Return(tokenizationKey *domain0.TokenizationKey, err error) *MockTokenizationKeyUseCase_GetByName_Call { + _c.Call.Return(tokenizationKey, err) + return _c +} + +func (_c *MockTokenizationKeyUseCase_GetByName_Call) RunAndReturn(run func(ctx context.Context, name string) (*domain0.TokenizationKey, error)) *MockTokenizationKeyUseCase_GetByName_Call { + _c.Call.Return(run) + return _c +} + // ListCursor provides a mock function for the type MockTokenizationKeyUseCase func (_mock *MockTokenizationKeyUseCase) ListCursor(ctx context.Context, afterName *string, limit int) ([]*domain0.TokenizationKey, error) { ret := _mock.Called(ctx, afterName, limit) diff --git a/internal/tokenization/usecase/tokenization_key_metrics_decorator.go b/internal/tokenization/usecase/tokenization_key_metrics_decorator.go index dd7045a..7f310d2 100644 --- a/internal/tokenization/usecase/tokenization_key_metrics_decorator.go +++ b/internal/tokenization/usecase/tokenization_key_metrics_decorator.go @@ -88,6 +88,25 @@ func (t *tokenizationKeyUseCaseWithMetrics) Delete(ctx context.Context, tokeniza return err } +// GetByName records metrics for tokenization key retrieval operations. +func (t *tokenizationKeyUseCaseWithMetrics) GetByName( + ctx context.Context, + name string, +) (*tokenizationDomain.TokenizationKey, error) { + start := time.Now() + key, err := t.next.GetByName(ctx, name) + + status := "success" + if err != nil { + status = "error" + } + + t.metrics.RecordOperation(ctx, "tokenization", "tokenization_key_get", status) + t.metrics.RecordDuration(ctx, "tokenization", "tokenization_key_get", time.Since(start), status) + + return key, err +} + // List records metrics for tokenization key listing operations. func (t *tokenizationKeyUseCaseWithMetrics) ListCursor( ctx context.Context, diff --git a/internal/tokenization/usecase/tokenization_key_metrics_decorator_test.go b/internal/tokenization/usecase/tokenization_key_metrics_decorator_test.go index 052d683..0c0fef9 100644 --- a/internal/tokenization/usecase/tokenization_key_metrics_decorator_test.go +++ b/internal/tokenization/usecase/tokenization_key_metrics_decorator_test.go @@ -260,6 +260,86 @@ func TestTokenizationKeyUseCaseWithMetrics_Rotate(t *testing.T) { } } +func TestTokenizationKeyUseCaseWithMetrics_GetByName(t *testing.T) { + tests := []struct { + name string + setupMocks func(*tokenizationMocks.MockTokenizationKeyUseCase, *mockBusinessMetrics) + keyName string + expectedKey *tokenizationDomain.TokenizationKey + expectedErr error + expectedStatus string + }{ + { + name: "Success_RecordsSuccessMetrics", + setupMocks: func(mockUseCase *tokenizationMocks.MockTokenizationKeyUseCase, mockMetrics *mockBusinessMetrics) { + key := &tokenizationDomain.TokenizationKey{ + ID: uuid.New(), + Name: "test-key", + Version: 1, + FormatType: tokenizationDomain.FormatUUID, + IsDeterministic: false, + CreatedAt: time.Now().UTC(), + } + mockUseCase.EXPECT(). + GetByName(mock.Anything, "test-key"). + Return(key, nil). + Once() + mockMetrics.On("RecordOperation", mock.Anything, "tokenization", "tokenization_key_get", "success"). + Once() + mockMetrics.On("RecordDuration", mock.Anything, "tokenization", "tokenization_key_get", mock.AnythingOfType("time.Duration"), "success"). + Once() + }, + keyName: "test-key", + expectedKey: &tokenizationDomain.TokenizationKey{ + Name: "test-key", + }, + expectedErr: nil, + expectedStatus: "success", + }, + { + name: "Error_RecordsErrorMetrics", + setupMocks: func(mockUseCase *tokenizationMocks.MockTokenizationKeyUseCase, mockMetrics *mockBusinessMetrics) { + mockUseCase.EXPECT(). + GetByName(mock.Anything, "test-key"). + Return(nil, errors.New("key not found")). + Once() + mockMetrics.On("RecordOperation", mock.Anything, "tokenization", "tokenization_key_get", "error"). + Once() + mockMetrics.On("RecordDuration", mock.Anything, "tokenization", "tokenization_key_get", mock.AnythingOfType("time.Duration"), "error"). + Once() + }, + keyName: "test-key", + expectedKey: nil, + expectedErr: errors.New("key not found"), + expectedStatus: "error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockUseCase := tokenizationMocks.NewMockTokenizationKeyUseCase(t) + mockMetrics := &mockBusinessMetrics{} + tt.setupMocks(mockUseCase, mockMetrics) + + decorator := NewTokenizationKeyUseCaseWithMetrics(mockUseCase, mockMetrics) + + key, err := decorator.GetByName(context.Background(), tt.keyName) + + if tt.expectedErr != nil { + assert.Error(t, err) + assert.Nil(t, key) + } else { + assert.NoError(t, err) + assert.NotNil(t, key) + assert.Equal(t, tt.expectedKey.Name, key.Name) + } + + mockMetrics.AssertExpectations(t) + mockUseCase.AssertExpectations(t) + }) + } +} + func TestTokenizationKeyUseCaseWithMetrics_Delete(t *testing.T) { tests := []struct { name string diff --git a/internal/tokenization/usecase/tokenization_key_usecase.go b/internal/tokenization/usecase/tokenization_key_usecase.go index 69e752e..45b722c 100644 --- a/internal/tokenization/usecase/tokenization_key_usecase.go +++ b/internal/tokenization/usecase/tokenization_key_usecase.go @@ -181,6 +181,22 @@ func (t *tokenizationKeyUseCase) Delete(ctx context.Context, keyID uuid.UUID) er return nil } +// GetByName retrieves a single tokenization key by its name. +// Returns the latest version for the key. Filters out soft-deleted keys. +func (t *tokenizationKeyUseCase) GetByName( + ctx context.Context, + name string, +) (*tokenizationDomain.TokenizationKey, error) { + key, err := t.tokenizationKeyRepo.GetByName(ctx, name) + if err != nil { + if apperrors.Is(err, tokenizationDomain.ErrTokenizationKeyNotFound) { + return nil, err + } + return nil, apperrors.Wrap(err, "failed to get tokenization key") + } + return key, nil +} + // ListCursor retrieves tokenization keys ordered by name ascending with cursor-based pagination. // Returns the latest version for each key name. func (t *tokenizationKeyUseCase) ListCursor( diff --git a/internal/tokenization/usecase/tokenization_key_usecase_test.go b/internal/tokenization/usecase/tokenization_key_usecase_test.go index e7142bd..3f32dd6 100644 --- a/internal/tokenization/usecase/tokenization_key_usecase_test.go +++ b/internal/tokenization/usecase/tokenization_key_usecase_test.go @@ -753,3 +753,123 @@ func TestTokenizationKeyUseCase_PurgeDeleted(t *testing.T) { assert.True(t, errors.Is(err, expectedError)) }) } + +// TestTokenizationKeyUseCase_GetByName tests the GetByName method. +func TestTokenizationKeyUseCase_GetByName(t *testing.T) { + ctx := context.Background() + + t.Run("Success_GetByName", func(t *testing.T) { + // Setup mocks + mockTxManager := databaseMocks.NewMockTxManager(t) + mockTokenizationKeyRepo := tokenizationMocks.NewMockTokenizationKeyRepository(t) + mockDekRepo := tokenizationMocks.NewMockDekRepository(t) + mockKeyManager := cryptoServiceMocks.NewMockKeyManager(t) + + // Create test data + masterKey := tokenizationTesting.CreateMasterKey() + kekChain := tokenizationTesting.CreateKekChain(masterKey) + defer kekChain.Close() + + expectedKey := &tokenizationDomain.TokenizationKey{ + ID: uuid.Must(uuid.NewV7()), + Name: "test-key", + FormatType: tokenizationDomain.FormatUUID, + Version: 1, + IsDeterministic: false, + DekID: uuid.Must(uuid.NewV7()), + } + + // Setup expectations + mockTokenizationKeyRepo.EXPECT(). + GetByName(ctx, "test-key"). + Return(expectedKey, nil). + Once() + + // Execute + uc := NewTokenizationKeyUseCase( + mockTxManager, + mockTokenizationKeyRepo, + mockDekRepo, + mockKeyManager, + kekChain, + ) + key, err := uc.GetByName(ctx, "test-key") + + // Assert + assert.NoError(t, err) + assert.Equal(t, expectedKey, key) + }) + + t.Run("Error_NotFound", func(t *testing.T) { + // Setup mocks + mockTxManager := databaseMocks.NewMockTxManager(t) + mockTokenizationKeyRepo := tokenizationMocks.NewMockTokenizationKeyRepository(t) + mockDekRepo := tokenizationMocks.NewMockDekRepository(t) + mockKeyManager := cryptoServiceMocks.NewMockKeyManager(t) + + // Create test data + masterKey := tokenizationTesting.CreateMasterKey() + kekChain := tokenizationTesting.CreateKekChain(masterKey) + defer kekChain.Close() + + expectedError := tokenizationDomain.ErrTokenizationKeyNotFound + + // Setup expectations + mockTokenizationKeyRepo.EXPECT(). + GetByName(ctx, "non-existent"). + Return(nil, expectedError). + Once() + + // Execute + uc := NewTokenizationKeyUseCase( + mockTxManager, + mockTokenizationKeyRepo, + mockDekRepo, + mockKeyManager, + kekChain, + ) + key, err := uc.GetByName(ctx, "non-existent") + + // Assert + assert.Error(t, err) + assert.Nil(t, key) + assert.True(t, errors.Is(err, expectedError)) + }) + + 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) + + // Create test data + masterKey := tokenizationTesting.CreateMasterKey() + kekChain := tokenizationTesting.CreateKekChain(masterKey) + defer kekChain.Close() + + expectedError := errors.New("database error") + + // Setup expectations + mockTokenizationKeyRepo.EXPECT(). + GetByName(ctx, "test-key"). + Return(nil, expectedError). + Once() + + // Execute + uc := NewTokenizationKeyUseCase( + mockTxManager, + mockTokenizationKeyRepo, + mockDekRepo, + mockKeyManager, + kekChain, + ) + key, err := uc.GetByName(ctx, "test-key") + + // Assert + assert.Error(t, err) + assert.Nil(t, key) + assert.True(t, errors.Is(err, expectedError)) + assert.Contains(t, err.Error(), "failed to get tokenization key") + }) +} diff --git a/internal/transit/usecase/mocks/mocks.go b/internal/transit/usecase/mocks/mocks.go index afbdd9a..e0ec428 100644 --- a/internal/transit/usecase/mocks/mocks.go +++ b/internal/transit/usecase/mocks/mocks.go @@ -444,7 +444,7 @@ func (_c *MockTransitKeyRepository_GetByNameAndVersion_Call) Return(transitKey * return _c } -func (_c *MockTransitKeyRepository_GetByNameAndVersion_Call) RunAndReturn(run func(context.Context, string, uint) (*domain0.TransitKey, error)) *MockTransitKeyRepository_GetByNameAndVersion_Call { +func (_c *MockTransitKeyRepository_GetByNameAndVersion_Call) RunAndReturn(run func(ctx context.Context, name string, version uint) (*domain0.TransitKey, error)) *MockTransitKeyRepository_GetByNameAndVersion_Call { _c.Call.Return(run) return _c } @@ -519,8 +519,8 @@ func (_c *MockTransitKeyRepository_GetTransitKey_Call) Run(run func(ctx context. return _c } -func (_c *MockTransitKeyRepository_GetTransitKey_Call) Return(_a0 *domain0.TransitKey, _a1 domain.Algorithm, _a2 error) *MockTransitKeyRepository_GetTransitKey_Call { - _c.Call.Return(_a0, _a1, _a2) +func (_c *MockTransitKeyRepository_GetTransitKey_Call) Return(transitKey *domain0.TransitKey, algorithm domain.Algorithm, err error) *MockTransitKeyRepository_GetTransitKey_Call { + _c.Call.Return(transitKey, algorithm, err) return _c } @@ -529,7 +529,6 @@ func (_c *MockTransitKeyRepository_GetTransitKey_Call) RunAndReturn(run func(ctx return _c } -// HardDelete provides a mock function for the type MockTransitKeyRepository // HardDelete provides a mock function for the type MockTransitKeyRepository func (_mock *MockTransitKeyRepository) HardDelete(ctx context.Context, olderThan time.Time, dryRun bool) (int64, error) { ret := _mock.Called(ctx, olderThan, dryRun) @@ -857,86 +856,6 @@ func (_c *MockTransitKeyUseCase_Decrypt_Call) RunAndReturn(run func(ctx context. return _c } -// Get provides a mock function for the type MockTransitKeyUseCase -func (_mock *MockTransitKeyUseCase) Get(ctx context.Context, name string, version uint) (*domain0.TransitKey, domain.Algorithm, error) { - ret := _mock.Called(ctx, name, version) - - if len(ret) == 0 { - panic("no return value specified for Get") - } - - var r0 *domain0.TransitKey - var r1 domain.Algorithm - var r2 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, uint) (*domain0.TransitKey, domain.Algorithm, error)); ok { - return returnFunc(ctx, name, version) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, uint) *domain0.TransitKey); ok { - r0 = returnFunc(ctx, name, version) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*domain0.TransitKey) - } - } - if returnFunc, ok := ret.Get(1).(func(context.Context, string, uint) domain.Algorithm); ok { - r1 = returnFunc(ctx, name, version) - } else { - r1 = ret.Get(1).(domain.Algorithm) - } - if returnFunc, ok := ret.Get(2).(func(context.Context, string, uint) error); ok { - r2 = returnFunc(ctx, name, version) - } else { - r2 = ret.Error(2) - } - return r0, r1, r2 -} - -// MockTransitKeyUseCase_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' -type MockTransitKeyUseCase_Get_Call struct { - *mock.Call -} - -// Get is a helper method to define mock.On call -// - ctx context.Context -// - name string -// - version uint -func (_e *MockTransitKeyUseCase_Expecter) Get(ctx interface{}, name interface{}, version interface{}) *MockTransitKeyUseCase_Get_Call { - return &MockTransitKeyUseCase_Get_Call{Call: _e.mock.On("Get", ctx, name, version)} -} - -func (_c *MockTransitKeyUseCase_Get_Call) Run(run func(ctx context.Context, name string, version uint)) *MockTransitKeyUseCase_Get_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 string - if args[1] != nil { - arg1 = args[1].(string) - } - var arg2 uint - if args[2] != nil { - arg2 = args[2].(uint) - } - run( - arg0, - arg1, - arg2, - ) - }) - return _c -} - -func (_c *MockTransitKeyUseCase_Get_Call) Return(_a0 *domain0.TransitKey, _a1 domain.Algorithm, _a2 error) *MockTransitKeyUseCase_Get_Call { - _c.Call.Return(_a0, _a1, _a2) - return _c -} - -func (_c *MockTransitKeyUseCase_Get_Call) RunAndReturn(run func(ctx context.Context, name string, version uint) (*domain0.TransitKey, domain.Algorithm, error)) *MockTransitKeyUseCase_Get_Call { - _c.Call.Return(run) - return _c -} - // Delete provides a mock function for the type MockTransitKeyUseCase func (_mock *MockTransitKeyUseCase) Delete(ctx context.Context, transitKeyID uuid.UUID) error { ret := _mock.Called(ctx, transitKeyID) @@ -1075,6 +994,84 @@ func (_c *MockTransitKeyUseCase_Encrypt_Call) RunAndReturn(run func(ctx context. } // Get provides a mock function for the type MockTransitKeyUseCase +func (_mock *MockTransitKeyUseCase) Get(ctx context.Context, name string, version uint) (*domain0.TransitKey, domain.Algorithm, error) { + ret := _mock.Called(ctx, name, version) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 *domain0.TransitKey + var r1 domain.Algorithm + var r2 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, uint) (*domain0.TransitKey, domain.Algorithm, error)); ok { + return returnFunc(ctx, name, version) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, uint) *domain0.TransitKey); ok { + r0 = returnFunc(ctx, name, version) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*domain0.TransitKey) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, uint) domain.Algorithm); ok { + r1 = returnFunc(ctx, name, version) + } else { + r1 = ret.Get(1).(domain.Algorithm) + } + if returnFunc, ok := ret.Get(2).(func(context.Context, string, uint) error); ok { + r2 = returnFunc(ctx, name, version) + } else { + r2 = ret.Error(2) + } + return r0, r1, r2 +} + +// MockTransitKeyUseCase_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' +type MockTransitKeyUseCase_Get_Call struct { + *mock.Call +} + +// Get is a helper method to define mock.On call +// - ctx context.Context +// - name string +// - version uint +func (_e *MockTransitKeyUseCase_Expecter) Get(ctx interface{}, name interface{}, version interface{}) *MockTransitKeyUseCase_Get_Call { + return &MockTransitKeyUseCase_Get_Call{Call: _e.mock.On("Get", ctx, name, version)} +} + +func (_c *MockTransitKeyUseCase_Get_Call) Run(run func(ctx context.Context, name string, version uint)) *MockTransitKeyUseCase_Get_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 uint + if args[2] != nil { + arg2 = args[2].(uint) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockTransitKeyUseCase_Get_Call) Return(transitKey *domain0.TransitKey, algorithm domain.Algorithm, err error) *MockTransitKeyUseCase_Get_Call { + _c.Call.Return(transitKey, algorithm, err) + return _c +} + +func (_c *MockTransitKeyUseCase_Get_Call) RunAndReturn(run func(ctx context.Context, name string, version uint) (*domain0.TransitKey, domain.Algorithm, error)) *MockTransitKeyUseCase_Get_Call { + _c.Call.Return(run) + return _c +} // ListCursor provides a mock function for the type MockTransitKeyUseCase func (_mock *MockTransitKeyUseCase) ListCursor(ctx context.Context, afterName *string, limit int) ([]*domain0.TransitKey, error) { diff --git a/test/integration/tokenization_flow_test.go b/test/integration/tokenization_flow_test.go index 23f084c..a514d09 100644 --- a/test/integration/tokenization_flow_test.go +++ b/test/integration/tokenization_flow_test.go @@ -400,7 +400,27 @@ func TestIntegration_Tokenization_CompleteFlow(t *testing.T) { assert.Empty(t, body) }) - t.Logf("All 12 tokenization endpoint tests passed for %s", tc.dbDriver) + // [13/13] Test GET /v1/tokenization/keys/:name - Get tokenization key by name + t.Run("13_GetTokenizationKeyByName", func(t *testing.T) { + resp, body := ctx.makeRequest( + t, + http.MethodGet, + "/v1/tokenization/keys/"+tokenizationKeyName1, + nil, + true, + ) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var response tokenizationDTO.TokenizationKeyResponse + err := json.Unmarshal(body, &response) + require.NoError(t, err) + assert.Equal(t, tokenizationKeyName1, response.Name) + assert.Equal(t, uint(2), response.Version) // It was rotated in step 10 + assert.Equal(t, "uuid", response.FormatType) + assert.False(t, response.IsDeterministic) + }) + + t.Logf("All 13 tokenization endpoint tests passed for %s", tc.dbDriver) }) } }