diff --git a/PROJECTS/beginner/canary-token-generator/backend/cmd/canary/main.go b/PROJECTS/beginner/canary-token-generator/backend/cmd/canary/main.go index 061a9551..686f870a 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/cmd/canary/main.go +++ b/PROJECTS/beginner/canary-token-generator/backend/cmd/canary/main.go @@ -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 diff --git a/PROJECTS/beginner/canary-token-generator/backend/go.mod b/PROJECTS/beginner/canary-token-generator/backend/go.mod index 3a90c8d6..b4608694 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/go.mod +++ b/PROJECTS/beginner/canary-token-generator/backend/go.mod @@ -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 ) diff --git a/PROJECTS/beginner/canary-token-generator/backend/go.sum b/PROJECTS/beginner/canary-token-generator/backend/go.sum index 0fa4bd52..6545c6db 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/go.sum +++ b/PROJECTS/beginner/canary-token-generator/backend/go.sum @@ -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= diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/core/migrations.go b/PROJECTS/beginner/canary-token-generator/backend/internal/core/migrations.go new file mode 100644 index 00000000..1f874fbe --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/core/migrations.go @@ -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 +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/core/migrations/0001_create_tokens.sql b/PROJECTS/beginner/canary-token-generator/backend/internal/core/migrations/0001_create_tokens.sql new file mode 100644 index 00000000..8d72b01c --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/core/migrations/0001_create_tokens.sql @@ -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; diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/core/migrations/0002_create_events.sql b/PROJECTS/beginner/canary-token-generator/backend/internal/core/migrations/0002_create_events.sql new file mode 100644 index 00000000..7a6c395b --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/core/migrations/0002_create_events.sql @@ -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; diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/core/migrations/0003_indexes.sql b/PROJECTS/beginner/canary-token-generator/backend/internal/core/migrations/0003_indexes.sql new file mode 100644 index 00000000..b2d7e8de --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/core/migrations/0003_indexes.sql @@ -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 diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/event/dto.go b/PROJECTS/beginner/canary-token-generator/backend/internal/event/dto.go new file mode 100644 index 00000000..5f716870 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/event/dto.go @@ -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, + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/event/entity.go b/PROJECTS/beginner/canary-token-generator/backend/internal/event/entity.go new file mode 100644 index 00000000..990cc96d --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/event/entity.go @@ -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"` +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/event/repository.go b/PROJECTS/beginner/canary-token-generator/backend/internal/event/repository.go new file mode 100644 index 00000000..0fead69f --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/event/repository.go @@ -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 +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/event/repository_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/event/repository_test.go new file mode 100644 index 00000000..f13abdc1 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/event/repository_test.go @@ -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) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/testutil/postgres.go b/PROJECTS/beginner/canary-token-generator/backend/internal/testutil/postgres.go new file mode 100644 index 00000000..4ac29e78 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/testutil/postgres.go @@ -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 +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/dto.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/dto.go new file mode 100644 index 00000000..efb7991a --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/dto.go @@ -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, + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/entity.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/entity.go new file mode 100644 index 00000000..c85e20b6 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/entity.go @@ -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"` +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/repository.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/repository.go new file mode 100644 index 00000000..95426a23 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/repository.go @@ -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 +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/repository_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/repository_test.go new file mode 100644 index 00000000..68eaab98 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/repository_test.go @@ -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") +}