chore(canary): phase B audit fixes + frontend asset rename + gitignore
Audit Phase B (backend): F5 trusted-proxy gate wiring, F7 ErrValidation sentinel, F8 dedup unique-IP semantic via SCard, F9 notify worker pool + graceful Shutdown, F12 token ID collision retry. Frontend: public/asset/ → public/assets/ rename + index.html paths. Housekeeping: .npmrc strict-dep-builds=false (pnpm fix), .gitignore now excludes all .env.* except .env.example.
|
|
@ -20,7 +20,7 @@ NGINX_HOST_PORT=22784
|
||||||
PUBLIC_BASE_URL=https://canary.your.domain
|
PUBLIC_BASE_URL=https://canary.your.domain
|
||||||
|
|
||||||
# Frontend build-time vars (baked into the bundle by Vite)
|
# Frontend build-time vars (baked into the bundle by Vite)
|
||||||
VITE_APP_TITLE=Canary Token Generator
|
VITE_APP_TITLE="Canary Token Generator"
|
||||||
VITE_API_URL=/api
|
VITE_API_URL=/api
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,7 @@ docs/
|
||||||
# Environment files (secrets)
|
# Environment files (secrets)
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.*
|
||||||
.env.*.local
|
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,14 @@ func run(configPath string) error {
|
||||||
|
|
||||||
shutdownErr := gracefulShutdown(cfg, logger, srv, telemetry, rdb, db)
|
shutdownErr := gracefulShutdown(cfg, logger, srv, telemetry, rdb, db)
|
||||||
logger.Info("waiting for in-flight notifications")
|
logger.Info("waiting for in-flight notifications")
|
||||||
notifySvc.Wait()
|
notifyShutdownCtx, notifyShutdownCancel := context.WithTimeout(
|
||||||
|
context.Background(),
|
||||||
|
cfg.Server.ShutdownTimeout,
|
||||||
|
)
|
||||||
|
if nErr := notifySvc.Shutdown(notifyShutdownCtx); nErr != nil {
|
||||||
|
logger.Warn("notify shutdown timed out", "error", nErr)
|
||||||
|
}
|
||||||
|
notifyShutdownCancel()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
return shutdownErr
|
return shutdownErr
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,7 @@ func setupIntgStack(t *testing.T) *intgStack {
|
||||||
eventRepo,
|
eventRepo,
|
||||||
eventSvc,
|
eventSvc,
|
||||||
logger,
|
logger,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
@ -412,8 +413,8 @@ func TestIntegration_ManagePageReturnsTokenAndEventsAndSilencedCount(t *testing.
|
||||||
|
|
||||||
require.Equal(t, int64(3), manageResp.Data.EventsTotal,
|
require.Equal(t, int64(3), manageResp.Data.EventsTotal,
|
||||||
"all 3 events recorded (one sent + two deduped)")
|
"all 3 events recorded (one sent + two deduped)")
|
||||||
require.Equal(t, int64(2), manageResp.Data.EventsSilencedActive,
|
require.Equal(t, int64(1), manageResp.Data.EventsSilencedActive,
|
||||||
"two triggers deduped within 15-min window")
|
"one unique IP silenced (same source for both dedup hits)")
|
||||||
|
|
||||||
require.Len(t, manageResp.Data.Events, 3, "events page payload")
|
require.Len(t, manageResp.Data.Events, 3, "events page payload")
|
||||||
require.False(t, manageResp.Data.Page.HasMore,
|
require.False(t, manageResp.Data.Page.HasMore,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
|
|
@ -17,9 +16,9 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
dedupKeyPrefix = "dedup:trigger:"
|
dedupKeyPrefix = "dedup:trigger:"
|
||||||
defaultDedupTTL = 15 * time.Minute
|
dedupActivePrefix = "dedup:active:"
|
||||||
dedupScanBatch = 100
|
defaultDedupTTL = 15 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
|
|
@ -132,6 +131,17 @@ func (s *Service) dedupGate(
|
||||||
s.logger.WarnContext(ctx, "dedup incr failed",
|
s.logger.WarnContext(ctx, "dedup incr failed",
|
||||||
"error", iErr, "key", key)
|
"error", iErr, "key", key)
|
||||||
}
|
}
|
||||||
|
trackKey := dedupActivePrefix + tokenID
|
||||||
|
if _, sErr := s.rdb.SAdd(ctx, trackKey, sourceIP).Result(); sErr != nil {
|
||||||
|
s.logger.WarnContext(ctx, "dedup track add",
|
||||||
|
"error", sErr, "key", trackKey)
|
||||||
|
}
|
||||||
|
if _, eErr := s.rdb.Expire(
|
||||||
|
ctx, trackKey, s.dedupTTL,
|
||||||
|
).Result(); eErr != nil {
|
||||||
|
s.logger.WarnContext(ctx, "dedup track expire",
|
||||||
|
"error", eErr, "key", trackKey)
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,40 +152,14 @@ func (s *Service) CountActiveDedup(
|
||||||
if s.rdb == nil {
|
if s.rdb == nil {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
pattern := dedupKeyPrefix + tokenID + ":*"
|
n, err := s.rdb.SCard(ctx, dedupActivePrefix+tokenID).Result()
|
||||||
var total int64
|
if errors.Is(err, redis.Nil) {
|
||||||
var cursor uint64
|
return 0, nil
|
||||||
for {
|
|
||||||
keys, next, err := s.rdb.Scan(
|
|
||||||
ctx, cursor, pattern, dedupScanBatch,
|
|
||||||
).Result()
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("dedup scan: %w", err)
|
|
||||||
}
|
|
||||||
for _, key := range keys {
|
|
||||||
v, gErr := s.rdb.Get(ctx, key).Result()
|
|
||||||
if errors.Is(gErr, redis.Nil) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if gErr != nil {
|
|
||||||
s.logger.WarnContext(ctx, "dedup count: get key",
|
|
||||||
"error", gErr, "key", key)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
n, pErr := strconv.ParseInt(v, 10, 64)
|
|
||||||
if pErr != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if n > 1 {
|
|
||||||
total += n - 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if next == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
cursor = next
|
|
||||||
}
|
}
|
||||||
return total, nil
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("dedup count: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) RunRetentionLoop(
|
func (s *Service) RunRetentionLoop(
|
||||||
|
|
|
||||||
|
|
@ -582,8 +582,8 @@ func TestService_CountActiveDedup_CountsSilencedAcrossIPs(t *testing.T) {
|
||||||
|
|
||||||
n, err := svc.CountActiveDedup(context.Background(), testTokenID)
|
n, err := svc.CountActiveDedup(context.Background(), testTokenID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, int64(2+4), n,
|
require.Equal(t, int64(2), n,
|
||||||
"key1=3 (silenced 2) + key2=5 (silenced 4)")
|
"two distinct IPs were silenced (203.0.113.1 and 203.0.113.2)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_CountActiveDedup_IgnoresOtherTokens(t *testing.T) {
|
func TestService_CountActiveDedup_IgnoresOtherTokens(t *testing.T) {
|
||||||
|
|
@ -616,7 +616,8 @@ func TestService_CountActiveDedup_IgnoresOtherTokens(t *testing.T) {
|
||||||
|
|
||||||
n, err := svc.CountActiveDedup(context.Background(), testTokenID)
|
n, err := svc.CountActiveDedup(context.Background(), testTokenID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, int64(2), n, "only this token's keys counted")
|
require.Equal(t, int64(1), n,
|
||||||
|
"only this token's silenced IPs counted (one distinct IP)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_CountActiveDedup_NilRedisReturnsZero(t *testing.T) {
|
func TestService_CountActiveDedup_NilRedisReturnsZero(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,27 @@ import (
|
||||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
|
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultSendTimeout = 30 * time.Second
|
const (
|
||||||
|
defaultSendTimeout = 30 * time.Second
|
||||||
|
defaultWorkers = 8
|
||||||
|
defaultQueueSize = 256
|
||||||
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
senders map[string]Sender
|
senders map[string]Sender
|
||||||
status StatusWriter
|
status StatusWriter
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
sendTimeout time.Duration
|
sendTimeout time.Duration
|
||||||
wg sync.WaitGroup
|
workers int
|
||||||
|
queue chan dispatchJob
|
||||||
|
workerWg sync.WaitGroup
|
||||||
|
jobWg sync.WaitGroup
|
||||||
|
closeOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
type dispatchJob struct {
|
||||||
|
info event.NotifyInfo
|
||||||
|
evt *event.Event
|
||||||
}
|
}
|
||||||
|
|
||||||
type Option func(*Service)
|
type Option func(*Service)
|
||||||
|
|
@ -32,16 +45,40 @@ func WithSendTimeout(d time.Duration) Option {
|
||||||
return func(s *Service) { s.sendTimeout = d }
|
return func(s *Service) { s.sendTimeout = d }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithMaxConcurrent(n int) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
if n > 0 {
|
||||||
|
s.workers = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithQueueSize(n int) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
if n > 0 {
|
||||||
|
s.queue = make(chan dispatchJob, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func NewService(status StatusWriter, opts ...Option) *Service {
|
func NewService(status StatusWriter, opts ...Option) *Service {
|
||||||
s := &Service{
|
s := &Service{
|
||||||
senders: make(map[string]Sender),
|
senders: make(map[string]Sender),
|
||||||
status: status,
|
status: status,
|
||||||
logger: slog.Default(),
|
logger: slog.Default(),
|
||||||
sendTimeout: defaultSendTimeout,
|
sendTimeout: defaultSendTimeout,
|
||||||
|
workers: defaultWorkers,
|
||||||
}
|
}
|
||||||
for _, o := range opts {
|
for _, o := range opts {
|
||||||
o(s)
|
o(s)
|
||||||
}
|
}
|
||||||
|
if s.queue == nil {
|
||||||
|
s.queue = make(chan dispatchJob, defaultQueueSize)
|
||||||
|
}
|
||||||
|
for range s.workers {
|
||||||
|
s.workerWg.Add(1)
|
||||||
|
go s.worker()
|
||||||
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,15 +92,50 @@ func (s *Service) Register(senders ...Sender) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Notify(info event.NotifyInfo, evt *event.Event) {
|
func (s *Service) Notify(info event.NotifyInfo, evt *event.Event) {
|
||||||
s.wg.Add(1)
|
s.jobWg.Add(1)
|
||||||
go func() {
|
select {
|
||||||
defer s.wg.Done()
|
case s.queue <- dispatchJob{info: info, evt: evt}:
|
||||||
s.dispatch(info, evt)
|
default:
|
||||||
}()
|
s.jobWg.Done()
|
||||||
|
s.logger.Warn("notify: queue full, dropping",
|
||||||
|
"event_id", evt.ID,
|
||||||
|
"token_id", info.TokenID,
|
||||||
|
"channel", info.AlertChannel,
|
||||||
|
)
|
||||||
|
s.markStatus(
|
||||||
|
context.Background(),
|
||||||
|
evt.ID,
|
||||||
|
event.NotifyFailed,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Wait() {
|
func (s *Service) Wait() {
|
||||||
s.wg.Wait()
|
s.jobWg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Shutdown(ctx context.Context) error {
|
||||||
|
s.closeOnce.Do(func() { close(s.queue) })
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
s.workerWg.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) worker() {
|
||||||
|
defer s.workerWg.Done()
|
||||||
|
for job := range s.queue {
|
||||||
|
s.dispatch(job.info, job.evt)
|
||||||
|
s.jobWg.Done()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) dispatch(info event.NotifyInfo, evt *event.Event) {
|
func (s *Service) dispatch(info event.NotifyInfo, evt *event.Event) {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import (
|
||||||
|
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -22,6 +23,9 @@ const (
|
||||||
|
|
||||||
metadataDestinationURL = "destination_url"
|
metadataDestinationURL = "destination_url"
|
||||||
metadataIncludeKeys = "include_keys"
|
metadataIncludeKeys = "include_keys"
|
||||||
|
|
||||||
|
pgUniqueViolationCode = "23505"
|
||||||
|
maxTokenIDAttempts = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -35,6 +39,7 @@ var (
|
||||||
"token: no generator registered for this type",
|
"token: no generator registered for this type",
|
||||||
)
|
)
|
||||||
ErrGenerateFailed = errors.New("token: artifact generation failed")
|
ErrGenerateFailed = errors.New("token: artifact generation failed")
|
||||||
|
ErrValidation = errors.New("token: request validation failed")
|
||||||
)
|
)
|
||||||
|
|
||||||
var allowedIncludeKeys = map[string]struct{}{
|
var allowedIncludeKeys = map[string]struct{}{
|
||||||
|
|
@ -94,7 +99,7 @@ func (s *Service) Create(
|
||||||
) (*Token, Artifact, error) {
|
) (*Token, Artifact, error) {
|
||||||
if err := s.validate.Struct(req); err != nil {
|
if err := s.validate.Struct(req); err != nil {
|
||||||
return nil, Artifact{}, fmt.Errorf(
|
return nil, Artifact{}, fmt.Errorf(
|
||||||
"validate request: %w", err,
|
"%w: %w", ErrValidation, err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if err := validateTypeMetadata(req.Type, req.Metadata); err != nil {
|
if err := validateTypeMetadata(req.Type, req.Metadata); err != nil {
|
||||||
|
|
@ -108,16 +113,8 @@ func (s *Service) Create(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := generateTokenID()
|
|
||||||
if err != nil {
|
|
||||||
return nil, Artifact{}, fmt.Errorf(
|
|
||||||
"generate id: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
manageID := uuid.NewString()
|
manageID := uuid.NewString()
|
||||||
|
|
||||||
tok := &Token{
|
tok := &Token{
|
||||||
ID: id,
|
|
||||||
ManageID: manageID,
|
ManageID: manageID,
|
||||||
Type: req.Type,
|
Type: req.Type,
|
||||||
Memo: req.Memo,
|
Memo: req.Memo,
|
||||||
|
|
@ -132,19 +129,45 @@ func (s *Service) Create(
|
||||||
Metadata: normalizeMetadata(req.Metadata),
|
Metadata: normalizeMetadata(req.Metadata),
|
||||||
}
|
}
|
||||||
|
|
||||||
art, err := gen.Generate(ctx, tok, s.baseURL)
|
var (
|
||||||
if err != nil {
|
art Artifact
|
||||||
return nil, Artifact{}, fmt.Errorf(
|
insertErr error
|
||||||
"%w: %w", ErrGenerateFailed, err,
|
)
|
||||||
)
|
for attempt := range maxTokenIDAttempts {
|
||||||
}
|
id, err := generateTokenID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, Artifact{}, fmt.Errorf(
|
||||||
|
"generate id (attempt %d): %w", attempt, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
tok.ID = id
|
||||||
|
|
||||||
if err := s.repo.Insert(ctx, tok); err != nil {
|
art, err = gen.Generate(ctx, tok, s.baseURL)
|
||||||
return nil, Artifact{}, fmt.Errorf(
|
if err != nil {
|
||||||
"persist token: %w", err,
|
return nil, Artifact{}, fmt.Errorf(
|
||||||
)
|
"%w: %w", ErrGenerateFailed, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
insertErr = s.repo.Insert(ctx, tok)
|
||||||
|
if insertErr == nil {
|
||||||
|
return tok, art, nil
|
||||||
|
}
|
||||||
|
if !isUniqueViolation(insertErr) {
|
||||||
|
return nil, Artifact{}, fmt.Errorf(
|
||||||
|
"persist token: %w", insertErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return tok, art, nil
|
return nil, Artifact{}, fmt.Errorf(
|
||||||
|
"persist token after %d id-collision retries: %w",
|
||||||
|
maxTokenIDAttempts, insertErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUniqueViolation(err error) bool {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
return errors.As(err, &pgErr) && pgErr.Code == pgUniqueViolationCode
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetByID(
|
func (s *Service) GetByID(
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/stretchr/testify/require"
|
"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/event"
|
||||||
|
|
@ -358,6 +359,119 @@ func TestService_Create_DistinctIDsAcrossCalls(t *testing.T) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type collisionRepo struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
collisions int
|
||||||
|
remaining int
|
||||||
|
inserted []*token.Token
|
||||||
|
byID map[string]*token.Token
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCollisionRepo(collisions int) *collisionRepo {
|
||||||
|
return &collisionRepo{
|
||||||
|
remaining: collisions,
|
||||||
|
byID: map[string]*token.Token{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *collisionRepo) Insert(_ context.Context, t *token.Token) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.remaining > 0 {
|
||||||
|
r.remaining--
|
||||||
|
r.collisions++
|
||||||
|
return &pgconn.PgError{Code: "23505"}
|
||||||
|
}
|
||||||
|
r.inserted = append(r.inserted, t)
|
||||||
|
r.byID[t.ID] = t
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *collisionRepo) GetByID(
|
||||||
|
_ context.Context, id string,
|
||||||
|
) (*token.Token, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
t, ok := r.byID[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, token.ErrNotFound
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *collisionRepo) GetByManageID(
|
||||||
|
_ context.Context, _ string,
|
||||||
|
) (*token.Token, error) {
|
||||||
|
return nil, token.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *collisionRepo) IncrementTriggerCount(
|
||||||
|
_ context.Context, _ string,
|
||||||
|
) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *collisionRepo) DeleteByManageID(
|
||||||
|
_ context.Context, _ string,
|
||||||
|
) error {
|
||||||
|
return token.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_Create_RetriesOnTokenIDCollision(t *testing.T) {
|
||||||
|
repo := newCollisionRepo(2)
|
||||||
|
gen := &fakeGenerator{
|
||||||
|
tokenType: token.TypeWebbug,
|
||||||
|
artifact: generators.Artifact{
|
||||||
|
Kind: generators.KindURL,
|
||||||
|
URL: "https://canary.example.com/c/x",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := token.NewService(
|
||||||
|
repo,
|
||||||
|
token.MapRegistry{token.TypeWebbug: gen},
|
||||||
|
token.ServiceConfig{BaseURL: "https://canary.example.com"},
|
||||||
|
)
|
||||||
|
|
||||||
|
tok, _, err := svc.Create(context.Background(), token.CreateRequest{
|
||||||
|
Type: token.TypeWebbug,
|
||||||
|
Memo: "x",
|
||||||
|
AlertChannel: token.ChannelWebhook,
|
||||||
|
WebhookURL: "https://example.com/h",
|
||||||
|
}, "fp", "ip")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, tok)
|
||||||
|
require.Equal(t, 2, repo.collisions,
|
||||||
|
"repo must have rejected exactly two prior IDs")
|
||||||
|
require.Len(t, repo.inserted, 1, "exactly one token persisted")
|
||||||
|
require.Equal(t, int32(3), gen.calls.Load(),
|
||||||
|
"generator called once per attempt (regenerates artifact per id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_Create_GivesUpAfterMaxCollisions(t *testing.T) {
|
||||||
|
repo := newCollisionRepo(10)
|
||||||
|
gen := &fakeGenerator{
|
||||||
|
tokenType: token.TypeWebbug,
|
||||||
|
artifact: generators.Artifact{
|
||||||
|
Kind: generators.KindURL,
|
||||||
|
URL: "https://canary.example.com/c/x",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := token.NewService(
|
||||||
|
repo,
|
||||||
|
token.MapRegistry{token.TypeWebbug: gen},
|
||||||
|
token.ServiceConfig{BaseURL: "https://canary.example.com"},
|
||||||
|
)
|
||||||
|
|
||||||
|
_, _, err := svc.Create(context.Background(), token.CreateRequest{
|
||||||
|
Type: token.TypeWebbug,
|
||||||
|
Memo: "x",
|
||||||
|
AlertChannel: token.ChannelWebhook,
|
||||||
|
WebhookURL: "https://example.com/h",
|
||||||
|
}, "fp", "ip")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "id-collision retries")
|
||||||
|
}
|
||||||
|
|
||||||
func TestService_GetByID_NotFoundReturnsNilNil(t *testing.T) {
|
func TestService_GetByID_NotFoundReturnsNilNil(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := newFakeRepo()
|
||||||
svc := token.NewService(repo, token.MapRegistry{},
|
svc := token.NewService(repo, token.MapRegistry{},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
# ©AngelaMos | 2026
|
||||||
|
# .npmrc
|
||||||
|
strict-dep-builds=false
|
||||||
|
|
@ -9,20 +9,20 @@
|
||||||
<link
|
<link
|
||||||
rel="icon"
|
rel="icon"
|
||||||
type="image/x-icon"
|
type="image/x-icon"
|
||||||
href="/asset/favicon.ico"
|
href="/assets/favicon.ico"
|
||||||
/>
|
/>
|
||||||
<link
|
<link
|
||||||
rel="apple-touch-icon"
|
rel="apple-touch-icon"
|
||||||
type="image/png"
|
type="image/png"
|
||||||
href="/asset/apple-touch-icon.png"
|
href="/assets/apple-touch-icon.png"
|
||||||
/>
|
/>
|
||||||
<link
|
<link
|
||||||
rel="manifest"
|
rel="manifest"
|
||||||
href="/asset/site.webmanifest"
|
href="/assets/site.webmanifest"
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1.0"
|
content="width=device-width, initial-scale=1.0, maximum-scale=1"
|
||||||
/>
|
/>
|
||||||
<title>Canary Token Generator</title>
|
<title>Canary Token Generator</title>
|
||||||
<meta
|
<meta
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,11 @@
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"vite": "npm:rolldown-vite@7.2.5"
|
"vite": "npm:rolldown-vite@7.2.5"
|
||||||
}
|
},
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"@parcel/watcher",
|
||||||
|
"core-js",
|
||||||
|
"esbuild"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 410 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 823 B |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 302 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -9,11 +9,11 @@
|
||||||
|
|
||||||
FROM node:22-slim
|
FROM node:22-slim
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable && corepack prepare pnpm@10.29.1 --activate
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml* ./
|
COPY package.json pnpm-lock.yaml* .npmrc* ./
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
|
|
||||||