feat(storage): add audit persistence
This commit is contained in:
26
backend/internal/audit/record.go
Normal file
26
backend/internal/audit/record.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package audit
|
||||
|
||||
import "time"
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusSuccess Status = "success"
|
||||
StatusValidationFailed Status = "validation_failed"
|
||||
StatusAuthenticationError Status = "authentication_error"
|
||||
StatusUpstreamRejected Status = "upstream_rejected"
|
||||
StatusUpstreamTimeout Status = "upstream_timeout"
|
||||
StatusUpstreamUnavailable Status = "upstream_unavailable"
|
||||
StatusInternalError Status = "internal_error"
|
||||
)
|
||||
|
||||
type Record struct {
|
||||
RequestID string
|
||||
CreatedAt time.Time
|
||||
Duration time.Duration
|
||||
ClientIP *string
|
||||
Status Status
|
||||
UpstreamHTTPStatus *int
|
||||
MaskedCard string
|
||||
ResponseJSON []byte
|
||||
}
|
||||
80
backend/internal/repository/audit_repository.go
Normal file
80
backend/internal/repository/audit_repository.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"card-to-iban/internal/audit"
|
||||
)
|
||||
|
||||
type AuditRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewAuditRepository(
|
||||
db *sql.DB,
|
||||
) *AuditRepository {
|
||||
return &AuditRepository{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *AuditRepository) Save(
|
||||
ctx context.Context,
|
||||
record audit.Record,
|
||||
) error {
|
||||
var clientIP any
|
||||
|
||||
if record.ClientIP != nil {
|
||||
clientIP = *record.ClientIP
|
||||
}
|
||||
|
||||
var upstreamHTTPStatus any
|
||||
|
||||
if record.UpstreamHTTPStatus != nil {
|
||||
upstreamHTTPStatus = *record.UpstreamHTTPStatus
|
||||
}
|
||||
|
||||
var responseJSON any
|
||||
|
||||
if len(record.ResponseJSON) > 0 {
|
||||
responseJSON = string(record.ResponseJSON)
|
||||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO audit_records (
|
||||
request_id,
|
||||
created_at,
|
||||
duration_ms,
|
||||
client_ip,
|
||||
final_status,
|
||||
upstream_http_status,
|
||||
masked_card,
|
||||
response_json
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(
|
||||
ctx,
|
||||
query,
|
||||
record.RequestID,
|
||||
record.CreatedAt.UTC().Format("2006-01-02T15:04:05.000000000Z07:00"),
|
||||
record.Duration.Milliseconds(),
|
||||
clientIP,
|
||||
string(record.Status),
|
||||
upstreamHTTPStatus,
|
||||
record.MaskedCard,
|
||||
responseJSON,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"insert audit record: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
183
backend/internal/repository/audit_repository_test.go
Normal file
183
backend/internal/repository/audit_repository_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"card-to-iban/internal/audit"
|
||||
"card-to-iban/internal/storage"
|
||||
)
|
||||
|
||||
func TestAuditRepositorySave(t *testing.T) {
|
||||
databasePath := filepath.Join(
|
||||
t.TempDir(),
|
||||
"test.db",
|
||||
)
|
||||
|
||||
db, err := storage.OpenSQLite(databasePath)
|
||||
if err != nil {
|
||||
t.Fatalf(
|
||||
"OpenSQLite() error = %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
defer db.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
if err := storage.Migrate(ctx, db); err != nil {
|
||||
t.Fatalf(
|
||||
"Migrate() error = %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
if err := storage.Migrate(ctx, db); err != nil {
|
||||
t.Fatalf(
|
||||
"second Migrate() error = %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
repo := NewAuditRepository(db)
|
||||
|
||||
clientIP := "127.0.0.1"
|
||||
upstreamStatus := 200
|
||||
|
||||
record := audit.Record{
|
||||
RequestID: "request-test-123",
|
||||
|
||||
CreatedAt: time.Date(
|
||||
2026,
|
||||
time.September,
|
||||
21,
|
||||
8,
|
||||
30,
|
||||
0,
|
||||
0,
|
||||
time.UTC,
|
||||
),
|
||||
|
||||
Duration: 150 * time.Millisecond,
|
||||
|
||||
ClientIP: &clientIP,
|
||||
|
||||
Status: audit.StatusSuccess,
|
||||
|
||||
UpstreamHTTPStatus: &upstreamStatus,
|
||||
|
||||
MaskedCard: "603799******5678",
|
||||
|
||||
ResponseJSON: []byte(
|
||||
`{"data":{"iban":"IR820540102680020817909002"}}`,
|
||||
),
|
||||
}
|
||||
|
||||
if err := repo.Save(ctx, record); err != nil {
|
||||
t.Fatalf(
|
||||
"Save() error = %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
var (
|
||||
requestID string
|
||||
durationMS int64
|
||||
storedClientIP string
|
||||
finalStatus string
|
||||
storedHTTPStatus int
|
||||
maskedCard string
|
||||
storedResponseJSON string
|
||||
)
|
||||
|
||||
err = db.QueryRowContext(
|
||||
ctx,
|
||||
`
|
||||
SELECT
|
||||
request_id,
|
||||
duration_ms,
|
||||
client_ip,
|
||||
final_status,
|
||||
upstream_http_status,
|
||||
masked_card,
|
||||
response_json
|
||||
FROM audit_records
|
||||
WHERE request_id = ?
|
||||
`,
|
||||
record.RequestID,
|
||||
).Scan(
|
||||
&requestID,
|
||||
&durationMS,
|
||||
&storedClientIP,
|
||||
&finalStatus,
|
||||
&storedHTTPStatus,
|
||||
&maskedCard,
|
||||
&storedResponseJSON,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf(
|
||||
"query stored audit record: %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
if requestID != record.RequestID {
|
||||
t.Errorf(
|
||||
"request ID = %q, want %q",
|
||||
requestID,
|
||||
record.RequestID,
|
||||
)
|
||||
}
|
||||
|
||||
if durationMS != record.Duration.Milliseconds() {
|
||||
t.Errorf(
|
||||
"duration = %d, want %d",
|
||||
durationMS,
|
||||
record.Duration.Milliseconds(),
|
||||
)
|
||||
}
|
||||
|
||||
if storedClientIP != clientIP {
|
||||
t.Errorf(
|
||||
"client IP = %q, want %q",
|
||||
storedClientIP,
|
||||
clientIP,
|
||||
)
|
||||
}
|
||||
|
||||
if finalStatus != string(record.Status) {
|
||||
t.Errorf(
|
||||
"status = %q, want %q",
|
||||
finalStatus,
|
||||
record.Status,
|
||||
)
|
||||
}
|
||||
|
||||
if storedHTTPStatus != upstreamStatus {
|
||||
t.Errorf(
|
||||
"HTTP status = %d, want %d",
|
||||
storedHTTPStatus,
|
||||
upstreamStatus,
|
||||
)
|
||||
}
|
||||
|
||||
if maskedCard != record.MaskedCard {
|
||||
t.Errorf(
|
||||
"masked card = %q, want %q",
|
||||
maskedCard,
|
||||
record.MaskedCard,
|
||||
)
|
||||
}
|
||||
|
||||
if storedResponseJSON != string(record.ResponseJSON) {
|
||||
t.Errorf(
|
||||
"response JSON = %q, want %q",
|
||||
storedResponseJSON,
|
||||
record.ResponseJSON,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE IF NOT EXISTS audit_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
request_id TEXT NOT NULL UNIQUE,
|
||||
|
||||
created_at TEXT NOT NULL,
|
||||
|
||||
duration_ms INTEGER NOT NULL,
|
||||
|
||||
client_ip TEXT NULL,
|
||||
|
||||
final_status TEXT NOT NULL,
|
||||
|
||||
upstream_http_status INTEGER NULL,
|
||||
|
||||
masked_card TEXT NOT NULL,
|
||||
|
||||
response_json TEXT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_records_created_at
|
||||
ON audit_records(created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_records_final_status
|
||||
ON audit_records(final_status);
|
||||
47
backend/internal/storage/sqlite.go
Normal file
47
backend/internal/storage/sqlite.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed migrations/001_create_audit_records.sql
|
||||
var auditMigration string
|
||||
|
||||
func OpenSQLite(path string) (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite database: %w", err)
|
||||
}
|
||||
|
||||
// SQLite permits only one writer at a time.
|
||||
// Keeping a small connection pool makes behavior predictable
|
||||
// for this assessment-sized service.
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
|
||||
return nil, fmt.Errorf("ping sqlite database: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func Migrate(
|
||||
ctx context.Context,
|
||||
db *sql.DB,
|
||||
) error {
|
||||
if _, err := db.ExecContext(
|
||||
ctx,
|
||||
auditMigration,
|
||||
); err != nil {
|
||||
return fmt.Errorf("apply audit migration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user