feat(canary): Phase 1 — Postgres schema + token & event repositories

Schema (3 goose migrations under internal/core/migrations/):
- 0001_create_tokens.sql: tokens table per spec §7.1
  Columns: id (varchar 12, PK), manage_id (UUID unique), type, memo,
           filename, alert_channel, telegram_bot/chat, webhook_url,
           created_at/ip/fp, enabled, trigger_count, last_triggered, metadata (jsonb)
  CHECK constraints: chk_token_type (7-token enum), chk_alert_channel (telegram|webhook),
                     chk_telegram_complete (when channel=telegram, bot+chat required),
                     chk_webhook_complete (when channel=webhook, url required)

- 0002_create_events.sql: events table
  Columns: id (bigserial PK), token_id (FK with ON DELETE CASCADE),
           triggered_at, source_ip (inet), user_agent, referer,
           geo_country/region/city/asn/asn_org, extra (jsonb),
           notify_status, notified_at
  CHECK chk_notify_status (pending|sent|failed|deduped)

- 0003_indexes.sql: full index set per spec §7.1
  tokens(created_ip), tokens(created_fp), tokens(created_at DESC),
  tokens(type), partial idx_tokens_trigger_count WHERE trigger_count > 0,
  events(token_id, triggered_at DESC), events(source_ip),
  partial idx_events_notify_pending WHERE notify_status = 'pending'

Migration runner (internal/core/migrations.go):
- go:embed migrations/*.sql baked into binary
- core.RunMigrations(*sql.DB) called from main.go after DB connect
- pressly/goose v3.27.1 (research-recommended; no dirty-state bug)

Token domain (internal/token/):
- entity.go: Token struct + typed Type/AlertChannel enums with Valid() guards
- dto.go: CreateRequest with validator/v10 tags (oneof, required_if, url, max),
          Response shape with ToResponse(triggerURL, manageURL) helper
- repository.go: Insert (RETURNING created_at + counters), GetByID,
                 GetByManageID, DeleteByManageID (FK cascade), IncrementTriggerCount,
                 SetEnabled, ListAll + CountAll. ErrNotFound on sql.ErrNoRows.
                 Named-parameter binding via sqlx.NamedExecContext.
- repository_test.go (//go:build integration): 11 tests covering insert,
  get-by-id, get-by-manage-id, not-found paths, delete with cascade,
  trigger count increment, enable toggle, list pagination, type CHECK

Event domain (internal/event/):
- entity.go: Event struct + NotifyStatus enum
- dto.go: GeoView + Response with ToResponse() flattens geo into nested object
- repository.go: Insert, GetByID, ListByToken (cursor pagination via
                 LIMIT N+1, returns NextCursor + HasMore), CountByToken,
                 AttachFingerprint (UPDATE most-recent within window using
                 jsonb || merge), UpdateNotifyStatus, PruneToLimit
                 (window function row_number() PARTITION BY token_id)
- repository_test.go (//go:build integration): 9 tests covering insert,
  cursor-paginated listing, FK cascade from token deletion, fingerprint
  merge into existing extra jsonb, notify status update, prune-to-N
  preserves newest-first, prune rejects zero limit

Test infrastructure:
- internal/testutil/postgres.go: testcontainers-go helper that spins up
  postgres:18-alpine, applies migrations via core.RunMigrations, returns
  ready *sql.DB with t.Cleanup-registered teardown. ~5-7s per test;
  20 integration tests run in ~13s end-to-end.

Wired into main.go:
- core.RunMigrations(db.DB.DB) called after NewDatabase
- token + event repos instantiated; blank-assigned for now (services
  consume them in Phase 9/10)

Verification:
- go build ./...                                      clean
- go vet ./...                                        clean
- go test -tags=integration ./internal/token/...      11 of 11 PASS
- go test -tags=integration ./internal/event/...      9 of 9 PASS
This commit is contained in:
CarterPerez-dev 2026-05-10 05:47:24 -04:00
parent a86939f412
commit b2558e2e41
16 changed files with 1464 additions and 60 deletions

View File

@ -6,6 +6,7 @@ package main
import (
"context"
"flag"
"fmt"
"log/slog"
"os"
"os/signal"
@ -16,9 +17,11 @@ import (
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/config"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/core"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/health"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/server"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
)
const drainDelay = 5 * time.Second
@ -64,6 +67,16 @@ func run(configPath string) error {
}
logger.Info("database connected")
if err := core.RunMigrations(db.DB.DB); err != nil {
return fmt.Errorf("run migrations: %w", err)
}
logger.Info("migrations applied")
tokenRepo := token.NewRepository(db.DB)
eventRepo := event.NewRepository(db.DB)
_ = tokenRepo
_ = eventRepo
rdb, err := core.NewRedis(ctx, cfg.Redis)
if err != nil {
return err

View File

@ -1,36 +1,55 @@
module github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend
go 1.25.0
go 1.25.7
require (
github.com/go-chi/chi/v5 v5.2.3
github.com/go-playground/validator/v10 v10.23.0
github.com/go-redis/redis_rate/v10 v10.0.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.2
github.com/jackc/pgx/v5 v5.9.2
github.com/jmoiron/sqlx v1.4.0
github.com/knadh/koanf/parsers/yaml v1.1.0
github.com/knadh/koanf/providers/env v1.1.0
github.com/knadh/koanf/providers/file v1.2.1
github.com/knadh/koanf/v2 v2.1.2
github.com/pressly/goose/v3 v3.27.1
github.com/redis/go-redis/v9 v9.7.0
go.opentelemetry.io/otel v1.33.0
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.42.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0
go.opentelemetry.io/otel/sdk v1.33.0
go.opentelemetry.io/otel/trace v1.33.0
golang.org/x/crypto v0.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
golang.org/x/crypto v0.50.0
golang.org/x/time v0.14.0
google.golang.org/grpc v1.68.1
google.golang.org/grpc v1.80.0
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
@ -38,20 +57,46 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.54.2 // indirect
github.com/moby/moby/client v0.4.1 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect
go.opentelemetry.io/otel/metric v1.33.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.4.0 // indirect
go.yaml.in/yaml/v3 v3.0.3 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect
google.golang.org/protobuf v1.35.2 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@ -1,5 +1,14 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@ -8,11 +17,35 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
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/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
@ -20,10 +53,12 @@ github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcP
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@ -34,14 +69,16 @@ github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-redis/redis_rate/v10 v10.0.1 h1:calPxi7tVlxojKunJwQ72kwfozdy25RjA0bCj1h0MUo=
github.com/go-redis/redis_rate/v10 v10.0.1/go.mod h1:EMiuO9+cjRkR7UvdvwMO7vbgqJkltQHtwbdIQvaBKIU=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE=
@ -50,12 +87,14 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4=
@ -74,66 +113,150 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
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/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw=
go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo=
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA=
go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ=
go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M=
go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM=
go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM=
go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s=
go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg=
go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=
google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0=
google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw=
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM=
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
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/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=

View File

@ -0,0 +1,27 @@
// ©AngelaMos | 2026
// migrations.go
package core
import (
"database/sql"
"embed"
"fmt"
"github.com/pressly/goose/v3"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
func RunMigrations(db *sql.DB) error {
goose.SetBaseFS(migrationsFS)
if err := goose.SetDialect("postgres"); err != nil {
return fmt.Errorf("goose set dialect: %w", err)
}
if err := goose.Up(db, "migrations"); err != nil {
return fmt.Errorf("goose up: %w", err)
}
return nil
}

View File

@ -0,0 +1,42 @@
-- ©AngelaMos | 2026
-- 0001_create_tokens.sql
-- +goose Up
-- +goose StatementBegin
CREATE TABLE tokens (
id VARCHAR(12) PRIMARY KEY,
manage_id UUID UNIQUE NOT NULL,
type VARCHAR(32) NOT NULL,
memo TEXT NOT NULL DEFAULT '',
filename TEXT,
alert_channel VARCHAR(16) NOT NULL,
telegram_bot TEXT,
telegram_chat TEXT,
webhook_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_ip INET NOT NULL,
created_fp CHAR(16) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
trigger_count BIGINT NOT NULL DEFAULT 0,
last_triggered TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
CONSTRAINT chk_token_type CHECK (type IN
('webbug', 'slowredirect', 'docx', 'pdf', 'kubeconfig', 'envfile', 'mysql')),
CONSTRAINT chk_alert_channel CHECK (alert_channel IN ('telegram', 'webhook')),
CONSTRAINT chk_telegram_complete CHECK (
alert_channel <> 'telegram' OR
(telegram_bot IS NOT NULL AND telegram_chat IS NOT NULL)
),
CONSTRAINT chk_webhook_complete CHECK (
alert_channel <> 'webhook' OR webhook_url IS NOT NULL
)
);
-- +goose StatementEnd
-- +goose Down
DROP TABLE IF EXISTS tokens;

View File

@ -0,0 +1,33 @@
-- ©AngelaMos | 2026
-- 0002_create_events.sql
-- +goose Up
-- +goose StatementBegin
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
token_id VARCHAR(12) NOT NULL REFERENCES tokens(id) ON DELETE CASCADE,
triggered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source_ip INET NOT NULL,
user_agent TEXT,
referer TEXT,
geo_country CHAR(2),
geo_region VARCHAR(64),
geo_city VARCHAR(64),
geo_asn INT,
geo_asn_org VARCHAR(128),
extra JSONB NOT NULL DEFAULT '{}'::jsonb,
notify_status VARCHAR(16) NOT NULL DEFAULT 'pending',
notified_at TIMESTAMPTZ,
CONSTRAINT chk_notify_status CHECK (
notify_status IN ('pending', 'sent', 'failed', 'deduped')
)
);
-- +goose StatementEnd
-- +goose Down
DROP TABLE IF EXISTS events;

View File

@ -0,0 +1,29 @@
-- ©AngelaMos | 2026
-- 0003_indexes.sql
-- +goose Up
-- +goose StatementBegin
CREATE INDEX idx_tokens_created_ip ON tokens(created_ip);
CREATE INDEX idx_tokens_created_fp ON tokens(created_fp);
CREATE INDEX idx_tokens_created_at ON tokens(created_at DESC);
CREATE INDEX idx_tokens_type ON tokens(type);
CREATE INDEX idx_tokens_trigger_count
ON tokens(trigger_count DESC) WHERE trigger_count > 0;
CREATE INDEX idx_events_token_triggered ON events(token_id, triggered_at DESC);
CREATE INDEX idx_events_source_ip ON events(source_ip);
CREATE INDEX idx_events_notify_pending
ON events(notify_status) WHERE notify_status = 'pending';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS idx_events_notify_pending;
DROP INDEX IF EXISTS idx_events_source_ip;
DROP INDEX IF EXISTS idx_events_token_triggered;
DROP INDEX IF EXISTS idx_tokens_trigger_count;
DROP INDEX IF EXISTS idx_tokens_type;
DROP INDEX IF EXISTS idx_tokens_created_at;
DROP INDEX IF EXISTS idx_tokens_created_fp;
DROP INDEX IF EXISTS idx_tokens_created_ip;
-- +goose StatementEnd

View File

@ -0,0 +1,49 @@
// ©AngelaMos | 2026
// dto.go
package event
import (
"encoding/json"
"time"
)
type GeoView struct {
Country *string `json:"country"`
Region *string `json:"region"`
City *string `json:"city"`
ASN *int `json:"asn"`
ASNOrg *string `json:"asn_org"`
}
type Response struct {
ID int64 `json:"id"`
TriggeredAt time.Time `json:"triggered_at"`
SourceIP string `json:"source_ip"`
UserAgent *string `json:"user_agent"`
Referer *string `json:"referer"`
Geo GeoView `json:"geo"`
Extra json.RawMessage `json:"extra"`
NotifyStatus NotifyStatus `json:"notify_status"`
NotifiedAt *time.Time `json:"notified_at"`
}
func (e *Event) ToResponse() Response {
return Response{
ID: e.ID,
TriggeredAt: e.TriggeredAt,
SourceIP: e.SourceIP,
UserAgent: e.UserAgent,
Referer: e.Referer,
Geo: GeoView{
Country: e.GeoCountry,
Region: e.GeoRegion,
City: e.GeoCity,
ASN: e.GeoASN,
ASNOrg: e.GeoASNOrg,
},
Extra: e.Extra,
NotifyStatus: e.NotifyStatus,
NotifiedAt: e.NotifiedAt,
}
}

View File

@ -0,0 +1,43 @@
// ©AngelaMos | 2026
// entity.go
package event
import (
"encoding/json"
"time"
)
type NotifyStatus string
const (
NotifyPending NotifyStatus = "pending"
NotifySent NotifyStatus = "sent"
NotifyFailed NotifyStatus = "failed"
NotifyDeduped NotifyStatus = "deduped"
)
func (s NotifyStatus) Valid() bool {
switch s {
case NotifyPending, NotifySent, NotifyFailed, NotifyDeduped:
return true
}
return false
}
type Event struct {
ID int64 `db:"id" json:"id"`
TokenID string `db:"token_id" json:"token_id"`
TriggeredAt time.Time `db:"triggered_at" json:"triggered_at"`
SourceIP string `db:"source_ip" json:"source_ip"`
UserAgent *string `db:"user_agent" json:"user_agent"`
Referer *string `db:"referer" json:"referer"`
GeoCountry *string `db:"geo_country" json:"geo_country"`
GeoRegion *string `db:"geo_region" json:"geo_region"`
GeoCity *string `db:"geo_city" json:"geo_city"`
GeoASN *int `db:"geo_asn" json:"geo_asn"`
GeoASNOrg *string `db:"geo_asn_org" json:"geo_asn_org"`
Extra json.RawMessage `db:"extra" json:"extra"`
NotifyStatus NotifyStatus `db:"notify_status" json:"notify_status"`
NotifiedAt *time.Time `db:"notified_at" json:"notified_at"`
}

View File

@ -0,0 +1,207 @@
// ©AngelaMos | 2026
// repository.go
package event
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jmoiron/sqlx"
)
var ErrNotFound = errors.New("event not found")
type Repository struct {
db *sqlx.DB
}
func NewRepository(db *sqlx.DB) *Repository {
return &Repository{db: db}
}
const insertSQL = `
INSERT INTO events (
token_id, source_ip, user_agent, referer,
geo_country, geo_region, geo_city, geo_asn, geo_asn_org,
extra, notify_status
) VALUES (
:token_id, :source_ip, :user_agent, :referer,
:geo_country, :geo_region, :geo_city, :geo_asn, :geo_asn_org,
:extra, :notify_status
)
RETURNING id, triggered_at`
func (r *Repository) Insert(ctx context.Context, e *Event) error {
if e.NotifyStatus == "" {
e.NotifyStatus = NotifyPending
}
if len(e.Extra) == 0 {
e.Extra = json.RawMessage(`{}`)
}
stmt, err := r.db.PrepareNamedContext(ctx, insertSQL)
if err != nil {
return fmt.Errorf("prepare insert event: %w", err)
}
defer stmt.Close()
if err := stmt.GetContext(ctx, e, e); err != nil {
return fmt.Errorf("insert event: %w", err)
}
return nil
}
const selectColumns = `
id, token_id, triggered_at,
source_ip, user_agent, referer,
geo_country, geo_region, geo_city, geo_asn, geo_asn_org,
extra, notify_status, notified_at`
type ListOptions struct {
Cursor int64
Limit int
}
type ListResult struct {
Events []Event
NextCursor int64
HasMore bool
}
func (r *Repository) ListByToken(
ctx context.Context, tokenID string, opts ListOptions,
) (ListResult, error) {
if opts.Limit <= 0 {
opts.Limit = 20
}
q := `SELECT ` + selectColumns + `
FROM events
WHERE token_id = $1
AND ($2 = 0 OR id < $2)
ORDER BY id DESC
LIMIT $3`
var events []Event
err := r.db.SelectContext(ctx, &events, q, tokenID, opts.Cursor, opts.Limit+1)
if err != nil {
return ListResult{}, fmt.Errorf("list events: %w", err)
}
hasMore := len(events) > opts.Limit
if hasMore {
events = events[:opts.Limit]
}
var next int64
if hasMore && len(events) > 0 {
next = events[len(events)-1].ID
}
return ListResult{
Events: events,
NextCursor: next,
HasMore: hasMore,
}, nil
}
func (r *Repository) CountByToken(ctx context.Context, tokenID string) (int64, error) {
var n int64
err := r.db.GetContext(ctx, &n,
`SELECT COUNT(*) FROM events WHERE token_id = $1`, tokenID)
if err != nil {
return 0, fmt.Errorf("count events: %w", err)
}
return n, nil
}
func (r *Repository) AttachFingerprint(
ctx context.Context, tokenID, sourceIP string, fingerprint json.RawMessage, window time.Duration,
) error {
q := `
UPDATE events
SET extra = extra || $3::jsonb
WHERE id = (
SELECT id FROM events
WHERE token_id = $1
AND source_ip = $2::inet
AND triggered_at >= NOW() - $4::interval
ORDER BY id DESC
LIMIT 1
)`
res, err := r.db.ExecContext(ctx, q,
tokenID, sourceIP, []byte(fingerprint), fmt.Sprintf("%d milliseconds", window.Milliseconds()))
if err != nil {
return fmt.Errorf("attach fingerprint: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return ErrNotFound
}
return nil
}
func (r *Repository) UpdateNotifyStatus(
ctx context.Context, eventID int64, status NotifyStatus, sentAt *time.Time,
) error {
q := `
UPDATE events
SET notify_status = $2, notified_at = $3
WHERE id = $1`
res, err := r.db.ExecContext(ctx, q, eventID, status, sentAt)
if err != nil {
return fmt.Errorf("update notify status: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return ErrNotFound
}
return nil
}
func (r *Repository) PruneToLimit(ctx context.Context, perTokenLimit int) (int64, error) {
if perTokenLimit <= 0 {
return 0, errors.New("perTokenLimit must be positive")
}
q := `
WITH ranked AS (
SELECT id,
row_number() OVER (PARTITION BY token_id ORDER BY triggered_at DESC) AS rn
FROM events
)
DELETE FROM events
WHERE id IN (SELECT id FROM ranked WHERE rn > $1)`
res, err := r.db.ExecContext(ctx, q, perTokenLimit)
if err != nil {
return 0, fmt.Errorf("prune events: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("rows affected: %w", err)
}
return n, nil
}
func (r *Repository) GetByID(ctx context.Context, id int64) (*Event, error) {
var e Event
q := `SELECT ` + selectColumns + ` FROM events WHERE id = $1`
err := r.db.GetContext(ctx, &e, q, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get event by id: %w", err)
}
return &e, nil
}

View File

@ -0,0 +1,253 @@
// ©AngelaMos | 2026
// repository_test.go
//go:build integration
package event_test
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/testutil"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
)
func ptr[T any](v T) *T { return &v }
func newRepos(t *testing.T) (*sqlx.DB, *token.Repository, *event.Repository) {
t.Helper()
db := sqlx.NewDb(testutil.NewTestDB(t), "pgx")
return db, token.NewRepository(db), event.NewRepository(db)
}
func seedToken(t *testing.T, repo *token.Repository, id string) *token.Token {
t.Helper()
tok := &token.Token{
ID: id,
ManageID: uuid.New().String(),
Type: token.TypeWebbug,
Memo: "event-test",
AlertChannel: token.ChannelWebhook,
WebhookURL: ptr("https://example.com/hook"),
CreatedIP: "203.0.113.1",
CreatedFP: "abcdef0123456789",
Metadata: json.RawMessage(`{}`),
Enabled: true,
}
require.NoError(t, repo.Insert(context.Background(), tok))
return tok
}
func TestRepository_InsertAndCount(t *testing.T) {
t.Parallel()
_, tokRepo, evtRepo := newRepos(t)
ctx := context.Background()
tok := seedToken(t, tokRepo, "evtinsert001")
for range 3 {
e := &event.Event{
TokenID: tok.ID,
SourceIP: "203.0.113.45",
}
require.NoError(t, evtRepo.Insert(ctx, e))
require.NotZero(t, e.ID)
require.False(t, e.TriggeredAt.IsZero())
require.Equal(t, event.NotifyPending, e.NotifyStatus)
}
count, err := evtRepo.CountByToken(ctx, tok.ID)
require.NoError(t, err)
require.Equal(t, int64(3), count)
}
func TestRepository_ListByToken_CursorPagination(t *testing.T) {
t.Parallel()
_, tokRepo, evtRepo := newRepos(t)
ctx := context.Background()
tok := seedToken(t, tokRepo, "evtcursor001")
for i := range 5 {
e := &event.Event{
TokenID: tok.ID,
SourceIP: "203.0.113.45",
UserAgent: ptr(string(rune('A' + i))),
}
require.NoError(t, evtRepo.Insert(ctx, e))
}
page1, err := evtRepo.ListByToken(ctx, tok.ID, event.ListOptions{Limit: 2})
require.NoError(t, err)
require.Len(t, page1.Events, 2)
require.True(t, page1.HasMore)
require.NotZero(t, page1.NextCursor)
require.Equal(t, "E", *page1.Events[0].UserAgent, "newest first")
page2, err := evtRepo.ListByToken(ctx, tok.ID, event.ListOptions{
Cursor: page1.NextCursor, Limit: 2,
})
require.NoError(t, err)
require.Len(t, page2.Events, 2)
require.True(t, page2.HasMore)
page3, err := evtRepo.ListByToken(ctx, tok.ID, event.ListOptions{
Cursor: page2.NextCursor, Limit: 2,
})
require.NoError(t, err)
require.Len(t, page3.Events, 1)
require.False(t, page3.HasMore)
require.Zero(t, page3.NextCursor)
}
func TestRepository_FKCascade(t *testing.T) {
t.Parallel()
_, tokRepo, evtRepo := newRepos(t)
ctx := context.Background()
tok := seedToken(t, tokRepo, "evtcascade01")
for range 3 {
e := &event.Event{TokenID: tok.ID, SourceIP: "203.0.113.45"}
require.NoError(t, evtRepo.Insert(ctx, e))
}
require.NoError(t, tokRepo.DeleteByManageID(ctx, tok.ManageID))
count, err := evtRepo.CountByToken(ctx, tok.ID)
require.NoError(t, err)
require.Equal(t, int64(0), count, "cascade delete should remove all events")
}
func TestRepository_AttachFingerprint(t *testing.T) {
t.Parallel()
_, tokRepo, evtRepo := newRepos(t)
ctx := context.Background()
tok := seedToken(t, tokRepo, "evtfprint001")
e := &event.Event{
TokenID: tok.ID,
SourceIP: "203.0.113.45",
Extra: json.RawMessage(`{"initial":"value"}`),
}
require.NoError(t, evtRepo.Insert(ctx, e))
fp := json.RawMessage(`{"screen":"1920x1080","tz":"America/Los_Angeles"}`)
require.NoError(t, evtRepo.AttachFingerprint(ctx, tok.ID, "203.0.113.45", fp, 30*time.Second))
got, err := evtRepo.GetByID(ctx, e.ID)
require.NoError(t, err)
var merged map[string]any
require.NoError(t, json.Unmarshal(got.Extra, &merged))
require.Equal(t, "value", merged["initial"])
require.Equal(t, "1920x1080", merged["screen"])
require.Equal(t, "America/Los_Angeles", merged["tz"])
}
func TestRepository_AttachFingerprint_NoMatch(t *testing.T) {
t.Parallel()
_, tokRepo, evtRepo := newRepos(t)
ctx := context.Background()
tok := seedToken(t, tokRepo, "evtfpnomtch1")
fp := json.RawMessage(`{"screen":"1024x768"}`)
err := evtRepo.AttachFingerprint(ctx, tok.ID, "203.0.113.45", fp, 30*time.Second)
require.ErrorIs(t, err, event.ErrNotFound)
}
func TestRepository_UpdateNotifyStatus(t *testing.T) {
t.Parallel()
_, tokRepo, evtRepo := newRepos(t)
ctx := context.Background()
tok := seedToken(t, tokRepo, "evtnotify001")
e := &event.Event{TokenID: tok.ID, SourceIP: "203.0.113.45"}
require.NoError(t, evtRepo.Insert(ctx, e))
require.Equal(t, event.NotifyPending, e.NotifyStatus)
now := time.Now()
require.NoError(t, evtRepo.UpdateNotifyStatus(ctx, e.ID, event.NotifySent, &now))
got, err := evtRepo.GetByID(ctx, e.ID)
require.NoError(t, err)
require.Equal(t, event.NotifySent, got.NotifyStatus)
require.NotNil(t, got.NotifiedAt)
}
func TestRepository_PruneToLimit(t *testing.T) {
t.Parallel()
_, tokRepo, evtRepo := newRepos(t)
ctx := context.Background()
tokA := seedToken(t, tokRepo, "evtpruneaaa1")
tokB := seedToken(t, tokRepo, "evtprunebbb1")
for range 5 {
require.NoError(t, evtRepo.Insert(ctx, &event.Event{TokenID: tokA.ID, SourceIP: "203.0.113.10"}))
}
for range 7 {
require.NoError(t, evtRepo.Insert(ctx, &event.Event{TokenID: tokB.ID, SourceIP: "203.0.113.20"}))
}
deleted, err := evtRepo.PruneToLimit(ctx, 3)
require.NoError(t, err)
require.Equal(t, int64((5-3)+(7-3)), deleted, "should delete 2 from A and 4 from B")
cntA, err := evtRepo.CountByToken(ctx, tokA.ID)
require.NoError(t, err)
require.Equal(t, int64(3), cntA)
cntB, err := evtRepo.CountByToken(ctx, tokB.ID)
require.NoError(t, err)
require.Equal(t, int64(3), cntB)
}
func TestRepository_PruneToLimit_KeepsNewest(t *testing.T) {
t.Parallel()
_, tokRepo, evtRepo := newRepos(t)
ctx := context.Background()
tok := seedToken(t, tokRepo, "evtprunekeep")
var ids []int64
for i := range 5 {
e := &event.Event{TokenID: tok.ID, SourceIP: "203.0.113.45", UserAgent: ptr(string(rune('A' + i)))}
require.NoError(t, evtRepo.Insert(ctx, e))
ids = append(ids, e.ID)
time.Sleep(2 * time.Millisecond)
}
_, err := evtRepo.PruneToLimit(ctx, 2)
require.NoError(t, err)
cnt, err := evtRepo.CountByToken(ctx, tok.ID)
require.NoError(t, err)
require.Equal(t, int64(2), cnt)
_, err = evtRepo.GetByID(ctx, ids[4])
require.NoError(t, err, "newest should be kept")
_, err = evtRepo.GetByID(ctx, ids[3])
require.NoError(t, err, "second-newest should be kept")
_, err = evtRepo.GetByID(ctx, ids[0])
require.ErrorIs(t, err, event.ErrNotFound, "oldest should be deleted")
}
func TestRepository_PruneToLimit_RejectsZero(t *testing.T) {
t.Parallel()
_, _, evtRepo := newRepos(t)
_, err := evtRepo.PruneToLimit(context.Background(), 0)
require.Error(t, err)
}

View File

@ -0,0 +1,56 @@
// ©AngelaMos | 2026
// postgres.go
package testutil
import (
"context"
"database/sql"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/core"
)
func NewTestDB(t *testing.T) *sql.DB {
t.Helper()
ctx := context.Background()
pgContainer, err := tcpostgres.Run(ctx, "postgres:18-alpine",
tcpostgres.WithDatabase("canary_test"),
tcpostgres.WithUsername("test"),
tcpostgres.WithPassword("test"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(60*time.Second),
),
)
require.NoError(t, err)
t.Cleanup(func() {
_ = pgContainer.Terminate(context.Background())
})
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err)
db, err := sql.Open("pgx", connStr)
require.NoError(t, err)
t.Cleanup(func() {
_ = db.Close()
})
require.NoError(t, db.Ping())
require.NoError(t, core.RunMigrations(db))
return db
}

View File

@ -0,0 +1,55 @@
// ©AngelaMos | 2026
// dto.go
package token
import (
"encoding/json"
"time"
)
type CreateRequest struct {
Type Type `json:"type" validate:"required,oneof=webbug slowredirect docx pdf kubeconfig envfile mysql"`
Memo string `json:"memo" validate:"max=256"`
Filename string `json:"filename" validate:"max=128"`
AlertChannel AlertChannel `json:"alert_channel" validate:"required,oneof=telegram webhook"`
TelegramBot string `json:"telegram_bot" validate:"required_if=AlertChannel telegram"`
TelegramChat string `json:"telegram_chat" validate:"required_if=AlertChannel telegram"`
WebhookURL string `json:"webhook_url" validate:"required_if=AlertChannel webhook,omitempty,url"`
Metadata json.RawMessage `json:"metadata"`
TurnstileResp string `json:"cf_turnstile_response" validate:"required"`
}
type Response struct {
ID string `json:"id"`
ManageID string `json:"manage_id"`
Type Type `json:"type"`
Memo string `json:"memo"`
Filename *string `json:"filename"`
AlertChannel AlertChannel `json:"alert_channel"`
CreatedAt time.Time `json:"created_at"`
TriggerCount int64 `json:"trigger_count"`
LastTriggered *time.Time `json:"last_triggered"`
Enabled bool `json:"enabled"`
TriggerURL string `json:"trigger_url"`
ManageURL string `json:"manage_url"`
Metadata json.RawMessage `json:"metadata,omitempty"`
}
func (t *Token) ToResponse(triggerURL, manageURL string) Response {
return Response{
ID: t.ID,
ManageID: t.ManageID,
Type: t.Type,
Memo: t.Memo,
Filename: t.Filename,
AlertChannel: t.AlertChannel,
CreatedAt: t.CreatedAt,
TriggerCount: t.TriggerCount,
LastTriggered: t.LastTriggered,
Enabled: t.Enabled,
TriggerURL: triggerURL,
ManageURL: manageURL,
Metadata: t.Metadata,
}
}

View File

@ -0,0 +1,64 @@
// ©AngelaMos | 2026
// entity.go
package token
import (
"encoding/json"
"time"
)
type Type string
const (
TypeWebbug Type = "webbug"
TypeSlowRedirect Type = "slowredirect"
TypeDocx Type = "docx"
TypePDF Type = "pdf"
TypeKubeconfig Type = "kubeconfig"
TypeEnvfile Type = "envfile"
TypeMySQL Type = "mysql"
)
func (t Type) Valid() bool {
switch t {
case TypeWebbug, TypeSlowRedirect, TypeDocx, TypePDF,
TypeKubeconfig, TypeEnvfile, TypeMySQL:
return true
}
return false
}
type AlertChannel string
const (
ChannelTelegram AlertChannel = "telegram"
ChannelWebhook AlertChannel = "webhook"
)
func (c AlertChannel) Valid() bool {
switch c {
case ChannelTelegram, ChannelWebhook:
return true
}
return false
}
type Token struct {
ID string `db:"id" json:"id"`
ManageID string `db:"manage_id" json:"manage_id"`
Type Type `db:"type" json:"type"`
Memo string `db:"memo" json:"memo"`
Filename *string `db:"filename" json:"filename"`
AlertChannel AlertChannel `db:"alert_channel" json:"alert_channel"`
TelegramBot *string `db:"telegram_bot" json:"-"`
TelegramChat *string `db:"telegram_chat" json:"-"`
WebhookURL *string `db:"webhook_url" json:"-"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
CreatedIP string `db:"created_ip" json:"-"`
CreatedFP string `db:"created_fp" json:"-"`
Enabled bool `db:"enabled" json:"enabled"`
TriggerCount int64 `db:"trigger_count" json:"trigger_count"`
LastTriggered *time.Time `db:"last_triggered" json:"last_triggered"`
Metadata json.RawMessage `db:"metadata" json:"metadata"`
}

View File

@ -0,0 +1,151 @@
// ©AngelaMos | 2026
// repository.go
package token
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/jmoiron/sqlx"
)
var ErrNotFound = errors.New("token not found")
type Repository struct {
db *sqlx.DB
}
func NewRepository(db *sqlx.DB) *Repository {
return &Repository{db: db}
}
const insertSQL = `
INSERT INTO tokens (
id, manage_id, type, memo, filename,
alert_channel, telegram_bot, telegram_chat, webhook_url,
created_ip, created_fp, metadata, enabled
) VALUES (
:id, :manage_id, :type, :memo, :filename,
:alert_channel, :telegram_bot, :telegram_chat, :webhook_url,
:created_ip, :created_fp, :metadata, :enabled
)
RETURNING created_at, trigger_count, last_triggered`
func (r *Repository) Insert(ctx context.Context, t *Token) error {
stmt, err := r.db.PrepareNamedContext(ctx, insertSQL)
if err != nil {
return fmt.Errorf("prepare insert token: %w", err)
}
defer stmt.Close()
if err := stmt.GetContext(ctx, t, t); err != nil {
return fmt.Errorf("insert token: %w", err)
}
return nil
}
const selectColumns = `
id, manage_id, type, memo, filename,
alert_channel, telegram_bot, telegram_chat, webhook_url,
created_at, created_ip, created_fp, enabled,
trigger_count, last_triggered, metadata`
func (r *Repository) GetByID(ctx context.Context, id string) (*Token, error) {
var t Token
q := `SELECT ` + selectColumns + ` FROM tokens WHERE id = $1`
err := r.db.GetContext(ctx, &t, q, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get token by id: %w", err)
}
return &t, nil
}
func (r *Repository) GetByManageID(ctx context.Context, manageID string) (*Token, error) {
var t Token
q := `SELECT ` + selectColumns + ` FROM tokens WHERE manage_id = $1`
err := r.db.GetContext(ctx, &t, q, manageID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get token by manage_id: %w", err)
}
return &t, nil
}
func (r *Repository) DeleteByManageID(ctx context.Context, manageID string) error {
res, err := r.db.ExecContext(ctx,
`DELETE FROM tokens WHERE manage_id = $1`, manageID)
if err != nil {
return fmt.Errorf("delete token: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return ErrNotFound
}
return nil
}
func (r *Repository) IncrementTriggerCount(ctx context.Context, id string) error {
_, err := r.db.ExecContext(ctx, `
UPDATE tokens
SET trigger_count = trigger_count + 1,
last_triggered = NOW()
WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("increment trigger count: %w", err)
}
return nil
}
func (r *Repository) SetEnabled(ctx context.Context, id string, enabled bool) error {
res, err := r.db.ExecContext(ctx,
`UPDATE tokens SET enabled = $2 WHERE id = $1`, id, enabled)
if err != nil {
return fmt.Errorf("set enabled: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return ErrNotFound
}
return nil
}
type ListOptions struct {
Limit int
Offset int
}
func (r *Repository) ListAll(ctx context.Context, opts ListOptions) ([]Token, error) {
if opts.Limit <= 0 {
opts.Limit = 50
}
q := `SELECT ` + selectColumns + ` FROM tokens
ORDER BY created_at DESC
LIMIT $1 OFFSET $2`
var tokens []Token
if err := r.db.SelectContext(ctx, &tokens, q, opts.Limit, opts.Offset); err != nil {
return nil, fmt.Errorf("list all tokens: %w", err)
}
return tokens, nil
}
func (r *Repository) CountAll(ctx context.Context) (int64, error) {
var n int64
if err := r.db.GetContext(ctx, &n, `SELECT COUNT(*) FROM tokens`); err != nil {
return 0, fmt.Errorf("count tokens: %w", err)
}
return n, nil
}

View File

@ -0,0 +1,214 @@
// ©AngelaMos | 2026
// repository_test.go
//go:build integration
package token_test
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/testutil"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
)
func newRepo(t *testing.T) *token.Repository {
t.Helper()
db := sqlx.NewDb(testutil.NewTestDB(t), "pgx")
return token.NewRepository(db)
}
func ptr[T any](v T) *T { return &v }
func sampleWebhookToken(id string) *token.Token {
return &token.Token{
ID: id,
ManageID: uuid.New().String(),
Type: token.TypeWebbug,
Memo: "test webbug",
AlertChannel: token.ChannelWebhook,
WebhookURL: ptr("https://example.com/hook"),
CreatedIP: "203.0.113.10",
CreatedFP: "0123456789abcdef",
Metadata: json.RawMessage(`{}`),
Enabled: true,
}
}
func sampleTelegramToken(id string) *token.Token {
return &token.Token{
ID: id,
ManageID: uuid.New().String(),
Type: token.TypeDocx,
Memo: "Q4 bonuses",
Filename: ptr("Q4_Bonuses_2024.docx"),
AlertChannel: token.ChannelTelegram,
TelegramBot: ptr("123456:ABCDEF"),
TelegramChat: ptr("-1001234567890"),
CreatedIP: "198.51.100.5",
CreatedFP: "fedcba9876543210",
Metadata: json.RawMessage(`{"include_keys":["aws"]}`),
Enabled: true,
}
}
func TestRepository_InsertAndGetByID(t *testing.T) {
t.Parallel()
repo := newRepo(t)
ctx := context.Background()
tok := sampleWebhookToken("abcdef0123ko")
require.NoError(t, repo.Insert(ctx, tok))
require.False(t, tok.CreatedAt.IsZero(), "Insert should populate created_at via RETURNING")
got, err := repo.GetByID(ctx, tok.ID)
require.NoError(t, err)
require.Equal(t, tok.ID, got.ID)
require.Equal(t, tok.ManageID, got.ManageID)
require.Equal(t, tok.Type, got.Type)
require.Equal(t, tok.AlertChannel, got.AlertChannel)
require.Equal(t, tok.Memo, got.Memo)
require.NotNil(t, got.WebhookURL)
require.Equal(t, "https://example.com/hook", *got.WebhookURL)
require.True(t, got.Enabled)
require.Equal(t, int64(0), got.TriggerCount)
}
func TestRepository_GetByID_NotFound(t *testing.T) {
t.Parallel()
repo := newRepo(t)
_, err := repo.GetByID(context.Background(), "doesnotexist")
require.ErrorIs(t, err, token.ErrNotFound)
}
func TestRepository_GetByManageID(t *testing.T) {
t.Parallel()
repo := newRepo(t)
ctx := context.Background()
tok := sampleTelegramToken("mngabcdef012")
require.NoError(t, repo.Insert(ctx, tok))
got, err := repo.GetByManageID(ctx, tok.ManageID)
require.NoError(t, err)
require.Equal(t, tok.ID, got.ID)
require.Equal(t, token.ChannelTelegram, got.AlertChannel)
require.NotNil(t, got.TelegramBot)
require.Equal(t, "123456:ABCDEF", *got.TelegramBot)
}
func TestRepository_GetByManageID_NotFound(t *testing.T) {
t.Parallel()
repo := newRepo(t)
_, err := repo.GetByManageID(context.Background(), uuid.New().String())
require.ErrorIs(t, err, token.ErrNotFound)
}
func TestRepository_DeleteByManageID(t *testing.T) {
t.Parallel()
repo := newRepo(t)
ctx := context.Background()
tok := sampleWebhookToken("delok0123abc")
require.NoError(t, repo.Insert(ctx, tok))
require.NoError(t, repo.DeleteByManageID(ctx, tok.ManageID))
_, err := repo.GetByID(ctx, tok.ID)
require.ErrorIs(t, err, token.ErrNotFound)
}
func TestRepository_DeleteByManageID_NotFound(t *testing.T) {
t.Parallel()
repo := newRepo(t)
err := repo.DeleteByManageID(context.Background(), uuid.New().String())
require.True(t, errors.Is(err, token.ErrNotFound))
}
func TestRepository_IncrementTriggerCount(t *testing.T) {
t.Parallel()
repo := newRepo(t)
ctx := context.Background()
tok := sampleWebhookToken("trgcount001a")
require.NoError(t, repo.Insert(ctx, tok))
require.NoError(t, repo.IncrementTriggerCount(ctx, tok.ID))
require.NoError(t, repo.IncrementTriggerCount(ctx, tok.ID))
got, err := repo.GetByID(ctx, tok.ID)
require.NoError(t, err)
require.Equal(t, int64(2), got.TriggerCount)
require.NotNil(t, got.LastTriggered)
}
func TestRepository_SetEnabled(t *testing.T) {
t.Parallel()
repo := newRepo(t)
ctx := context.Background()
tok := sampleWebhookToken("setenab0001a")
require.NoError(t, repo.Insert(ctx, tok))
require.NoError(t, repo.SetEnabled(ctx, tok.ID, false))
got, err := repo.GetByID(ctx, tok.ID)
require.NoError(t, err)
require.False(t, got.Enabled)
require.NoError(t, repo.SetEnabled(ctx, tok.ID, true))
got, err = repo.GetByID(ctx, tok.ID)
require.NoError(t, err)
require.True(t, got.Enabled)
}
func TestRepository_SetEnabled_NotFound(t *testing.T) {
t.Parallel()
repo := newRepo(t)
err := repo.SetEnabled(context.Background(), "nonexistent", false)
require.ErrorIs(t, err, token.ErrNotFound)
}
func TestRepository_ListAll(t *testing.T) {
t.Parallel()
repo := newRepo(t)
ctx := context.Background()
for _, id := range []string{"lstaaaaaaaa1", "lstbbbbbbbb2", "lstcccccccc3"} {
tok := sampleWebhookToken(id)
tok.Memo = "list-test-" + id
require.NoError(t, repo.Insert(ctx, tok))
}
got, err := repo.ListAll(ctx, token.ListOptions{Limit: 10})
require.NoError(t, err)
require.Len(t, got, 3)
count, err := repo.CountAll(ctx)
require.NoError(t, err)
require.Equal(t, int64(3), count)
}
func TestRepository_TypeAndChannelValidation(t *testing.T) {
t.Parallel()
repo := newRepo(t)
bad := sampleWebhookToken("badtypee0001")
bad.Type = "invalid_type"
err := repo.Insert(context.Background(), bad)
require.Error(t, err, "DB CHECK should reject invalid type")
}