From 22d67c7c368d5048bf4bea3d0feeaf1a2131434d Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Mon, 21 Sep 2026 09:16:36 +0330 Subject: [PATCH] feat(storage): add audit persistence --- .env.sample | 4 +- backend/go.mod | 14 ++ backend/go.sum | 50 +++++ backend/internal/audit/record.go | 26 +++ .../internal/repository/audit_repository.go | 80 ++++++++ .../repository/audit_repository_test.go | 183 ++++++++++++++++++ .../migrations/001_create_audit_records.sql | 25 +++ backend/internal/storage/sqlite.go | 47 +++++ 8 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 backend/go.sum create mode 100644 backend/internal/audit/record.go create mode 100644 backend/internal/repository/audit_repository.go create mode 100644 backend/internal/repository/audit_repository_test.go create mode 100644 backend/internal/storage/migrations/001_create_audit_records.sql create mode 100644 backend/internal/storage/sqlite.go diff --git a/.env.sample b/.env.sample index 707b603..2d92b41 100644 --- a/.env.sample +++ b/.env.sample @@ -1,3 +1,5 @@ ZARINHUB_ACCESS_TOKEN= ZARINHUB_ENDPOINT=https://zarin-hub.com/api/v5/KYC/CardToIban -ZARINHUB_TIMEOUT=5s \ No newline at end of file +ZARINHUB_TIMEOUT=5s + +DATABASE_PATH=./card-to-iban.db diff --git a/backend/go.mod b/backend/go.mod index 02f6f5d..6a67be3 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,3 +1,17 @@ module card-to-iban go 1.27.1 + +require modernc.org/sqlite v1.59.0 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.75.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..83ad0ca --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,50 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew= +modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A= +modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/internal/audit/record.go b/backend/internal/audit/record.go new file mode 100644 index 0000000..0f353fe --- /dev/null +++ b/backend/internal/audit/record.go @@ -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 +} diff --git a/backend/internal/repository/audit_repository.go b/backend/internal/repository/audit_repository.go new file mode 100644 index 0000000..591b307 --- /dev/null +++ b/backend/internal/repository/audit_repository.go @@ -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 +} diff --git a/backend/internal/repository/audit_repository_test.go b/backend/internal/repository/audit_repository_test.go new file mode 100644 index 0000000..3e41aa3 --- /dev/null +++ b/backend/internal/repository/audit_repository_test.go @@ -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, + ) + } +} diff --git a/backend/internal/storage/migrations/001_create_audit_records.sql b/backend/internal/storage/migrations/001_create_audit_records.sql new file mode 100644 index 0000000..f8a3c92 --- /dev/null +++ b/backend/internal/storage/migrations/001_create_audit_records.sql @@ -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); diff --git a/backend/internal/storage/sqlite.go b/backend/internal/storage/sqlite.go new file mode 100644 index 0000000..d48159a --- /dev/null +++ b/backend/internal/storage/sqlite.go @@ -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 +}