feat(storage): add audit persistence

This commit is contained in:
2026-09-21 09:16:36 +03:30
parent a8c351f3c0
commit 22d67c7c36
8 changed files with 428 additions and 1 deletions

View 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
}

View 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
}

View 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,
)
}
}

View File

@@ -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);

View 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
}