feat(canary): token service + contract relocation

Phase 9 task 9.5.

Resolved a long-standing import-cycle constraint: the Generator
interface + Artifact + ArtifactKind + TriggerResponse types now live
at internal/token/contract.go (the token package, which both the
service and concrete generators can import). The
internal/token/generators/generator.go file now re-exports these
as Go type aliases for backwards compatibility — concrete generators
(webbug/slowredirect/docx/pdf/kubeconfig/envfile/mysql) keep their
existing `generators.Artifact` / `generators.KindFile` references
unchanged.

This unlocks token.Service.Create, which would otherwise be unable
to type-reference Artifact (since generators imports token for
token.Type/token.Token).

token.Service:
  - Create(ctx, req, fp, ip) (*Token, Artifact, error):
      - go-playground/validator on the request DTO
      - type-specific metadata validation:
        - slowredirect → metadata.destination_url required, must be
          http:// or https://
        - envfile → metadata.include_keys (if present) must be a
          subset of {aws,stripe,github,db}; malformed JSON rejected
        - others → no extra validation
      - registry.Get(type) → ErrUnknownGeneratorType on miss
      - generateTokenID: 12-char [a-z0-9] via crypto/rand
      - uuid.NewString for manage_id
      - call Generator.Generate (which may mutate t.Metadata for
        mysql's mysql_username persist)
      - repo.Insert; rollback semantics: if generator fails, no
        persistence happens
  - GetByID / GetByManageID: proxy to repo, return (nil, nil) on
    not-found (so handlers can distinguish 404 from real errors)
  - IncrementTriggerCount: proxy to repo
  - TriggerURL / ManageURL: URL builders so handlers don't duplicate
    path joining
  - Generator(t Type): registry lookup for handler dispatch

Tests (~15 cases): ID + manage_id format regexes, repo persistence,
generator call, unknown type, validation failure, slowredirect
metadata variants (missing/invalid-scheme/valid), envfile include_keys
(invalid/valid), generator error path skips persistence, distinct IDs
across 30 calls, GetByID nil-nil semantics, URL builders.
This commit is contained in:
CarterPerez-dev 2026-05-13 15:13:43 -04:00
parent 537b0e3fd8
commit 019bfda437
4 changed files with 723 additions and 38 deletions

View File

@ -0,0 +1,52 @@
// ©AngelaMos | 2026
// contract.go
package token
import (
"context"
"net/http"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
)
type ArtifactKind string
const (
KindURL ArtifactKind = "url"
KindFile ArtifactKind = "file"
KindText ArtifactKind = "text"
KindConnectionString ArtifactKind = "connection_string"
)
type Artifact struct {
Kind ArtifactKind
URL string
Filename string
Content []byte
ContentType string
ConnectionString string
DestinationURL string
}
type TriggerResponse struct {
StatusCode int
ContentType string
Body []byte
RedirectURL string
ExtraHeaders map[string]string
}
type Generator interface {
Type() Type
Generate(
ctx context.Context,
t *Token,
baseURL string,
) (Artifact, error)
Trigger(
ctx context.Context,
t *Token,
r *http.Request,
) (*event.Event, *TriggerResponse, error)
}

View File

@ -4,50 +4,20 @@
package generators
import (
"context"
"net/http"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
)
type ArtifactKind string
type ArtifactKind = token.ArtifactKind
const (
KindURL ArtifactKind = "url"
KindFile ArtifactKind = "file"
KindText ArtifactKind = "text"
KindConnectionString ArtifactKind = "connection_string"
KindURL = token.KindURL
KindFile = token.KindFile
KindText = token.KindText
KindConnectionString = token.KindConnectionString
)
type Artifact struct {
Kind ArtifactKind
URL string
Filename string
Content []byte
ContentType string
ConnectionString string
DestinationURL string
}
type Artifact = token.Artifact
type TriggerResponse struct {
StatusCode int
ContentType string
Body []byte
RedirectURL string
ExtraHeaders map[string]string
}
type TriggerResponse = token.TriggerResponse
type Generator interface {
Type() token.Type
Generate(
ctx context.Context,
t *token.Token,
baseURL string,
) (Artifact, error)
Trigger(
ctx context.Context,
t *token.Token,
r *http.Request,
) (*event.Event, *TriggerResponse, error)
}
type Generator = token.Generator

View File

@ -0,0 +1,297 @@
// ©AngelaMos | 2026
// service.go
package token
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"math/big"
"strings"
"github.com/go-playground/validator/v10"
"github.com/google/uuid"
)
const (
tokenIDAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
tokenIDLength = 12
metadataDestinationURL = "destination_url"
metadataIncludeKeys = "include_keys"
)
var (
ErrInvalidDestinationURL = errors.New(
"token: slowredirect requires metadata.destination_url",
)
ErrInvalidIncludeKeys = errors.New(
"token: envfile metadata.include_keys must be a subset of {aws,stripe,github,db}",
)
ErrUnknownGeneratorType = errors.New(
"token: no generator registered for this type",
)
ErrGenerateFailed = errors.New("token: artifact generation failed")
)
var allowedIncludeKeys = map[string]struct{}{
"aws": {}, "stripe": {}, "github": {}, "db": {},
}
type ServiceRepository interface {
Insert(ctx context.Context, t *Token) error
GetByID(ctx context.Context, id string) (*Token, error)
GetByManageID(ctx context.Context, manageID string) (*Token, error)
IncrementTriggerCount(ctx context.Context, id string) error
}
type Registry interface {
Get(t Type) (Generator, bool)
}
type MapRegistry map[Type]Generator
func (m MapRegistry) Get(t Type) (Generator, bool) {
g, ok := m[t]
return g, ok
}
type ServiceConfig struct {
BaseURL string
ManageURL string
}
type Service struct {
repo ServiceRepository
registry Registry
validate *validator.Validate
baseURL string
manageURL string
}
func NewService(
repo ServiceRepository,
reg Registry,
cfg ServiceConfig,
) *Service {
return &Service{
repo: repo,
registry: reg,
validate: validator.New(),
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
manageURL: strings.TrimRight(cfg.ManageURL, "/"),
}
}
func (s *Service) Create(
ctx context.Context,
req CreateRequest,
createdFP, createdIP string,
) (*Token, Artifact, error) {
if err := s.validate.Struct(req); err != nil {
return nil, Artifact{}, fmt.Errorf(
"validate request: %w", err,
)
}
if err := validateTypeMetadata(req.Type, req.Metadata); err != nil {
return nil, Artifact{}, err
}
gen, ok := s.registry.Get(req.Type)
if !ok {
return nil, Artifact{}, fmt.Errorf(
"%w: %s", ErrUnknownGeneratorType, req.Type,
)
}
id, err := generateTokenID()
if err != nil {
return nil, Artifact{}, fmt.Errorf(
"generate id: %w", err,
)
}
manageID := uuid.NewString()
tok := &Token{
ID: id,
ManageID: manageID,
Type: req.Type,
Memo: req.Memo,
Filename: filenamePointer(req.Filename),
AlertChannel: req.AlertChannel,
TelegramBot: optionalString(req.TelegramBot),
TelegramChat: optionalString(req.TelegramChat),
WebhookURL: optionalString(req.WebhookURL),
CreatedIP: createdIP,
CreatedFP: createdFP,
Enabled: true,
Metadata: normalizeMetadata(req.Metadata),
}
art, err := gen.Generate(ctx, tok, s.baseURL)
if err != nil {
return nil, Artifact{}, fmt.Errorf(
"%w: %w", ErrGenerateFailed, err,
)
}
if err := s.repo.Insert(ctx, tok); err != nil {
return nil, Artifact{}, fmt.Errorf(
"persist token: %w", err,
)
}
return tok, art, nil
}
func (s *Service) GetByID(
ctx context.Context,
id string,
) (*Token, error) {
tok, err := s.repo.GetByID(ctx, id)
if errors.Is(err, ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return tok, nil
}
func (s *Service) GetByManageID(
ctx context.Context,
manageID string,
) (*Token, error) {
tok, err := s.repo.GetByManageID(ctx, manageID)
if errors.Is(err, ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return tok, nil
}
func (s *Service) IncrementTriggerCount(
ctx context.Context,
id string,
) error {
return s.repo.IncrementTriggerCount(ctx, id)
}
func (s *Service) TriggerURL(id string) string {
return s.baseURL + "/c/" + id
}
func (s *Service) ManageURL(manageID string) string {
return s.manageURL + "/m/" + manageID
}
func (s *Service) Generator(t Type) (Generator, bool) {
return s.registry.Get(t)
}
func validateTypeMetadata(t Type, metadata json.RawMessage) error {
switch t {
case TypeSlowRedirect:
return validateSlowredirectMetadata(metadata)
case TypeEnvfile:
return validateEnvfileMetadata(metadata)
case TypeWebbug, TypeDocx, TypePDF, TypeKubeconfig, TypeMySQL:
return nil
}
return nil
}
func validateSlowredirectMetadata(metadata json.RawMessage) error {
if len(metadata) == 0 {
return ErrInvalidDestinationURL
}
var m map[string]json.RawMessage
if err := json.Unmarshal(metadata, &m); err != nil {
return fmt.Errorf(
"parse metadata: %w (slowredirect: %w)",
err, ErrInvalidDestinationURL,
)
}
raw, ok := m[metadataDestinationURL]
if !ok {
return ErrInvalidDestinationURL
}
var dest string
if err := json.Unmarshal(raw, &dest); err != nil {
return ErrInvalidDestinationURL
}
dest = strings.TrimSpace(dest)
if dest == "" {
return ErrInvalidDestinationURL
}
low := strings.ToLower(dest)
if !strings.HasPrefix(low, "http://") &&
!strings.HasPrefix(low, "https://") {
return ErrInvalidDestinationURL
}
return nil
}
func validateEnvfileMetadata(metadata json.RawMessage) error {
if len(metadata) == 0 {
return nil
}
var m map[string]json.RawMessage
if err := json.Unmarshal(metadata, &m); err != nil {
return fmt.Errorf("envfile metadata: %w", err)
}
raw, ok := m[metadataIncludeKeys]
if !ok {
return nil
}
var keys []string
if err := json.Unmarshal(raw, &keys); err != nil {
return ErrInvalidIncludeKeys
}
for _, k := range keys {
if _, ok := allowedIncludeKeys[k]; !ok {
return fmt.Errorf("%w: %q", ErrInvalidIncludeKeys, k)
}
}
return nil
}
func generateTokenID() (string, error) {
out := make([]byte, tokenIDLength)
bigLen := big.NewInt(int64(len(tokenIDAlphabet)))
for i := range out {
idx, err := rand.Int(rand.Reader, bigLen)
if err != nil {
return "", fmt.Errorf("rand: %w", err)
}
out[i] = tokenIDAlphabet[idx.Int64()]
}
return string(out), nil
}
func filenamePointer(s string) *string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
return &s
}
func optionalString(s string) *string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
return &s
}
func normalizeMetadata(metadata json.RawMessage) json.RawMessage {
if len(metadata) == 0 {
return json.RawMessage(`{}`)
}
return metadata
}

View File

@ -0,0 +1,366 @@
// ©AngelaMos | 2026
// service_test.go
package token_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"sync"
"sync/atomic"
"testing"
"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/token"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators"
)
type fakeRepo struct {
mu sync.Mutex
inserted []*token.Token
byID map[string]*token.Token
byManage map[string]*token.Token
insertErr error
}
func newFakeRepo() *fakeRepo {
return &fakeRepo{
byID: map[string]*token.Token{},
byManage: map[string]*token.Token{},
}
}
func (f *fakeRepo) Insert(_ context.Context, t *token.Token) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.insertErr != nil {
return f.insertErr
}
f.inserted = append(f.inserted, t)
f.byID[t.ID] = t
f.byManage[t.ManageID] = t
return nil
}
func (f *fakeRepo) GetByID(_ context.Context, id string) (*token.Token, error) {
f.mu.Lock()
defer f.mu.Unlock()
t, ok := f.byID[id]
if !ok {
return nil, token.ErrNotFound
}
return t, nil
}
func (f *fakeRepo) GetByManageID(
_ context.Context,
manageID string,
) (*token.Token, error) {
f.mu.Lock()
defer f.mu.Unlock()
t, ok := f.byManage[manageID]
if !ok {
return nil, token.ErrNotFound
}
return t, nil
}
func (f *fakeRepo) IncrementTriggerCount(_ context.Context, _ string) error {
return nil
}
type fakeGenerator struct {
tokenType token.Type
artifact generators.Artifact
generateErr error
calls atomic.Int32
}
func (g *fakeGenerator) Type() token.Type { return g.tokenType }
func (g *fakeGenerator) Generate(
_ context.Context,
_ *token.Token,
_ string,
) (generators.Artifact, error) {
g.calls.Add(1)
if g.generateErr != nil {
return generators.Artifact{}, g.generateErr
}
return g.artifact, nil
}
func (g *fakeGenerator) Trigger(
_ context.Context,
_ *token.Token,
_ *http.Request,
) (*event.Event, *generators.TriggerResponse, error) {
return nil, nil, nil
}
func newWebbugReq() token.CreateRequest {
return token.CreateRequest{
Type: token.TypeWebbug,
Memo: "test",
AlertChannel: token.ChannelWebhook,
WebhookURL: "https://example.com/hook",
TurnstileResp: "stub-token",
}
}
func TestService_Create_GeneratesValidIDAndManageID(t *testing.T) {
repo := newFakeRepo()
gen := &fakeGenerator{
tokenType: token.TypeWebbug,
artifact: generators.Artifact{Kind: generators.KindURL, URL: "x"},
}
svc := token.NewService(
repo,
token.MapRegistry{token.TypeWebbug: gen},
token.ServiceConfig{
BaseURL: "https://canary.example.com",
ManageURL: "https://canary.example.com",
},
)
tok, _, err := svc.Create(
context.Background(),
newWebbugReq(),
"fp",
"1.2.3.4",
)
require.NoError(t, err)
require.NotNil(t, tok)
require.Regexp(t, `^[a-z0-9]{12}$`, tok.ID)
require.Regexp(
t,
`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`,
tok.ManageID,
)
}
func TestService_Create_PersistsToRepo(t *testing.T) {
repo := newFakeRepo()
gen := &fakeGenerator{
tokenType: token.TypeWebbug,
artifact: generators.Artifact{Kind: generators.KindURL},
}
svc := token.NewService(repo, token.MapRegistry{token.TypeWebbug: gen},
token.ServiceConfig{BaseURL: "https://canary.example.com"})
tok, _, err := svc.Create(
context.Background(),
newWebbugReq(),
"fp",
"1.2.3.4",
)
require.NoError(t, err)
require.Len(t, repo.inserted, 1)
require.Equal(t, tok.ID, repo.inserted[0].ID)
require.Equal(t, "1.2.3.4", repo.inserted[0].CreatedIP)
require.Equal(t, "fp", repo.inserted[0].CreatedFP)
require.True(t, repo.inserted[0].Enabled)
}
func TestService_Create_CallsGeneratorWithBaseURL(t *testing.T) {
repo := newFakeRepo()
gen := &fakeGenerator{
tokenType: token.TypeWebbug,
artifact: generators.Artifact{Kind: generators.KindURL, URL: "x"},
}
svc := token.NewService(repo, token.MapRegistry{token.TypeWebbug: gen},
token.ServiceConfig{BaseURL: "https://canary.example.com"})
_, _, err := svc.Create(
context.Background(),
newWebbugReq(),
"fp",
"1.2.3.4",
)
require.NoError(t, err)
require.Equal(t, int32(1), gen.calls.Load())
}
func TestService_Create_UnknownTypeReturnsError(t *testing.T) {
repo := newFakeRepo()
svc := token.NewService(repo, token.MapRegistry{},
token.ServiceConfig{BaseURL: "https://canary.example.com"})
_, _, err := svc.Create(
context.Background(),
newWebbugReq(),
"fp",
"1.2.3.4",
)
require.Error(t, err)
require.ErrorIs(t, err, token.ErrUnknownGeneratorType)
}
func TestService_Create_ValidationFails(t *testing.T) {
repo := newFakeRepo()
gen := &fakeGenerator{tokenType: token.TypeWebbug}
svc := token.NewService(repo, token.MapRegistry{token.TypeWebbug: gen},
token.ServiceConfig{BaseURL: "https://canary.example.com"})
req := newWebbugReq()
req.AlertChannel = ""
_, _, err := svc.Create(context.Background(), req, "fp", "1.2.3.4")
require.Error(t, err)
}
func TestService_Create_SlowredirectMissingDestinationFails(t *testing.T) {
repo := newFakeRepo()
gen := &fakeGenerator{tokenType: token.TypeSlowRedirect}
svc := token.NewService(
repo,
token.MapRegistry{token.TypeSlowRedirect: gen},
token.ServiceConfig{BaseURL: "https://canary.example.com"},
)
req := newWebbugReq()
req.Type = token.TypeSlowRedirect
_, _, err := svc.Create(context.Background(), req, "fp", "1.2.3.4")
require.Error(t, err)
require.ErrorIs(t, err, token.ErrInvalidDestinationURL)
}
func TestService_Create_SlowredirectInvalidSchemeFails(t *testing.T) {
repo := newFakeRepo()
gen := &fakeGenerator{tokenType: token.TypeSlowRedirect}
svc := token.NewService(
repo,
token.MapRegistry{token.TypeSlowRedirect: gen},
token.ServiceConfig{BaseURL: "https://canary.example.com"},
)
req := newWebbugReq()
req.Type = token.TypeSlowRedirect
req.Metadata = json.RawMessage(`{"destination_url":"javascript:alert(1)"}`)
_, _, err := svc.Create(context.Background(), req, "fp", "1.2.3.4")
require.ErrorIs(t, err, token.ErrInvalidDestinationURL)
}
func TestService_Create_SlowredirectValidURLSucceeds(t *testing.T) {
repo := newFakeRepo()
gen := &fakeGenerator{
tokenType: token.TypeSlowRedirect,
artifact: generators.Artifact{Kind: generators.KindURL},
}
svc := token.NewService(
repo,
token.MapRegistry{token.TypeSlowRedirect: gen},
token.ServiceConfig{BaseURL: "https://canary.example.com"},
)
req := newWebbugReq()
req.Type = token.TypeSlowRedirect
req.Metadata = json.RawMessage(`{"destination_url":"https://example.com"}`)
_, _, err := svc.Create(context.Background(), req, "fp", "1.2.3.4")
require.NoError(t, err)
}
func TestService_Create_EnvfileInvalidKeyFails(t *testing.T) {
repo := newFakeRepo()
gen := &fakeGenerator{
tokenType: token.TypeEnvfile,
artifact: generators.Artifact{Kind: generators.KindText},
}
svc := token.NewService(repo, token.MapRegistry{token.TypeEnvfile: gen},
token.ServiceConfig{BaseURL: "https://canary.example.com"})
req := newWebbugReq()
req.Type = token.TypeEnvfile
req.Metadata = json.RawMessage(`{"include_keys":["aws","nonexistent"]}`)
_, _, err := svc.Create(context.Background(), req, "fp", "1.2.3.4")
require.ErrorIs(t, err, token.ErrInvalidIncludeKeys)
}
func TestService_Create_EnvfileValidKeysSucceeds(t *testing.T) {
repo := newFakeRepo()
gen := &fakeGenerator{
tokenType: token.TypeEnvfile,
artifact: generators.Artifact{Kind: generators.KindText},
}
svc := token.NewService(repo, token.MapRegistry{token.TypeEnvfile: gen},
token.ServiceConfig{BaseURL: "https://canary.example.com"})
req := newWebbugReq()
req.Type = token.TypeEnvfile
req.Metadata = json.RawMessage(`{"include_keys":["aws","stripe"]}`)
_, _, err := svc.Create(context.Background(), req, "fp", "1.2.3.4")
require.NoError(t, err)
}
func TestService_Create_GeneratorErrorPropagates(t *testing.T) {
repo := newFakeRepo()
gen := &fakeGenerator{
tokenType: token.TypeWebbug,
generateErr: errors.New("generate failed"),
}
svc := token.NewService(repo, token.MapRegistry{token.TypeWebbug: gen},
token.ServiceConfig{BaseURL: "https://canary.example.com"})
_, _, err := svc.Create(
context.Background(),
newWebbugReq(),
"fp",
"1.2.3.4",
)
require.ErrorIs(t, err, token.ErrGenerateFailed)
require.Empty(t, repo.inserted, "must not persist if generator failed")
}
func TestService_Create_DistinctIDsAcrossCalls(t *testing.T) {
repo := newFakeRepo()
gen := &fakeGenerator{
tokenType: token.TypeWebbug,
artifact: generators.Artifact{Kind: generators.KindURL},
}
svc := token.NewService(repo, token.MapRegistry{token.TypeWebbug: gen},
token.ServiceConfig{BaseURL: "https://canary.example.com"})
seen := make(map[string]struct{})
for range 30 {
tok, _, err := svc.Create(
context.Background(),
newWebbugReq(),
"fp",
"1.2.3.4",
)
require.NoError(t, err)
seen[tok.ID] = struct{}{}
}
require.Greater(
t,
len(seen),
28,
"ID generation must be sufficiently random",
)
}
func TestService_GetByID_NotFoundReturnsNilNil(t *testing.T) {
repo := newFakeRepo()
svc := token.NewService(repo, token.MapRegistry{},
token.ServiceConfig{BaseURL: "https://canary.example.com"})
tok, err := svc.GetByID(context.Background(), "nope")
require.NoError(t, err)
require.Nil(t, tok)
}
func TestService_TriggerURL(t *testing.T) {
svc := token.NewService(newFakeRepo(), token.MapRegistry{},
token.ServiceConfig{BaseURL: "https://canary.example.com/"})
require.Equal(t, "https://canary.example.com/c/abc", svc.TriggerURL("abc"))
}
func TestService_ManageURL(t *testing.T) {
svc := token.NewService(newFakeRepo(), token.MapRegistry{},
token.ServiceConfig{ManageURL: "https://canary.example.com/"})
require.Equal(t, "https://canary.example.com/m/uuid", svc.ManageURL("uuid"))
}