From 019bfda437337fd90f6ab79726f73964b8971c3b Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 13 May 2026 15:13:43 -0400 Subject: [PATCH] feat(canary): token service + contract relocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../backend/internal/token/contract.go | 52 +++ .../internal/token/generators/generator.go | 46 +-- .../backend/internal/token/service.go | 297 ++++++++++++++ .../backend/internal/token/service_test.go | 366 ++++++++++++++++++ 4 files changed, 723 insertions(+), 38 deletions(-) create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/token/contract.go create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/token/service.go create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/token/service_test.go diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/contract.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/contract.go new file mode 100644 index 00000000..c91463dc --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/contract.go @@ -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) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/generator.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/generator.go index d7e66c2b..604e436d 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/generator.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/generator.go @@ -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 diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/service.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/service.go new file mode 100644 index 00000000..eba9dbed --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/service.go @@ -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 +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/service_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/service_test.go new file mode 100644 index 00000000..686c7742 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/service_test.go @@ -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")) +}