diff --git a/payment/audit.go b/payment/audit.go new file mode 100644 index 0000000..c1c337d --- /dev/null +++ b/payment/audit.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Lanka Software Foundation + +package payment + +import "github.com/OpenNSW/core/shared/audit" + +var _ audit.Details = AuditDetails{} + +// AuditDetails is the payment-owned payload on an audit.Event. +type AuditDetails struct { + GatewayID string + Reference string + Status string // domain PaymentStatus, not audit.Status + Error string +} + +func (d AuditDetails) Metadata() map[string]any { + m := make(map[string]any, 4) + if d.GatewayID != "" { + m["gateway_id"] = d.GatewayID + } + if d.Reference != "" { + m["reference"] = d.Reference + } + if d.Status != "" { + m["status"] = d.Status + } + if d.Error != "" { + m["error"] = d.Error + } + return m +} diff --git a/payment/go.mod b/payment/go.mod index c0732b4..f203158 100644 --- a/payment/go.mod +++ b/payment/go.mod @@ -4,6 +4,7 @@ go 1.26 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 + github.com/OpenNSW/core/shared v0.3.0 github.com/google/uuid v1.6.0 github.com/shopspring/decimal v1.4.0 github.com/stretchr/testify v1.12.1 @@ -23,3 +24,5 @@ require ( golang.org/x/sync v0.17.0 // indirect golang.org/x/text v0.29.0 // indirect ) + +replace github.com/OpenNSW/core/shared => ../shared diff --git a/payment/handler_test.go b/payment/handler_test.go index 2dd3ce7..97266ee 100644 --- a/payment/handler_test.go +++ b/payment/handler_test.go @@ -13,6 +13,7 @@ import ( "net/http/httptest" "testing" + "github.com/OpenNSW/core/shared/audit" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -37,6 +38,7 @@ func (m *mockService) ProcessWebhook(context.Context, string, []byte, map[string return m.webhookResp, m.webhookErr } func (m *mockService) SetTaskCompleter(TaskCompleter) {} +func (m *mockService) WithAuditor(audit.Auditor) {} // serve routes a webhook POST through a mux so PathValue("gatewayId") resolves. func serveWebhook(svc PaymentService) *httptest.ResponseRecorder { diff --git a/payment/service.go b/payment/service.go index f1b141f..698b471 100644 --- a/payment/service.go +++ b/payment/service.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/OpenNSW/core/shared/audit" "github.com/google/uuid" ) @@ -83,12 +84,16 @@ type PaymentService interface { // SetTaskCompleter injects the dependency used to advance the workflow when // a payment settles. Wired post-construction to avoid an import cycle with taskv2. SetTaskCompleter(completer TaskCompleter) + + // WithAuditor injects an optional auditor for recording payment audit events. + WithAuditor(auditor audit.Auditor) } type paymentService struct { repo PaymentRepository registry GatewayRegistry taskCompleter TaskCompleter + auditor audit.Auditor } // NewPaymentService initializes a new payment service. @@ -103,6 +108,30 @@ func (s *paymentService) SetTaskCompleter(completer TaskCompleter) { s.taskCompleter = completer } +// WithAuditor injects an optional auditor for recording payment audit events. +// Passing nil disables auditing. +func (s *paymentService) WithAuditor(auditor audit.Auditor) { + s.auditor = auditor +} + +const eventTypePayment = "PAYMENT" + +// auditPayment is a nil-safe helper that emits a payment audit event. +func (s *paymentService) auditPayment(ctx context.Context, action audit.Action, status audit.Status, d AuditDetails) { + if s.auditor == nil { + return + } + s.auditor.Audit(ctx, audit.Event{ + Timestamp: time.Now().UTC(), + EventType: eventTypePayment, + Action: action, + Status: status, + TargetType: "RESOURCE", + TargetID: d.Reference, + Details: d, + }) +} + func (s *paymentService) ListAvailableMethods(ctx context.Context) ([]GatewayInfo, error) { return s.registry.ListInfo(), nil } @@ -196,7 +225,14 @@ func (s *paymentService) CreateCheckoutSession(ctx context.Context, req CreateCh slog.ErrorContext(ctx, "payment: failed to mark transaction failed after gateway error", "reference", tx.ReferenceNumber, "error", uerr) } - return nil, fmt.Errorf("gateway failed to create session: %w", err) + err = fmt.Errorf("gateway failed to create session: %w", err) + s.auditPayment(ctx, audit.ActionCreate, audit.StatusFailure, AuditDetails{ + GatewayID: req.GatewayID, + Reference: tx.ReferenceNumber, + Status: string(PaymentStatusFailed), + Error: err.Error(), + }) + return nil, err } // 4. Persist the gateway-assigned session id. @@ -211,6 +247,11 @@ func (s *paymentService) CreateCheckoutSession(ctx context.Context, req CreateCh return nil, errors.New("gateway returned nil session response") } + s.auditPayment(ctx, audit.ActionCreate, audit.StatusSuccess, AuditDetails{ + GatewayID: req.GatewayID, + Reference: generatedRef, + Status: string(PaymentStatusPending), + }) return &CreateCheckoutResponse{ ReferenceNumber: generatedRef, SessionID: sessionResp.SessionID, @@ -227,26 +268,46 @@ func (s *paymentService) ValidateReference(ctx context.Context, gatewayID string // 1. Get the gateway from the registry using the ID from the URL gateway, err := s.registry.Get(gatewayID) if err != nil { - return nil, fmt.Errorf("gateway %s not found: %w", gatewayID, err) + err = fmt.Errorf("gateway %s not found: %w", gatewayID, err) + s.auditPayment(ctx, audit.ActionRead, audit.StatusFailure, AuditDetails{ + GatewayID: gatewayID, + Error: err.Error(), + }) + return nil, err } // 2. Verify the caller before any gateway-specific parsing runs. No // reference lookup, and no presentment info, may be disclosed to an // unverified caller. if err := verifyCaller(ctx, gateway, gatewayID, rawBody, headers); err != nil { + s.auditPayment(ctx, audit.ActionRead, audit.StatusFailure, AuditDetails{ + GatewayID: gatewayID, + Error: err.Error(), + }) return nil, err } // 3. Extract reference number from raw body refNo, err := gateway.ExtractReferenceNumber(ctx, rawBody) if err != nil { - return nil, fmt.Errorf("failed to extract reference number: %w", err) + err = fmt.Errorf("failed to extract reference number: %w", err) + s.auditPayment(ctx, audit.ActionRead, audit.StatusFailure, AuditDetails{ + GatewayID: gatewayID, + Error: err.Error(), + }) + return nil, err } // 4. Look up the transaction metadata from the DB tx, err := s.repo.GetByReferenceNumber(ctx, refNo) if err != nil { - return nil, fmt.Errorf("failed to retrieve payment reference: %w", err) + err = fmt.Errorf("failed to retrieve payment reference: %w", err) + s.auditPayment(ctx, audit.ActionRead, audit.StatusFailure, AuditDetails{ + GatewayID: gatewayID, + Reference: refNo, + Error: err.Error(), + }) + return nil, err } // 5. Map the internal record to the gateway DTO and decide payability. @@ -272,36 +333,92 @@ func (s *paymentService) ValidateReference(ctx context.Context, gatewayID string } } + // Domain status is known once the transaction has been mapped, including on + // subsequent gateway formatting failures. Leave it empty only when there is + // no usable domain transaction (unknown reference or gateway mismatch). + status := "" + if validationTx != nil { + status = validationTx.Status + } + // 5. Delegate the protocol-specific response formatting to the gateway. - return gateway.HandleValidateReference(ctx, validationTx, isPayable, rawBody) + resp, err := gateway.HandleValidateReference(ctx, validationTx, isPayable, rawBody) + if err != nil { + s.auditPayment(ctx, audit.ActionRead, audit.StatusFailure, AuditDetails{ + GatewayID: gatewayID, + Reference: refNo, + Status: status, + Error: err.Error(), + }) + return nil, err + } + if resp == nil { + err = errors.New("gateway returned nil validation response") + s.auditPayment(ctx, audit.ActionRead, audit.StatusFailure, AuditDetails{ + GatewayID: gatewayID, + Reference: refNo, + Status: status, + Error: err.Error(), + }) + return nil, err + } + s.auditPayment(ctx, audit.ActionRead, audit.StatusSuccess, AuditDetails{ + GatewayID: gatewayID, Reference: refNo, Status: status, + }) + return resp, nil } func (s *paymentService) ProcessWebhook(ctx context.Context, gatewayID string, body []byte, headers map[string][]string) (*WebhookResponse, error) { gateway, err := s.registry.Get(gatewayID) if err != nil { - return nil, fmt.Errorf("failed to get gateway %s: %w", gatewayID, err) + err = fmt.Errorf("failed to get gateway %s: %w", gatewayID, err) + s.auditPayment(ctx, audit.ActionUpdate, audit.StatusFailure, AuditDetails{ + GatewayID: gatewayID, + Error: err.Error(), + }) + return nil, err } // Verify the caller before any gateway-specific parsing runs. No // transaction may be settled on the strength of an unverified caller. if err := verifyCaller(ctx, gateway, gatewayID, body, headers); err != nil { + s.auditPayment(ctx, audit.ActionUpdate, audit.StatusFailure, AuditDetails{ + GatewayID: gatewayID, + Error: err.Error(), + }) return nil, err } gwPayload, webhookResp, err := gateway.ParseWebhook(ctx, body, headers) if err != nil { - return nil, fmt.Errorf("gateway failed to parse webhook: %w", err) + err = fmt.Errorf("gateway failed to parse webhook: %w", err) + s.auditPayment(ctx, audit.ActionUpdate, audit.StatusFailure, AuditDetails{ + GatewayID: gatewayID, + Error: err.Error(), + }) + return nil, err } if gwPayload == nil { - return nil, fmt.Errorf("gateway returned nil webhook payload") + err = fmt.Errorf("gateway returned nil webhook payload") + s.auditPayment(ctx, audit.ActionUpdate, audit.StatusFailure, AuditDetails{ + GatewayID: gatewayID, + Error: err.Error(), + }) + return nil, err } // Translate the canonical gateway status into our domain status, rejecting // anything unrecognized (defense-in-depth against a misbehaving gateway). newStatus, err := toDomainStatus(gwPayload.Status) if err != nil { - return nil, fmt.Errorf("webhook for %s: %w", gwPayload.ReferenceNumber, err) + err = fmt.Errorf("webhook for %s: %w", gwPayload.ReferenceNumber, err) + s.auditPayment(ctx, audit.ActionUpdate, audit.StatusFailure, AuditDetails{ + GatewayID: gatewayID, + Reference: gwPayload.ReferenceNumber, + Error: err.Error(), + }) + return nil, err } // Claim and apply the status transition atomically. A row-level lock makes @@ -329,6 +446,7 @@ func (s *paymentService) ProcessWebhook(ctx context.Context, gatewayID string, b // concurrent delivery that committed first) — nothing more to do. if tx.Status == PaymentStatusSuccess || tx.Status == PaymentStatusFailed { slog.InfoContext(ctx, "webhook ignored (idempotent)", "reference", tx.ReferenceNumber, "current_status", tx.Status) + finalStatus = tx.Status return nil } @@ -363,6 +481,11 @@ func (s *paymentService) ProcessWebhook(ctx context.Context, gatewayID string, b return nil }) if err != nil { + s.auditPayment(ctx, audit.ActionUpdate, audit.StatusFailure, AuditDetails{ + GatewayID: gatewayID, + Reference: gwPayload.ReferenceNumber, + Error: err.Error(), + }) return nil, err } @@ -371,6 +494,11 @@ func (s *paymentService) ProcessWebhook(ctx context.Context, gatewayID string, b // Already terminal / nothing claimed — don't advance again. if !advance { + s.auditPayment(ctx, audit.ActionUpdate, audit.StatusSuccess, AuditDetails{ + GatewayID: gatewayID, + Reference: gwPayload.ReferenceNumber, + Status: string(finalStatus), + }) return webhookResp, nil } @@ -381,6 +509,11 @@ func (s *paymentService) ProcessWebhook(ctx context.Context, gatewayID string, b // task signal; any other status leaves the task untouched so a non-terminal or // unrecognized gateway status can't be misread as paid. if s.taskCompleter == nil { + s.auditPayment(ctx, audit.ActionUpdate, audit.StatusSuccess, AuditDetails{ + GatewayID: gatewayID, + Reference: gwPayload.ReferenceNumber, + Status: string(finalStatus), + }) return webhookResp, nil } @@ -394,6 +527,11 @@ func (s *paymentService) ProcessWebhook(ctx context.Context, gatewayID string, b if statusStr == "" { slog.WarnContext(ctx, "payment: non-terminal webhook status, not advancing task", "reference", gwPayload.ReferenceNumber, "task_id", advanceTask, "status", finalStatus) + s.auditPayment(ctx, audit.ActionUpdate, audit.StatusSuccess, AuditDetails{ + GatewayID: gatewayID, + Reference: gwPayload.ReferenceNumber, + Status: string(finalStatus), + }) return webhookResp, nil } @@ -410,8 +548,20 @@ func (s *paymentService) ProcessWebhook(ctx context.Context, gatewayID string, b // The transaction is already persisted; log and let the gateway retry // drive a re-attempt rather than masking the failure as success. slog.ErrorContext(ctx, "payment: failed to advance task step", "task_id", advanceTask, "error", err) + s.auditPayment(ctx, audit.ActionUpdate, audit.StatusFailure, AuditDetails{ + GatewayID: gatewayID, + Reference: gwPayload.ReferenceNumber, + Status: string(finalStatus), + Error: err.Error(), + }) return nil, fmt.Errorf("failed to advance task step for %s: %w", advanceTask, err) } + s.auditPayment(ctx, audit.ActionUpdate, audit.StatusSuccess, AuditDetails{ + GatewayID: gatewayID, + Reference: gwPayload.ReferenceNumber, + Status: string(finalStatus), + }) + return webhookResp, nil } diff --git a/payment/service_test.go b/payment/service_test.go index 266d5bf..dd67811 100644 --- a/payment/service_test.go +++ b/payment/service_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/OpenNSW/core/shared/audit" "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -127,6 +128,21 @@ func (m *mockTaskCompleter) CompleteTaskStep(_ context.Context, taskID string, p return m.err } +type mockAuditor struct { + events []audit.Event +} + +func (m *mockAuditor) Audit(_ context.Context, e audit.Event) { + m.events = append(m.events, e) +} + +func paymentDetails(t *testing.T, e audit.Event) AuditDetails { + t.Helper() + d, ok := e.Details.(AuditDetails) + require.True(t, ok, "Details type %T", e.Details) + return d +} + func validCheckoutReq() CreateCheckoutRequest { return CreateCheckoutRequest{ GatewayID: "govpay", @@ -255,6 +271,45 @@ func TestCreateCheckoutSession_GatewaySessionError_MarksFailed(t *testing.T) { } } +func TestCreateCheckoutSession_Success_AuditsPending(t *testing.T) { + repo := newMockRepo() + gw := new(MockGateway) + gw.On("CreateSession", mock.Anything, mock.Anything). + Return(&SessionResponse{SessionID: "sess-1", Type: FlowTypeInstruction}, nil) + auditor := &mockAuditor{} + svc := NewPaymentService(repo, &mockRegistry{gw: gw}) + svc.WithAuditor(auditor) + + resp, err := svc.CreateCheckoutSession(context.Background(), validCheckoutReq()) + require.NoError(t, err) + require.Len(t, auditor.events, 1) + assert.Equal(t, eventTypePayment, auditor.events[0].EventType) + assert.Equal(t, audit.ActionCreate, auditor.events[0].Action) + assert.Equal(t, "govpay", paymentDetails(t, auditor.events[0]).GatewayID) + assert.Equal(t, resp.ReferenceNumber, paymentDetails(t, auditor.events[0]).Reference) + assert.Equal(t, string(PaymentStatusPending), paymentDetails(t, auditor.events[0]).Status) + assert.Equal(t, audit.StatusSuccess, auditor.events[0].Status) +} + +func TestCreateCheckoutSession_GatewaySessionError_AuditsFailed(t *testing.T) { + repo := newMockRepo() + gw := new(MockGateway) + gw.On("CreateSession", mock.Anything, mock.Anything).Return(nil, errors.New("boom")) + auditor := &mockAuditor{} + svc := NewPaymentService(repo, &mockRegistry{gw: gw}) + svc.WithAuditor(auditor) + + _, err := svc.CreateCheckoutSession(context.Background(), validCheckoutReq()) + require.Error(t, err) + require.Len(t, auditor.events, 1) + assert.Equal(t, audit.ActionCreate, auditor.events[0].Action) + assert.Equal(t, "govpay", paymentDetails(t, auditor.events[0]).GatewayID) + assert.NotEmpty(t, paymentDetails(t, auditor.events[0]).Reference) + assert.Equal(t, string(PaymentStatusFailed), paymentDetails(t, auditor.events[0]).Status) + assert.Equal(t, audit.StatusFailure, auditor.events[0].Status) + assert.Contains(t, paymentDetails(t, auditor.events[0]).Error, "boom") +} + func TestCreateCheckoutSession_ReferenceCollisionRetry(t *testing.T) { repo := newMockRepo() repo.collide = 1 // first candidate "exists", second is free @@ -567,6 +622,360 @@ func TestProcessWebhook_CompleterErrorPropagates(t *testing.T) { assert.Equal(t, PaymentStatusSuccess, repo.txs["TNSW1"].Status, "status is committed before the advance call") } +func TestProcessWebhook_Idempotent_AuditRecordsStatus(t *testing.T) { + repo := newMockRepo() + settled := pendingTx() + settled.Status = PaymentStatusSuccess + repo.txs["TNSW1"] = settled + gw := webhookGateway(&WebhookPayload{ + ReferenceNumber: "TNSW1", + Status: WebhookStatusSuccess, + Amount: decimal.RequireFromString("1500.00"), + Currency: "LKR", + }) + auditor := &mockAuditor{} + svc := NewPaymentService(repo, &mockRegistry{gw: gw}) + svc.WithAuditor(auditor) + + _, err := svc.ProcessWebhook(context.Background(), "govpay", []byte(`{}`), nil) + require.NoError(t, err) + require.Len(t, auditor.events, 1) + assert.Equal(t, audit.ActionUpdate, auditor.events[0].Action) + assert.Equal(t, "govpay", paymentDetails(t, auditor.events[0]).GatewayID) + assert.Equal(t, "TNSW1", paymentDetails(t, auditor.events[0]).Reference) + assert.Equal(t, "SUCCESS", paymentDetails(t, auditor.events[0]).Status) + assert.Equal(t, audit.StatusSuccess, auditor.events[0].Status) +} + +func TestProcessWebhook_CompleterError_AuditsFailure(t *testing.T) { + repo := newMockRepo() + repo.txs["TNSW1"] = pendingTx() + gw := webhookGateway(&WebhookPayload{ + ReferenceNumber: "TNSW1", + Status: WebhookStatusSuccess, + Amount: decimal.RequireFromString("1500.00"), + Currency: "LKR", + }) + tc := &mockTaskCompleter{err: errors.New("task engine down")} + auditor := &mockAuditor{} + svc := NewPaymentService(repo, &mockRegistry{gw: gw}) + svc.SetTaskCompleter(tc) + svc.WithAuditor(auditor) + + _, err := svc.ProcessWebhook(context.Background(), "govpay", []byte(`{}`), nil) + require.Error(t, err) + require.Len(t, auditor.events, 1) + assert.Equal(t, audit.ActionUpdate, auditor.events[0].Action) + assert.Equal(t, "govpay", paymentDetails(t, auditor.events[0]).GatewayID) + assert.Equal(t, "TNSW1", paymentDetails(t, auditor.events[0]).Reference) + assert.Equal(t, "SUCCESS", paymentDetails(t, auditor.events[0]).Status) + assert.Equal(t, audit.StatusFailure, auditor.events[0].Status) + assert.Contains(t, paymentDetails(t, auditor.events[0]).Error, "task engine down") +} + +func TestProcessWebhook_Success_AuditsSuccess(t *testing.T) { + repo := newMockRepo() + repo.txs["TNSW1"] = pendingTx() + gw := webhookGateway(&WebhookPayload{ + ReferenceNumber: "TNSW1", + Status: WebhookStatusSuccess, + Amount: decimal.RequireFromString("1500.00"), + Currency: "LKR", + }) + tc := &mockTaskCompleter{} + auditor := &mockAuditor{} + svc := NewPaymentService(repo, &mockRegistry{gw: gw}) + svc.SetTaskCompleter(tc) + svc.WithAuditor(auditor) + + _, err := svc.ProcessWebhook(context.Background(), "govpay", []byte(`{}`), nil) + require.NoError(t, err) + require.Len(t, auditor.events, 1) + assert.Equal(t, audit.ActionUpdate, auditor.events[0].Action) + assert.Equal(t, "govpay", paymentDetails(t, auditor.events[0]).GatewayID) + assert.Equal(t, "TNSW1", paymentDetails(t, auditor.events[0]).Reference) + assert.Equal(t, "SUCCESS", paymentDetails(t, auditor.events[0]).Status) + assert.Equal(t, audit.StatusSuccess, auditor.events[0].Status) +} + +func TestValidateReference_Success_AuditsStatus(t *testing.T) { + repo := newMockRepo() + repo.txs["TNSW1"] = &PaymentTransaction{ + ReferenceNumber: "TNSW1", + GatewayID: "govpay", + Status: PaymentStatusPending, + Amount: decimal.RequireFromString("100"), + Currency: "LKR", + ExpiryDate: time.Now().Add(time.Hour), + } + gw := validateGateway("TNSW1") + gw.On("HandleValidateReference", mock.Anything, mock.Anything, true, mock.Anything). + Return(&ValidationResponse{HTTPStatus: 200, Payload: []byte(`{}`)}, nil) + auditor := &mockAuditor{} + svc := NewPaymentService(repo, &mockRegistry{gw: gw}) + svc.WithAuditor(auditor) + + _, err := svc.ValidateReference(context.Background(), "govpay", []byte(`{}`), nil) + require.NoError(t, err) + require.Len(t, auditor.events, 1) + assert.Equal(t, audit.ActionRead, auditor.events[0].Action) + assert.Equal(t, "govpay", paymentDetails(t, auditor.events[0]).GatewayID) + assert.Equal(t, "TNSW1", paymentDetails(t, auditor.events[0]).Reference) + assert.Equal(t, "PENDING", paymentDetails(t, auditor.events[0]).Status) + assert.Equal(t, audit.StatusSuccess, auditor.events[0].Status) +} + +func TestValidateReference_UnknownReference_AuditsEmptyStatus(t *testing.T) { + gw := validateGateway("NOPE") + gw.On("HandleValidateReference", mock.Anything, + mock.MatchedBy(func(tx *ValidationTransaction) bool { return tx == nil }), + false, mock.Anything). + Return(&ValidationResponse{HTTPStatus: 200, Payload: []byte(`{}`)}, nil) + auditor := &mockAuditor{} + svc := NewPaymentService(newMockRepo(), &mockRegistry{gw: gw}) + svc.WithAuditor(auditor) + + _, err := svc.ValidateReference(context.Background(), "govpay", []byte(`{}`), nil) + require.NoError(t, err) + require.Len(t, auditor.events, 1) + assert.Equal(t, audit.ActionRead, auditor.events[0].Action) + assert.Equal(t, "NOPE", paymentDetails(t, auditor.events[0]).Reference) + assert.Empty(t, paymentDetails(t, auditor.events[0]).Status) + assert.Equal(t, audit.StatusSuccess, auditor.events[0].Status) +} + +func TestValidateReference_NilResponse_AuditsFailure(t *testing.T) { + gw := validateGateway("TNSW1") + gw.On("HandleValidateReference", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil, nil) + auditor := &mockAuditor{} + svc := NewPaymentService(newMockRepo(), &mockRegistry{gw: gw}) + svc.WithAuditor(auditor) + + _, err := svc.ValidateReference(context.Background(), "govpay", []byte(`{}`), nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "nil validation response") + require.Len(t, auditor.events, 1) + assert.Equal(t, audit.ActionRead, auditor.events[0].Action) + assert.Equal(t, audit.StatusFailure, auditor.events[0].Status) + assert.Contains(t, paymentDetails(t, auditor.events[0]).Error, "nil validation response") + assert.Empty(t, paymentDetails(t, auditor.events[0]).Status) +} + +func TestValidateReference_NilResponse_MatchingTx_AuditsStatus(t *testing.T) { + repo := newMockRepo() + repo.txs["TNSW1"] = &PaymentTransaction{ + ReferenceNumber: "TNSW1", + GatewayID: "govpay", + Status: PaymentStatusPending, + Amount: decimal.RequireFromString("100"), + Currency: "LKR", + ExpiryDate: time.Now().Add(time.Hour), + } + gw := validateGateway("TNSW1") + gw.On("HandleValidateReference", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil, nil) + auditor := &mockAuditor{} + svc := NewPaymentService(repo, &mockRegistry{gw: gw}) + svc.WithAuditor(auditor) + + _, err := svc.ValidateReference(context.Background(), "govpay", []byte(`{}`), nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "nil validation response") + require.Len(t, auditor.events, 1) + assert.Equal(t, audit.ActionRead, auditor.events[0].Action) + assert.Equal(t, audit.StatusFailure, auditor.events[0].Status) + assert.Equal(t, "PENDING", paymentDetails(t, auditor.events[0]).Status) +} + +func TestValidateReference_FailurePaths_Audit(t *testing.T) { + t.Run("gateway lookup", func(t *testing.T) { + auditor := &mockAuditor{} + svc := NewPaymentService(newMockRepo(), &mockRegistry{getErr: errors.New("nope")}) + svc.WithAuditor(auditor) + + _, err := svc.ValidateReference(context.Background(), "govpay", []byte(`{}`), nil) + require.Error(t, err) + require.Len(t, auditor.events, 1) + assert.Equal(t, audit.ActionRead, auditor.events[0].Action) + assert.Equal(t, "govpay", paymentDetails(t, auditor.events[0]).GatewayID) + assert.Equal(t, audit.StatusFailure, auditor.events[0].Status) + assert.Contains(t, paymentDetails(t, auditor.events[0]).Error, "nope") + }) + + t.Run("verification", func(t *testing.T) { + gw := new(MockGateway) + gw.On("VerifyWebhook", mock.Anything, mock.Anything, mock.Anything). + Return(fmt.Errorf("bad signature: %w", ErrWebhookVerificationFailed)) + auditor := &mockAuditor{} + svc := NewPaymentService(newMockRepo(), &mockRegistry{gw: gw}) + svc.WithAuditor(auditor) + + _, err := svc.ValidateReference(context.Background(), "govpay", []byte(`{}`), nil) + require.ErrorIs(t, err, ErrWebhookVerificationFailed) + require.Len(t, auditor.events, 1) + assert.Equal(t, audit.ActionRead, auditor.events[0].Action) + assert.Equal(t, audit.StatusFailure, auditor.events[0].Status) + assert.Contains(t, paymentDetails(t, auditor.events[0]).Error, "bad signature") + }) + + t.Run("extract", func(t *testing.T) { + gw := new(MockGateway) + gw.On("VerifyWebhook", mock.Anything, mock.Anything, mock.Anything).Return(nil) + gw.On("ExtractReferenceNumber", mock.Anything, mock.Anything).Return("", errors.New("bad body")) + auditor := &mockAuditor{} + svc := NewPaymentService(newMockRepo(), &mockRegistry{gw: gw}) + svc.WithAuditor(auditor) + + _, err := svc.ValidateReference(context.Background(), "govpay", []byte(`{}`), nil) + require.Error(t, err) + require.Len(t, auditor.events, 1) + assert.Equal(t, audit.ActionRead, auditor.events[0].Action) + assert.Equal(t, audit.StatusFailure, auditor.events[0].Status) + assert.Contains(t, paymentDetails(t, auditor.events[0]).Error, "bad body") + }) + + t.Run("repository", func(t *testing.T) { + repo := newMockRepo() + repo.getErr = errors.New("db down") + auditor := &mockAuditor{} + svc := NewPaymentService(repo, &mockRegistry{gw: validateGateway("TNSW1")}) + svc.WithAuditor(auditor) + + _, err := svc.ValidateReference(context.Background(), "govpay", []byte(`{}`), nil) + require.Error(t, err) + require.Len(t, auditor.events, 1) + assert.Equal(t, audit.ActionRead, auditor.events[0].Action) + assert.Equal(t, "TNSW1", paymentDetails(t, auditor.events[0]).Reference) + assert.Equal(t, audit.StatusFailure, auditor.events[0].Status) + assert.Contains(t, paymentDetails(t, auditor.events[0]).Error, "db down") + }) + + t.Run("handle validate", func(t *testing.T) { + gw := validateGateway("TNSW1") + gw.On("HandleValidateReference", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil, errors.New("gateway format error")) + auditor := &mockAuditor{} + svc := NewPaymentService(newMockRepo(), &mockRegistry{gw: gw}) + svc.WithAuditor(auditor) + + _, err := svc.ValidateReference(context.Background(), "govpay", []byte(`{}`), nil) + require.Error(t, err) + require.Len(t, auditor.events, 1) + assert.Equal(t, audit.ActionRead, auditor.events[0].Action) + assert.Equal(t, audit.StatusFailure, auditor.events[0].Status) + assert.Contains(t, paymentDetails(t, auditor.events[0]).Error, "gateway format error") + assert.Empty(t, paymentDetails(t, auditor.events[0]).Status) + }) + + t.Run("handle validate with matching tx", func(t *testing.T) { + repo := newMockRepo() + repo.txs["TNSW1"] = &PaymentTransaction{ + ReferenceNumber: "TNSW1", + GatewayID: "govpay", + Status: PaymentStatusPending, + Amount: decimal.RequireFromString("100"), + Currency: "LKR", + ExpiryDate: time.Now().Add(time.Hour), + } + gw := validateGateway("TNSW1") + gw.On("HandleValidateReference", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil, errors.New("gateway format error")) + auditor := &mockAuditor{} + svc := NewPaymentService(repo, &mockRegistry{gw: gw}) + svc.WithAuditor(auditor) + + _, err := svc.ValidateReference(context.Background(), "govpay", []byte(`{}`), nil) + require.Error(t, err) + require.Len(t, auditor.events, 1) + assert.Equal(t, audit.ActionRead, auditor.events[0].Action) + assert.Equal(t, audit.StatusFailure, auditor.events[0].Status) + assert.Contains(t, paymentDetails(t, auditor.events[0]).Error, "gateway format error") + assert.Equal(t, "PENDING", paymentDetails(t, auditor.events[0]).Status) + }) +} + +func TestProcessWebhook_FailurePaths_Audit(t *testing.T) { + t.Run("gateway lookup", func(t *testing.T) { + auditor := &mockAuditor{} + svc := NewPaymentService(newMockRepo(), &mockRegistry{getErr: errors.New("nope")}) + svc.WithAuditor(auditor) + + _, err := svc.ProcessWebhook(context.Background(), "govpay", []byte(`{}`), nil) + require.Error(t, err) + require.Len(t, auditor.events, 1) + assert.Equal(t, audit.ActionUpdate, auditor.events[0].Action) + assert.Equal(t, audit.StatusFailure, auditor.events[0].Status) + assert.Contains(t, paymentDetails(t, auditor.events[0]).Error, "nope") + }) + + t.Run("verification", func(t *testing.T) { + gw := new(MockGateway) + gw.On("VerifyWebhook", mock.Anything, mock.Anything, mock.Anything). + Return(fmt.Errorf("bad signature: %w", ErrWebhookVerificationFailed)) + auditor := &mockAuditor{} + svc := NewPaymentService(newMockRepo(), &mockRegistry{gw: gw}) + svc.WithAuditor(auditor) + + _, err := svc.ProcessWebhook(context.Background(), "govpay", []byte(`{}`), nil) + require.ErrorIs(t, err, ErrWebhookVerificationFailed) + require.Len(t, auditor.events, 1) + assert.Equal(t, audit.ActionUpdate, auditor.events[0].Action) + assert.Equal(t, audit.StatusFailure, auditor.events[0].Status) + assert.Contains(t, paymentDetails(t, auditor.events[0]).Error, "bad signature") + }) + + t.Run("parse", func(t *testing.T) { + gw := new(MockGateway) + gw.On("VerifyWebhook", mock.Anything, mock.Anything, mock.Anything).Return(nil) + gw.On("ParseWebhook", mock.Anything, mock.Anything, mock.Anything). + Return(nil, nil, errors.New("bad payload")) + auditor := &mockAuditor{} + svc := NewPaymentService(newMockRepo(), &mockRegistry{gw: gw}) + svc.WithAuditor(auditor) + + _, err := svc.ProcessWebhook(context.Background(), "govpay", []byte(`{}`), nil) + require.Error(t, err) + require.Len(t, auditor.events, 1) + assert.Equal(t, audit.ActionUpdate, auditor.events[0].Action) + assert.Equal(t, audit.StatusFailure, auditor.events[0].Status) + assert.Contains(t, paymentDetails(t, auditor.events[0]).Error, "bad payload") + }) + + t.Run("nil payload", func(t *testing.T) { + gw := new(MockGateway) + gw.On("VerifyWebhook", mock.Anything, mock.Anything, mock.Anything).Return(nil) + gw.On("ParseWebhook", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil, nil) + auditor := &mockAuditor{} + svc := NewPaymentService(newMockRepo(), &mockRegistry{gw: gw}) + svc.WithAuditor(auditor) + + _, err := svc.ProcessWebhook(context.Background(), "govpay", []byte(`{}`), nil) + require.Error(t, err) + require.Len(t, auditor.events, 1) + assert.Equal(t, audit.ActionUpdate, auditor.events[0].Action) + assert.Equal(t, audit.StatusFailure, auditor.events[0].Status) + assert.Contains(t, paymentDetails(t, auditor.events[0]).Error, "nil webhook payload") + }) + + t.Run("status mapping", func(t *testing.T) { + gw := webhookGateway(&WebhookPayload{ + ReferenceNumber: "TNSW1", + Status: WebhookStatus("WEIRD"), + }) + auditor := &mockAuditor{} + svc := NewPaymentService(newMockRepo(), &mockRegistry{gw: gw}) + svc.WithAuditor(auditor) + + _, err := svc.ProcessWebhook(context.Background(), "govpay", []byte(`{}`), nil) + require.ErrorIs(t, err, ErrUnsupportedWebhookStatus) + require.Len(t, auditor.events, 1) + assert.Equal(t, audit.ActionUpdate, auditor.events[0].Action) + assert.Equal(t, "TNSW1", paymentDetails(t, auditor.events[0]).Reference) + assert.Equal(t, audit.StatusFailure, auditor.events[0].Status) + }) +} + func TestProcessWebhook_VerificationFailure_NeverParses(t *testing.T) { gw := new(MockGateway) gw.On("VerifyWebhook", mock.Anything, mock.Anything, mock.Anything).Return(fmt.Errorf("bad signature: %w", ErrWebhookVerificationFailed)) diff --git a/shared/README.md b/shared/README.md index 5ec470c..5617d50 100644 --- a/shared/README.md +++ b/shared/README.md @@ -34,3 +34,23 @@ Shared validation helpers for config structs. err := validation.TCPPort("Port", cfg.Port) // 1-65535 err := validation.HTTPURL("Endpoint", cfg.URL) // absolute http(s) URL ``` + +## `audit` + +Shared auditor callback and event shape used by domain services. `Event` +carries Argus-aligned fields (`Action`, `Status`, actor/target, …). +Domain-specific payloads live on `Details` in the emitting package, not on +`Event` itself. + +```go +type sink struct{} +func (sink) Audit(ctx context.Context, e audit.Event) { /* persist e */ } + +type paymentDetails struct { + GatewayID string + Reference string +} +func (d paymentDetails) Metadata() map[string]any { + return map[string]any{"gateway_id": d.GatewayID, "reference": d.Reference} +} +``` diff --git a/shared/audit/audit.go b/shared/audit/audit.go new file mode 100644 index 0000000..7306ced --- /dev/null +++ b/shared/audit/audit.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Lanka Software Foundation + +// Package audit defines a domain-agnostic auditor callback and event shape. +// Domain-specific payloads live in the emitting package as Details implementations. +package audit + +import ( + "context" + "time" +) + +// Action is a CRUD operation, matching Argus audit-log actions. +type Action string + +const ( + ActionCreate Action = "CREATE" + ActionRead Action = "READ" + ActionUpdate Action = "UPDATE" + ActionDelete Action = "DELETE" +) + +// Status is the outcome of an audited operation, matching Argus audit-log status. +type Status string + +const ( + StatusSuccess Status = "SUCCESS" + StatusFailure Status = "FAILURE" +) + +// Details is a domain-owned payload attached to an Event. Each service +// (payment, storage, …) defines its own type that implements Metadata. +// Nil Details means the event has nothing extra to attach. +type Details interface { + Metadata() map[string]any +} + +// Event is a domain-agnostic audit record. Fields line up with +// github.com/LSFLK/argus/pkg/audit.AuditLogRequest so a later bridge is a +// straight field copy (Details.Metadata() becomes Metadata). +type Event struct { + TraceID string + Timestamp time.Time + EventType string + Action Action + Status Status + ActorType string + ActorID string + TargetType string + TargetID string + + Details Details +} + +// Auditor is an optional callback that services use to emit audit events. +// Implementations must be safe to call from any goroutine. +type Auditor interface { + Audit(ctx context.Context, e Event) +} diff --git a/shared/audit/audit_test.go b/shared/audit/audit_test.go new file mode 100644 index 0000000..ed66ff7 --- /dev/null +++ b/shared/audit/audit_test.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Lanka Software Foundation + +package audit + +import ( + "context" + "testing" + "time" +) + +type sampleDetails struct { + Key string +} + +func (d sampleDetails) Metadata() map[string]any { + return map[string]any{"key": d.Key} +} + +func TestEvent_DetailsOwnedByEmitter(t *testing.T) { + var recorded Event + auditor := AuditorFunc(func(_ context.Context, e Event) { recorded = e }) + + auditor.Audit(context.Background(), Event{ + EventType: "STORAGE", + Action: ActionDelete, + Status: StatusSuccess, + TargetType: "RESOURCE", + TargetID: "abc", + Timestamp: time.Unix(1, 0).UTC(), + Details: sampleDetails{Key: "abc"}, + }) + + if recorded.Action != ActionDelete || recorded.Status != StatusSuccess { + t.Fatalf("got action=%s status=%s", recorded.Action, recorded.Status) + } + d, ok := recorded.Details.(sampleDetails) + if !ok || d.Key != "abc" { + t.Fatalf("Details should round-trip as the emitter's type, got %#v", recorded.Details) + } + if recorded.Details.Metadata()["key"] != "abc" { + t.Fatalf("Metadata() = %v", recorded.Details.Metadata()) + } +} + +func TestEvent_NilDetails(t *testing.T) { + e := Event{Action: ActionRead, Status: StatusFailure} + if e.Details != nil { + t.Fatal("zero Event must have nil Details") + } + if e.Action != ActionRead || e.Status != StatusFailure { + t.Fatalf("got action=%s status=%s", e.Action, e.Status) + } +} + +// AuditorFunc adapts a function to Auditor for tests. +type AuditorFunc func(context.Context, Event) + +func (f AuditorFunc) Audit(ctx context.Context, e Event) { f(ctx, e) } diff --git a/storage/audit.go b/storage/audit.go new file mode 100644 index 0000000..86a70a9 --- /dev/null +++ b/storage/audit.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Lanka Software Foundation + +package storage + +import "github.com/OpenNSW/core/shared/audit" + +var _ audit.Details = AuditDetails{} + +// AuditDetails is the storage-owned payload on an audit.Event. +type AuditDetails struct { + Key string + Filename string + MimeType string + Size int64 + Error string +} + +func (d AuditDetails) Metadata() map[string]any { + m := make(map[string]any, 5) + if d.Key != "" { + m["key"] = d.Key + } + if d.Filename != "" { + m["filename"] = d.Filename + } + if d.MimeType != "" { + m["mime_type"] = d.MimeType + } + if d.Size != 0 { + m["size"] = d.Size + } + if d.Error != "" { + m["error"] = d.Error + } + return m +} diff --git a/storage/go.mod b/storage/go.mod index e2abf7a..b117d0e 100644 --- a/storage/go.mod +++ b/storage/go.mod @@ -29,3 +29,5 @@ require ( github.com/aws/smithy-go v1.28.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect ) + +replace github.com/OpenNSW/core/shared => ../shared diff --git a/storage/go.sum b/storage/go.sum index c90b1eb..9a6c720 100644 --- a/storage/go.sum +++ b/storage/go.sum @@ -1,7 +1,5 @@ github.com/OpenNSW/core/authn v0.3.0 h1:3IuKH2IQm0/GYoDBJFzVMSVWvzVJPLOnmxScHL76B+8= github.com/OpenNSW/core/authn v0.3.0/go.mod h1:9/yGWkx5t5u0+YBUH4FsaZO6wpq/5WK5tdyOD+yL2Ek= -github.com/OpenNSW/core/shared v0.3.0 h1:HcWcXyMdwzMfsVvo1HG2OTBYaQTRipTpLI0AFx/5Wgs= -github.com/OpenNSW/core/shared v0.3.0/go.mod h1:Lgh13h6agGl6WFR9i1W0iaUUky7wC0uLJrVc+FRRtMU= github.com/aws/aws-sdk-go-v2 v1.45.1 h1:iIoG3NaLhV6UZpPXyPXlDj2I9oS8tV/nMcMnITCC6Ks= github.com/aws/aws-sdk-go-v2 v1.45.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs= diff --git a/storage/service.go b/storage/service.go index 39ce265..d1d6647 100644 --- a/storage/service.go +++ b/storage/service.go @@ -8,20 +8,29 @@ import ( "fmt" "io" "path/filepath" + "time" + "github.com/OpenNSW/core/shared/audit" "github.com/OpenNSW/core/storage/drivers" "github.com/google/uuid" ) // Service coordinates file storage operations and manages metadata type Service struct { - Driver StorageDriver + Driver StorageDriver + Auditor audit.Auditor // optional; nil means no auditing } func NewService(driver StorageDriver) *Service { return &Service{Driver: driver} } +// WithAuditor injects an optional auditor for recording storage audit events. +// Passing nil disables auditing. +func (s *Service) WithAuditor(auditor audit.Auditor) { + s.Auditor = auditor +} + // Upload handles the preparation of a file upload by generating a unique key // and a presigned/upload URL via the storage driver. func (s *Service) Upload(ctx context.Context, filename string, size int64, mime string) (*FileMetadata, error) { @@ -35,6 +44,9 @@ func (s *Service) Upload(ctx context.Context, filename string, size int64, mime // Generate a presigned URL for the upload uploadURL, err := s.Driver.GetUploadURL(ctx, key, mime, size) if err != nil { + s.audit(ctx, eventTypePresignUpload, audit.ActionCreate, audit.StatusFailure, AuditDetails{ + Key: key, Filename: filename, MimeType: mime, Size: size, Error: err.Error(), + }) return nil, fmt.Errorf("failed to generate upload URL: %w", err) } @@ -47,24 +59,69 @@ func (s *Service) Upload(ctx context.Context, filename string, size int64, mime MimeType: mime, } + s.audit(ctx, eventTypePresignUpload, audit.ActionCreate, audit.StatusSuccess, AuditDetails{ + Key: key, Filename: filename, MimeType: mime, Size: size, + }) + return metadata, nil } // Download retrieves the file content and its MIME type func (s *Service) Download(ctx context.Context, key string) (io.ReadCloser, string, error) { - return s.Driver.Get(ctx, key) + rc, mime, err := s.Driver.Get(ctx, key) + if err != nil { + s.audit(ctx, eventTypeStorage, audit.ActionRead, audit.StatusFailure, AuditDetails{ + Key: key, Error: err.Error(), + }) + return nil, "", err + } + s.audit(ctx, eventTypeStorage, audit.ActionRead, audit.StatusSuccess, AuditDetails{Key: key, MimeType: mime}) + return rc, mime, nil } // GetDownloadURL generates a time-limited or presigned URL for the given key func (s *Service) GetDownloadURL(ctx context.Context, key string) (string, error) { - return s.Driver.GetDownloadURL(ctx, key) + url, err := s.Driver.GetDownloadURL(ctx, key) + if err != nil { + s.audit(ctx, eventTypeStorage, audit.ActionRead, audit.StatusFailure, AuditDetails{ + Key: key, Error: err.Error(), + }) + return "", err + } + s.audit(ctx, eventTypeStorage, audit.ActionRead, audit.StatusSuccess, AuditDetails{Key: key}) + return url, nil } // Delete removes a file from storage func (s *Service) Delete(ctx context.Context, key string) error { err := s.Driver.Delete(ctx, key) if err != nil { + s.audit(ctx, eventTypeStorage, audit.ActionDelete, audit.StatusFailure, AuditDetails{ + Key: key, Error: err.Error(), + }) return fmt.Errorf("failed to delete file: %w", err) } + s.audit(ctx, eventTypeStorage, audit.ActionDelete, audit.StatusSuccess, AuditDetails{Key: key}) return nil } + +const ( + eventTypeStorage = "STORAGE" + eventTypePresignUpload = "PRESIGN_UPLOAD" +) + +// audit is a nil-safe helper that emits an event only when an Auditor is set. +func (s *Service) audit(ctx context.Context, eventType string, action audit.Action, status audit.Status, d AuditDetails) { + if s.Auditor == nil { + return + } + s.Auditor.Audit(ctx, audit.Event{ + Timestamp: time.Now().UTC(), + EventType: eventType, + Action: action, + Status: status, + TargetType: "RESOURCE", + TargetID: d.Key, + Details: d, + }) +} diff --git a/storage/service_test.go b/storage/service_test.go index b47809f..27999e4 100644 --- a/storage/service_test.go +++ b/storage/service_test.go @@ -9,6 +9,8 @@ import ( "errors" "io" "testing" + + "github.com/OpenNSW/core/shared/audit" ) // MockDriver implements StorageDriver for testing @@ -16,6 +18,8 @@ type MockDriver struct { SavedKey string SavedBody []byte GenerateURLErr error + GetErr error + DeleteErr error DeleteCalled bool DeleteKey string } @@ -31,12 +35,18 @@ func (m *MockDriver) Save(ctx context.Context, key string, body io.Reader, conte } func (m *MockDriver) Get(ctx context.Context, key string) (io.ReadCloser, string, error) { + if m.GetErr != nil { + return nil, "", m.GetErr + } return io.NopCloser(bytes.NewReader(m.SavedBody)), "application/test", nil } func (m *MockDriver) Delete(ctx context.Context, key string) error { m.DeleteCalled = true m.DeleteKey = key + if m.DeleteErr != nil { + return m.DeleteErr + } return nil } @@ -54,6 +64,23 @@ func (m *MockDriver) GetUploadURL(ctx context.Context, key string, contentType s return "/test/upload/" + key, nil } +type mockAuditor struct { + events []audit.Event +} + +func (m *mockAuditor) Audit(_ context.Context, e audit.Event) { + m.events = append(m.events, e) +} + +func storageDetails(t *testing.T, e audit.Event) AuditDetails { + t.Helper() + d, ok := e.Details.(AuditDetails) + if !ok { + t.Fatalf("Details type %T", e.Details) + } + return d +} + func TestUploadService(t *testing.T) { mock := &MockDriver{} service := NewService(mock) @@ -80,6 +107,55 @@ func TestUploadService(t *testing.T) { } } +func TestUpload_Audit_Success(t *testing.T) { + mock := &MockDriver{} + auditor := &mockAuditor{} + service := NewService(mock) + service.WithAuditor(auditor) + + ctx := context.Background() + filename := "test.jpg" + size := int64(1024) + mime := "image/jpeg" + + metadata, err := service.Upload(ctx, filename, size, mime) + if err != nil { + t.Fatalf("Upload failed: %v", err) + } + + if len(auditor.events) != 1 { + t.Fatalf("expected 1 audit event, got %d", len(auditor.events)) + } + ev := auditor.events[0] + d := storageDetails(t, ev) + if ev.EventType != eventTypePresignUpload || ev.Action != audit.ActionCreate || ev.Status != audit.StatusSuccess || d.Key != metadata.Key || d.Filename != filename || d.MimeType != mime || d.Size != size { + t.Errorf("unexpected audit event: %+v details=%+v", ev, d) + } +} + +func TestUpload_Audit_DriverError(t *testing.T) { + driverErr := errors.New("driver presign failed") + mock := &MockDriver{GenerateURLErr: driverErr} + auditor := &mockAuditor{} + service := NewService(mock) + service.WithAuditor(auditor) + + ctx := context.Background() + _, err := service.Upload(ctx, "test.jpg", 1024, "image/jpeg") + if err == nil { + t.Fatal("expected error, got nil") + } + + if len(auditor.events) != 1 { + t.Fatalf("expected 1 audit event, got %d", len(auditor.events)) + } + ev := auditor.events[0] + d := storageDetails(t, ev) + if ev.Action != audit.ActionCreate || ev.Status != audit.StatusFailure || d.Error != driverErr.Error() { + t.Errorf("unexpected audit event: %+v details=%+v", ev, d) + } +} + func TestUploadService_Download(t *testing.T) { mock := &MockDriver{ SavedBody: []byte("test content"), @@ -103,6 +179,54 @@ func TestUploadService_Download(t *testing.T) { } } +func TestDownload_Audit_Success(t *testing.T) { + mock := &MockDriver{ + SavedBody: []byte("test content"), + } + auditor := &mockAuditor{} + service := NewService(mock) + service.WithAuditor(auditor) + + ctx := context.Background() + reader, _, err := service.Download(ctx, "test-key") + if err != nil { + t.Fatalf("Download failed: %v", err) + } + defer reader.Close() + + if len(auditor.events) != 1 { + t.Fatalf("expected 1 audit event, got %d", len(auditor.events)) + } + ev := auditor.events[0] + d := storageDetails(t, ev) + if ev.Action != audit.ActionRead || d.Key != "test-key" || d.MimeType != "application/test" || ev.Status != audit.StatusSuccess { + t.Errorf("unexpected audit event: %+v details=%+v", ev, d) + } +} + +func TestDownload_Audit_DriverError(t *testing.T) { + driverErr := errors.New("driver read failed") + mock := &MockDriver{GetErr: driverErr} + auditor := &mockAuditor{} + service := NewService(mock) + service.WithAuditor(auditor) + + ctx := context.Background() + _, _, err := service.Download(ctx, "test-key") + if !errors.Is(err, driverErr) { + t.Fatalf("expected error %v, got %v", driverErr, err) + } + + if len(auditor.events) != 1 { + t.Fatalf("expected 1 audit event, got %d", len(auditor.events)) + } + ev := auditor.events[0] + d := storageDetails(t, ev) + if ev.Action != audit.ActionRead || d.Key != "test-key" || ev.Status != audit.StatusFailure || d.Error != driverErr.Error() { + t.Errorf("unexpected audit event: %+v details=%+v", ev, d) + } +} + func TestUploadService_GetDownloadURL_Success(t *testing.T) { mock := &MockDriver{} service := NewService(mock) @@ -120,6 +244,34 @@ func TestUploadService_GetDownloadURL_Success(t *testing.T) { } } +func TestGetDownloadURL_Audit_Success(t *testing.T) { + mock := &MockDriver{} + auditor := &mockAuditor{} + service := NewService(mock) + service.WithAuditor(auditor) + + ctx := context.Background() + const key = "test-key" + + url, err := service.GetDownloadURL(ctx, key) + if err != nil { + t.Fatalf("GetDownloadURL failed: %v", err) + } + + if url != "/test/download/"+key { + t.Errorf("unexpected URL: %s", url) + } + + if len(auditor.events) != 1 { + t.Fatalf("expected 1 audit event, got %d", len(auditor.events)) + } + ev := auditor.events[0] + d := storageDetails(t, ev) + if ev.Action != audit.ActionRead || d.Key != key || ev.Status != audit.StatusSuccess { + t.Errorf("unexpected audit event: %+v details=%+v", ev, d) + } +} + func TestUploadService_GetDownloadURL_Error(t *testing.T) { expectedErr := io.ErrUnexpectedEOF mock := &MockDriver{GenerateURLErr: expectedErr} @@ -133,3 +285,78 @@ func TestUploadService_GetDownloadURL_Error(t *testing.T) { t.Errorf("expected error %v, got %v", expectedErr, err) } } + +func TestGetDownloadURL_Audit_DriverError(t *testing.T) { + driverErr := errors.New("driver presign failed") + mock := &MockDriver{GenerateURLErr: driverErr} + auditor := &mockAuditor{} + service := NewService(mock) + service.WithAuditor(auditor) + + ctx := context.Background() + _, err := service.GetDownloadURL(ctx, "test-key") + if !errors.Is(err, driverErr) { + t.Fatalf("expected error %v, got %v", driverErr, err) + } + + if len(auditor.events) != 1 { + t.Fatalf("expected 1 audit event, got %d", len(auditor.events)) + } + ev := auditor.events[0] + d := storageDetails(t, ev) + if ev.Action != audit.ActionRead || d.Key != "test-key" || ev.Status != audit.StatusFailure || d.Error != driverErr.Error() { + t.Errorf("unexpected audit event: %+v details=%+v", ev, d) + } +} + +func TestDelete_Audit_Success(t *testing.T) { + mock := &MockDriver{} + auditor := &mockAuditor{} + service := NewService(mock) + service.WithAuditor(auditor) + + ctx := context.Background() + const key = "test-key" + + if err := service.Delete(ctx, key); err != nil { + t.Fatalf("Delete failed: %v", err) + } + + if !mock.DeleteCalled || mock.DeleteKey != key { + t.Errorf("expected Delete(%q), got called=%v key=%q", key, mock.DeleteCalled, mock.DeleteKey) + } + if len(auditor.events) != 1 { + t.Fatalf("expected 1 audit event, got %d", len(auditor.events)) + } + ev := auditor.events[0] + d := storageDetails(t, ev) + if ev.Action != audit.ActionDelete || d.Key != key || ev.Status != audit.StatusSuccess { + t.Errorf("unexpected audit event: %+v details=%+v", ev, d) + } +} + +func TestDelete_Audit_DriverError(t *testing.T) { + driverErr := errors.New("driver delete failed") + mock := &MockDriver{DeleteErr: driverErr} + auditor := &mockAuditor{} + service := NewService(mock) + service.WithAuditor(auditor) + + ctx := context.Background() + err := service.Delete(ctx, "test-key") + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, driverErr) { + t.Fatalf("expected error %v, got %v", driverErr, err) + } + + if len(auditor.events) != 1 { + t.Fatalf("expected 1 audit event, got %d", len(auditor.events)) + } + ev := auditor.events[0] + d := storageDetails(t, ev) + if ev.Action != audit.ActionDelete || d.Key != "test-key" || ev.Status != audit.StatusFailure || d.Error != driverErr.Error() { + t.Errorf("unexpected audit event: %+v details=%+v", ev, d) + } +}