From 3defec35ab824bfa56c7ed510141234d90b0095c Mon Sep 17 00:00:00 2001 From: "S. A. N. K. Bandara" Date: Tue, 11 Aug 2026 23:16:08 +0530 Subject: [PATCH 1/3] chore(scan): scaffold scan service module --- services/scan/.env.example | 2 ++ services/scan/go.mod | 20 +++++++++++++++ services/scan/go.sum | 52 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 services/scan/.env.example create mode 100644 services/scan/go.mod create mode 100644 services/scan/go.sum diff --git a/services/scan/.env.example b/services/scan/.env.example new file mode 100644 index 0000000..30ccb98 --- /dev/null +++ b/services/scan/.env.example @@ -0,0 +1,2 @@ +PORT=8080 +SCAN_DB_PATH=./data/scan.db diff --git a/services/scan/go.mod b/services/scan/go.mod new file mode 100644 index 0000000..a619ef0 --- /dev/null +++ b/services/scan/go.mod @@ -0,0 +1,20 @@ +module securecloud/scan + +go 1.26.4 + +require ( + github.com/go-chi/chi/v5 v5.3.1 + modernc.org/sqlite v1.56.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.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/services/scan/go.sum b/services/scan/go.sum new file mode 100644 index 0000000..406575e --- /dev/null +++ b/services/scan/go.sum @@ -0,0 +1,52 @@ +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/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= +github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +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.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.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.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +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.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/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.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +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.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/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.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +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= From c75ea3aa9788888bb99a0070abf0fe416e03028c Mon Sep 17 00:00:00 2001 From: "S. A. N. K. Bandara" Date: Tue, 11 Aug 2026 23:16:15 +0530 Subject: [PATCH 2/3] feat(scan): add SQLite scan store --- services/scan/internal/store/model.go | 29 +++ services/scan/internal/store/store.go | 236 +++++++++++++++++++++ services/scan/internal/store/store_test.go | 63 ++++++ 3 files changed, 328 insertions(+) create mode 100644 services/scan/internal/store/model.go create mode 100644 services/scan/internal/store/store.go create mode 100644 services/scan/internal/store/store_test.go diff --git a/services/scan/internal/store/model.go b/services/scan/internal/store/model.go new file mode 100644 index 0000000..ecef1ac --- /dev/null +++ b/services/scan/internal/store/model.go @@ -0,0 +1,29 @@ +package store + +import "time" + +type ScanStatus string + +const ( + StatusQueued ScanStatus = "queued" + StatusRunning ScanStatus = "running" + StatusComplete ScanStatus = "complete" + StatusFailed ScanStatus = "failed" +) + +type Finding struct { + Check string `json:"check"` + Severity string `json:"severity"` + Message string `json:"message"` +} + +type Scan struct { + ID string `json:"id"` + Target string `json:"target"` + Status ScanStatus `json:"status"` + Score int `json:"score"` + Findings []Finding `json:"findings"` + Error string `json:"error,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/services/scan/internal/store/store.go b/services/scan/internal/store/store.go new file mode 100644 index 0000000..19435ce --- /dev/null +++ b/services/scan/internal/store/store.go @@ -0,0 +1,236 @@ +package store + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "time" + + _ "modernc.org/sqlite" +) + +type Store struct { + db *sql.DB +} + +func New(dbPath string) (*Store, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + db.SetMaxOpenConns(1) + + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("ping database: %w", err) + } + + const schema = ` + CREATE TABLE IF NOT EXISTS scans ( + id TEXT PRIMARY KEY, + target TEXT NOT NULL, + status TEXT NOT NULL, + score INTEGER NOT NULL DEFAULT 0, + findings TEXT NOT NULL DEFAULT '[]', + error TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + ` + + if _, err := db.Exec(schema); err != nil { + db.Close() + return nil, fmt.Errorf("create scans table: %w", err) + } + + return &Store{db: db}, nil +} + +func (s *Store) Close() error { + return s.db.Close() +} + +func (s *Store) Ping(ctx context.Context) error { + return s.db.PingContext(ctx) +} + +func (s *Store) CreateScan(ctx context.Context, target string) (*Scan, error) { + id, err := generateID() + if err != nil { + return nil, fmt.Errorf("generate scan ID: %w", err) + } + + now := time.Now().UTC() + + scan := &Scan{ + ID: id, + Target: target, + Status: StatusQueued, + Score: 0, + Findings: []Finding{}, + Error: "", + CreatedAt: now, + UpdatedAt: now, + } + + findingsJSON, err := json.Marshal(scan.Findings) + if err != nil { + return nil, fmt.Errorf("encode findings: %w", err) + } + + _, err = s.db.ExecContext( + ctx, + ` + INSERT INTO scans ( + id, + target, + status, + score, + findings, + error, + created_at, + updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, + scan.ID, + scan.Target, + scan.Status, + scan.Score, + string(findingsJSON), + scan.Error, + scan.CreatedAt.Format(time.RFC3339Nano), + scan.UpdatedAt.Format(time.RFC3339Nano), + ) + + if err != nil { + return nil, fmt.Errorf("insert scan: %w", err) + } + + return scan, nil +} + +func (s *Store) GetScan(ctx context.Context, id string) (*Scan, error) { + row := s.db.QueryRowContext( + ctx, + ` + SELECT + id, + target, + status, + score, + findings, + error, + created_at, + updated_at + FROM scans + WHERE id = ? + `, + id, + ) + + scan, err := decodeScan(row) + if err != nil { + return nil, err + } + + return scan, nil +} + +func (s *Store) ListScans(ctx context.Context) ([]Scan, error) { + rows, err := s.db.QueryContext( + ctx, + ` + SELECT + id, + target, + status, + score, + findings, + error, + created_at, + updated_at + FROM scans + ORDER BY created_at DESC + `, + ) + if err != nil { + return nil, fmt.Errorf("query scans: %w", err) + } + + defer rows.Close() + + scans := make([]Scan, 0) + + for rows.Next() { + scan, err := decodeScan(rows) + if err != nil { + return nil, err + } + + scans = append(scans, *scan) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate scans: %w", err) + } + + return scans, nil +} + +type scanner interface { + Scan(dest ...any) error +} + +func decodeScan(row scanner) (*Scan, error) { + var scan Scan + + var findingsJSON string + var createdAt string + var updatedAt string + + err := row.Scan( + &scan.ID, + &scan.Target, + &scan.Status, + &scan.Score, + &findingsJSON, + &scan.Error, + &createdAt, + &updatedAt, + ) + + if err != nil { + return nil, err + } + + if err := json.Unmarshal([]byte(findingsJSON), &scan.Findings); err != nil { + return nil, fmt.Errorf("decode findings: %w", err) + } + + scan.CreatedAt, err = time.Parse(time.RFC3339Nano, createdAt) + if err != nil { + return nil, fmt.Errorf("parse created_at: %w", err) + } + + scan.UpdatedAt, err = time.Parse(time.RFC3339Nano, updatedAt) + if err != nil { + return nil, fmt.Errorf("parse updated_at: %w", err) + } + + return &scan, nil +} + +func generateID() (string, error) { + bytes := make([]byte, 16) + + if _, err := rand.Read(bytes); err != nil { + return "", err + } + + return hex.EncodeToString(bytes), nil +} diff --git a/services/scan/internal/store/store_test.go b/services/scan/internal/store/store_test.go new file mode 100644 index 0000000..09754f2 --- /dev/null +++ b/services/scan/internal/store/store_test.go @@ -0,0 +1,63 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "path/filepath" + "testing" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + testStore, err := New(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + t.Cleanup(func() { _ = testStore.Close() }) + return testStore +} + +func TestCreateAndGetScan(t *testing.T) { + testStore := newTestStore(t) + created, err := testStore.CreateScan(context.Background(), "example.com") + if err != nil { + t.Fatalf("CreateScan() error = %v", err) + } + if created.ID == "" || created.Target != "example.com" || created.Status != StatusQueued { + t.Fatalf("CreateScan() = %+v", created) + } + if created.Score != 0 || len(created.Findings) != 0 { + t.Fatalf("new scan has score %d and %d findings", created.Score, len(created.Findings)) + } + found, err := testStore.GetScan(context.Background(), created.ID) + if err != nil { + t.Fatalf("GetScan() error = %v", err) + } + if found.ID != created.ID || found.Target != created.Target { + t.Fatalf("GetScan() = %+v, want %+v", found, created) + } +} + +func TestGetScanNotFound(t *testing.T) { + _, err := newTestStore(t).GetScan(context.Background(), "missing") + if !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("GetScan() error = %v, want sql.ErrNoRows", err) + } +} + +func TestListScans(t *testing.T) { + testStore := newTestStore(t) + for _, target := range []string{"example.com", "google.com"} { + if _, err := testStore.CreateScan(context.Background(), target); err != nil { + t.Fatalf("CreateScan(%q) error = %v", target, err) + } + } + scans, err := testStore.ListScans(context.Background()) + if err != nil { + t.Fatalf("ListScans() error = %v", err) + } + if len(scans) != 2 { + t.Fatalf("ListScans() returned %d scans, want 2", len(scans)) + } +} From 48e802508faeb58c0cd65d6e3db0122c34107aeb Mon Sep 17 00:00:00 2001 From: "S. A. N. K. Bandara" Date: Tue, 11 Aug 2026 23:16:23 +0530 Subject: [PATCH 3/3] feat(scan): add scan API and health endpoints --- services/scan/cmd/server/main.go | 70 ++++++++++ services/scan/internal/handler/handler.go | 132 ++++++++++++++++++ .../scan/internal/handler/handler_test.go | 101 ++++++++++++++ 3 files changed, 303 insertions(+) create mode 100644 services/scan/cmd/server/main.go create mode 100644 services/scan/internal/handler/handler.go create mode 100644 services/scan/internal/handler/handler_test.go diff --git a/services/scan/cmd/server/main.go b/services/scan/cmd/server/main.go new file mode 100644 index 0000000..7499c5b --- /dev/null +++ b/services/scan/cmd/server/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "securecloud/scan/internal/handler" + "securecloud/scan/internal/store" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + port := getEnv("PORT", "8080") + dbPath := getEnv("SCAN_DB_PATH", "./data/scan.db") + if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { + logger.Error("failed to create database directory", "error", err) + os.Exit(1) + } + scanStore, err := store.New(dbPath) + if err != nil { + logger.Error("failed to initialise database", "error", err) + os.Exit(1) + } + defer scanStore.Close() + server := &http.Server{ + Addr: ":" + port, + Handler: handler.New(scanStore, logger).Router(), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + serverErrors := make(chan error, 1) + go func() { + logger.Info("scan service starting", "port", port, "database", dbPath) + serverErrors <- server.ListenAndServe() + }() + signalContext, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + select { + case <-signalContext.Done(): + logger.Info("shutdown signal received") + case err := <-serverErrors: + if !errors.Is(err, http.ErrServerClosed) { + logger.Error("HTTP server failed", "error", err) + return + } + } + shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := server.Shutdown(shutdownContext); err != nil { + logger.Error("graceful shutdown failed", "error", err) + return + } + logger.Info("scan service stopped") +} + +func getEnv(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} diff --git a/services/scan/internal/handler/handler.go b/services/scan/internal/handler/handler.go new file mode 100644 index 0000000..c14387e --- /dev/null +++ b/services/scan/internal/handler/handler.go @@ -0,0 +1,132 @@ +package handler + +import ( + "database/sql" + "encoding/json" + "errors" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "securecloud/scan/internal/store" +) + +type Handler struct { + store *store.Store + logger *slog.Logger +} + +func New(scanStore *store.Store, logger *slog.Logger) *Handler { + return &Handler{store: scanStore, logger: logger} +} + +func (h *Handler) Router() http.Handler { + router := chi.NewRouter() + router.Use(middleware.RequestID) + router.Use(middleware.Recoverer) + router.Use(h.requestLogger) + router.Get("/healthz", h.healthz) + router.Get("/readyz", h.readyz) + router.Post("/scan", h.createScan) + router.Get("/scan/{id}", h.getScan) + router.Get("/scans", h.listScans) + return router +} + +func (h *Handler) healthz(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (h *Handler) readyz(w http.ResponseWriter, r *http.Request) { + if err := h.store.Ping(r.Context()); err != nil { + h.logger.Error("database readiness check failed", "error", err) + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "not_ready"}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) +} + +type createScanRequest struct { + Target string `json:"target"` +} + +func (h *Handler) createScan(w http.ResponseWriter, r *http.Request) { + var request createScanRequest + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON request") + return + } + request.Target = strings.TrimSpace(request.Target) + if request.Target == "" { + writeError(w, http.StatusBadRequest, "target is required") + return + } + scan, err := h.store.CreateScan(r.Context(), request.Target) + if err != nil { + h.logger.Error("failed to create scan", "error", err) + writeError(w, http.StatusInternalServerError, "failed to create scan") + return + } + h.logger.Info("scan created", "scan_id", scan.ID, "target", scan.Target) + writeJSON(w, http.StatusAccepted, scan) +} + +func (h *Handler) getScan(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + scan, err := h.store.GetScan(r.Context(), id) + if errors.Is(err, sql.ErrNoRows) { + writeError(w, http.StatusNotFound, "scan not found") + return + } + if err != nil { + h.logger.Error("failed to retrieve scan", "scan_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "failed to retrieve scan") + return + } + writeJSON(w, http.StatusOK, scan) +} + +func (h *Handler) listScans(w http.ResponseWriter, r *http.Request) { + scans, err := h.store.ListScans(r.Context()) + if err != nil { + h.logger.Error("failed to list scans", "error", err) + writeError(w, http.StatusInternalServerError, "failed to list scans") + return + } + writeJSON(w, http.StatusOK, scans) +} + +func writeJSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(data) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (r *statusRecorder) WriteHeader(status int) { + r.status = status + r.ResponseWriter.WriteHeader(status) +} + +func (h *Handler) requestLogger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(recorder, r) + h.logger.Info("http request", "method", r.Method, "path", r.URL.Path, "status", recorder.status, "duration_ms", time.Since(start).Milliseconds()) + }) +} diff --git a/services/scan/internal/handler/handler_test.go b/services/scan/internal/handler/handler_test.go new file mode 100644 index 0000000..6873faa --- /dev/null +++ b/services/scan/internal/handler/handler_test.go @@ -0,0 +1,101 @@ +package handler + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "securecloud/scan/internal/store" +) + +func newTestRouter(t *testing.T) http.Handler { + t.Helper() + scanStore, err := store.New(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("store.New() error = %v", err) + } + t.Cleanup(func() { _ = scanStore.Close() }) + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) + return New(scanStore, logger).Router() +} + +func performRequest(router http.Handler, method, path, body string) *httptest.ResponseRecorder { + request := httptest.NewRequest(method, path, strings.NewReader(body)) + if body != "" { + request.Header.Set("Content-Type", "application/json") + } + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + return response +} + +func TestHealthEndpoints(t *testing.T) { + router := newTestRouter(t) + for _, test := range []struct { + path string + body string + }{ + {path: "/healthz", body: `{"status":"ok"}`}, + {path: "/readyz", body: `{"status":"ready"}`}, + } { + response := performRequest(router, http.MethodGet, test.path, "") + if response.Code != http.StatusOK { + t.Errorf("GET %s status = %d, want 200", test.path, response.Code) + } + if strings.TrimSpace(response.Body.String()) != test.body { + t.Errorf("GET %s body = %q, want %q", test.path, response.Body.String(), test.body) + } + } +} + +func TestScanLifecycle(t *testing.T) { + router := newTestRouter(t) + createdResponse := performRequest(router, http.MethodPost, "/scan", `{"target":" example.com "}`) + if createdResponse.Code != http.StatusAccepted { + t.Fatalf("POST /scan status = %d, want 202", createdResponse.Code) + } + var created store.Scan + if err := json.NewDecoder(createdResponse.Body).Decode(&created); err != nil { + t.Fatalf("decode POST response: %v", err) + } + if created.Target != "example.com" || created.Status != store.StatusQueued { + t.Fatalf("POST /scan = %+v", created) + } + getResponse := performRequest(router, http.MethodGet, "/scan/"+created.ID, "") + if getResponse.Code != http.StatusOK { + t.Fatalf("GET /scan/{id} status = %d, want 200", getResponse.Code) + } + listResponse := performRequest(router, http.MethodGet, "/scans", "") + if listResponse.Code != http.StatusOK { + t.Fatalf("GET /scans status = %d, want 200", listResponse.Code) + } + var scans []store.Scan + if err := json.NewDecoder(listResponse.Body).Decode(&scans); err != nil { + t.Fatalf("decode list response: %v", err) + } + if len(scans) != 1 || scans[0].ID != created.ID { + t.Fatalf("GET /scans = %+v", scans) + } +} + +func TestCreateScanRejectsInvalidRequests(t *testing.T) { + router := newTestRouter(t) + for _, body := range []string{`{"target":""}`, `{"target":" "}`, `hello`, `{"target":"example.com","extra":true}`} { + response := performRequest(router, http.MethodPost, "/scan", body) + if response.Code != http.StatusBadRequest { + t.Errorf("POST /scan body %q status = %d, want 400", body, response.Code) + } + } +} + +func TestGetScanNotFound(t *testing.T) { + response := performRequest(newTestRouter(t), http.MethodGet, "/scan/does-not-exist", "") + if response.Code != http.StatusNotFound { + t.Fatalf("GET missing scan status = %d, want 404", response.Code) + } +}