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:
parent
537b0e3fd8
commit
019bfda437
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
Loading…
Reference in New Issue