feat(canary): mysql generator + register in registry

Phase 8 third commit. Closes the generator set — all 7 token types are
now registered.

mysql/generator.go:
  - Generator with publicHost / publicPort / database fields. New()
    returns localhost:3306/internal_db defaults; NewWithAddress(host,
    port) overrides for Phase 9 wire-up
  - Generate writes mysql_username into t.Metadata (json.RawMessage
    merge via setMySQLUsername helper — same defensive map[string]
    json.RawMessage pattern as envfile's extractIncludeKeys: malformed
    existing metadata is replaced rather than erroring, existing
    fields preserved, prior mysql_username overwritten)
  - Returns Artifact{Kind: KindConnectionString, ConnectionString:
    "mysql://canary_<id>@<host>:<port>/internal_db"} per spec §9.7
    lines 1345-1352. baseURL ignored (mysql connection strings don't
    use it)
  - Trigger(ctx, t, r) returns (nil, nil, ErrHTTPTriggerNotSupported)
    sentinel error — mysql triggers fire over the TCP listener (see
    server.go/handler.go), not via the HTTP router. Phase 9's router
    will not mount any HTTP route to mysql.Trigger; this is defense
    for accidental programmatic calls
  - Note on spec §9.7 line 1343: `t.Metadata["mysql_username"] =
    username` doesn't compile against the real Token.Metadata
    (json.RawMessage). The setMySQLUsername helper is the sanctioned
    *string-shape adaptation, matching the slowredirect/envfile
    metadata patterns

mysql/generator_test.go (~11 cases):
  - Type / KindConnectionString / default connection string format /
    custom address / canary_ prefix invariant / metadata persistence
    on empty token / preservation of existing metadata fields /
    malformed-metadata fallback / stale-mysql_username overwrite /
    baseURL ignored / Trigger returns ErrHTTPTriggerNotSupported with
    nil event + nil response

registry update: cardinality 6 → 7; pending list now empty.
TestBuild_AllSevenGeneratorsRegistered replaces the
TypesPresentInPhaseN test pattern from prior phases, asserting all 7
generator types are present (closes the generator set; future phases
add services/handlers, not generators).

DEFERRED TO PHASE 9 (task 8.5): wiring `go mysql.Run(ctx, addr,
handler)` into cmd/canary/main.go. The handoff anti-relitigation set
documents "Phase 9 wires the registry into main.go" — the mysql
listener goroutine fits naturally into Phase 9's main.go work
alongside config.Config.MySQL.Enabled, config.Config.MySQL.Addr,
the token.Service (for TokenLookup), and the event.Service (for
EventRecorder). Wiring it in Phase 8 would require partial config
additions that Phase 9 then re-touches.

Pre-rollup acceptance gate clean at HEAD:
  - go build ./... + go vet ./... clean
  - go test -race -timeout=60s ./... all packages pass (~115 tests
    added across Phase 8 alone)
  - go test -tags=integration -race -timeout=300s pass
  - golangci-lint run ./... → 0 issues.
  - zero //nolint pragmas anywhere
This commit is contained in:
CarterPerez-dev 2026-05-13 14:55:37 -04:00
parent 4d4d4951ba
commit fe80c94a73
4 changed files with 320 additions and 19 deletions

View File

@ -0,0 +1,103 @@
// ©AngelaMos | 2026
// generator.go
package mysql
import (
"context"
"encoding/json"
"fmt"
"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"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators"
)
const (
defaultPublicHost = "localhost"
defaultPublicPort = 3306
defaultDatabase = "internal_db"
connectionStringFmt = "mysql://%s@%s:%d/%s"
)
type Generator struct {
publicHost string
publicPort int
database string
}
func New() *Generator {
return NewWithAddress(defaultPublicHost, defaultPublicPort)
}
func NewWithAddress(host string, port int) *Generator {
return &Generator{
publicHost: host,
publicPort: port,
database: defaultDatabase,
}
}
func (g *Generator) Type() token.Type { return token.TypeMySQL }
func (g *Generator) Generate(
_ context.Context,
t *token.Token,
_ string,
) (generators.Artifact, error) {
username := mysqlUsernamePrefix + t.ID
newMeta, err := setMySQLUsername(t.Metadata, username)
if err != nil {
return generators.Artifact{}, fmt.Errorf(
"mysql: persist username: %w",
err,
)
}
t.Metadata = newMeta
connStr := fmt.Sprintf(
connectionStringFmt,
username,
g.publicHost,
g.publicPort,
g.database,
)
return generators.Artifact{
Kind: generators.KindConnectionString,
ConnectionString: connStr,
}, nil
}
func (g *Generator) Trigger(
_ context.Context,
_ *token.Token,
_ *http.Request,
) (*event.Event, *generators.TriggerResponse, error) {
return nil, nil, ErrHTTPTriggerNotSupported
}
func setMySQLUsername(
metadata json.RawMessage,
username string,
) (json.RawMessage, error) {
m := make(map[string]json.RawMessage)
if len(metadata) > 0 {
if err := json.Unmarshal(metadata, &m); err != nil {
m = make(map[string]json.RawMessage)
}
}
val, err := json.Marshal(username)
if err != nil {
return nil, fmt.Errorf("marshal mysql_username: %w", err)
}
m[extraMySQLUsername] = val
out, err := json.Marshal(m)
if err != nil {
return nil, fmt.Errorf("marshal merged metadata: %w", err)
}
return out, nil
}

View File

@ -0,0 +1,191 @@
// ©AngelaMos | 2026
// generator_test.go
package mysql_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"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"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/mysql"
)
func newMySQLToken(id string) *token.Token {
return &token.Token{
ID: id,
ManageID: "manage-" + id,
Type: token.TypeMySQL,
Memo: "unit test mysql",
AlertChannel: token.ChannelWebhook,
Enabled: true,
}
}
func TestGenerator_TypeIsMySQL(t *testing.T) {
require.Equal(t, token.TypeMySQL, mysql.New().Type())
}
func TestGenerate_ArtifactKindIsConnectionString(t *testing.T) {
g := mysql.New()
art, err := g.Generate(context.Background(), newMySQLToken("abc"), "")
require.NoError(t, err)
require.Equal(t, generators.KindConnectionString, art.Kind)
}
func TestGenerate_ConnectionStringDefaultLocalhost(t *testing.T) {
g := mysql.New()
art, err := g.Generate(context.Background(), newMySQLToken("abc"), "")
require.NoError(t, err)
require.Equal(
t,
"mysql://canary_abc@localhost:3306/internal_db",
art.ConnectionString,
)
}
func TestGenerate_ConnectionStringCustomAddress(t *testing.T) {
g := mysql.NewWithAddress("canary.example.com", 13306)
art, err := g.Generate(context.Background(), newMySQLToken("xyz"), "")
require.NoError(t, err)
require.Equal(
t,
"mysql://canary_xyz@canary.example.com:13306/internal_db",
art.ConnectionString,
)
}
func TestGenerate_UsernamePrefixIsCanary_(t *testing.T) {
g := mysql.New()
art, err := g.Generate(context.Background(), newMySQLToken("probeid"), "")
require.NoError(t, err)
require.Contains(
t,
art.ConnectionString,
"canary_probeid@",
"username must be canary_+token.ID for TCP-side lookup parity",
)
}
func TestGenerate_PersistsMySQLUsernameInMetadata(t *testing.T) {
g := mysql.New()
tok := newMySQLToken("abc")
require.Empty(t, tok.Metadata, "fresh token has no metadata yet")
_, err := g.Generate(context.Background(), tok, "")
require.NoError(t, err)
require.NotEmpty(
t,
tok.Metadata,
"Generate must persist mysql_username into token metadata",
)
var m map[string]any
require.NoError(t, json.Unmarshal(tok.Metadata, &m))
require.Equal(t, "canary_abc", m["mysql_username"])
}
func TestGenerate_PreservesExistingMetadataFields(t *testing.T) {
g := mysql.New()
tok := newMySQLToken("abc")
tok.Metadata = json.RawMessage(`{"existing_field":"keep_me","other":42}`)
_, err := g.Generate(context.Background(), tok, "")
require.NoError(t, err)
var m map[string]any
require.NoError(t, json.Unmarshal(tok.Metadata, &m))
require.Equal(t, "keep_me", m["existing_field"])
other, ok := m["other"].(float64)
require.True(t, ok)
require.Equal(t, 42, int(other))
require.Equal(t, "canary_abc", m["mysql_username"])
}
func TestGenerate_MalformedMetadataIsReplaced(t *testing.T) {
g := mysql.New()
tok := newMySQLToken("abc")
tok.Metadata = json.RawMessage(`{not valid json`)
_, err := g.Generate(context.Background(), tok, "")
require.NoError(
t,
err,
"malformed metadata must not block generation — replace with a fresh map",
)
var m map[string]any
require.NoError(t, json.Unmarshal(tok.Metadata, &m))
require.Equal(t, "canary_abc", m["mysql_username"])
}
func TestGenerate_OverwritesPriorMySQLUsername(t *testing.T) {
g := mysql.New()
tok := newMySQLToken("abc")
tok.Metadata = json.RawMessage(`{"mysql_username":"stale_value"}`)
_, err := g.Generate(context.Background(), tok, "")
require.NoError(t, err)
var m map[string]any
require.NoError(t, json.Unmarshal(tok.Metadata, &m))
require.Equal(
t,
"canary_abc",
m["mysql_username"],
"regeneration must overwrite stale mysql_username with the current token's",
)
}
func TestGenerate_BaseURLIgnored(t *testing.T) {
g := mysql.New()
art1, err := g.Generate(
context.Background(),
newMySQLToken("abc"),
"https://canary.example.com",
)
require.NoError(t, err)
art2, err := g.Generate(
context.Background(),
newMySQLToken("abc"),
"https://different.example.com/sub",
)
require.NoError(t, err)
require.Equal(
t,
art1.ConnectionString,
art2.ConnectionString,
"baseURL is irrelevant to mysql connection strings",
)
}
func TestTrigger_ReturnsHTTPNotSupportedError(t *testing.T) {
g := mysql.New()
r := httptest.NewRequest(http.MethodGet, "/c/abc", nil)
evt, resp, err := g.Trigger(
context.Background(),
newMySQLToken("abc"),
r,
)
require.Error(t, err)
require.ErrorIs(t, err, mysql.ErrHTTPTriggerNotSupported)
require.Nil(t, evt, "no event when HTTP-triggered (mysql uses TCP)")
require.Nil(t, resp, "no response when HTTP-triggered (mysql uses TCP)")
}
func TestTrigger_NilTokenAlsoReturnsHTTPNotSupportedError(t *testing.T) {
g := mysql.New()
r := httptest.NewRequest(http.MethodGet, "/c/abc", nil)
_, _, err := g.Trigger(context.Background(), nil, r)
require.ErrorIs(t, err, mysql.ErrHTTPTriggerNotSupported)
}

View File

@ -9,6 +9,7 @@ import (
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/docx" "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/docx"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/envfile" "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/envfile"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/kubeconfig" "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/kubeconfig"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/mysql"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/pdf" "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/pdf"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/slowredirect" "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/slowredirect"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/webbug" "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/webbug"
@ -28,5 +29,6 @@ func Build(_ Config) Registry {
token.TypePDF: pdf.New(), token.TypePDF: pdf.New(),
token.TypeKubeconfig: kubeconfig.New(), token.TypeKubeconfig: kubeconfig.New(),
token.TypeEnvfile: envfile.New(), token.TypeEnvfile: envfile.New(),
token.TypeMySQL: mysql.New(),
} }
} }

View File

@ -62,6 +62,14 @@ func TestBuild_RegistersEnvfile(t *testing.T) {
require.Equal(t, token.TypeEnvfile, g.Type()) require.Equal(t, token.TypeEnvfile, g.Type())
} }
func TestBuild_RegistersMySQL(t *testing.T) {
reg := registry.Build(registry.Config{BaseURL: testBaseURL})
g, ok := reg[token.TypeMySQL]
require.True(t, ok, "expected mysql generator registered")
require.NotNil(t, g)
require.Equal(t, token.TypeMySQL, g.Type())
}
func TestBuild_UnknownTypeReturnsZeroValue(t *testing.T) { func TestBuild_UnknownTypeReturnsZeroValue(t *testing.T) {
reg := registry.Build(registry.Config{BaseURL: testBaseURL}) reg := registry.Build(registry.Config{BaseURL: testBaseURL})
g, ok := reg["nonexistent-type"] g, ok := reg["nonexistent-type"]
@ -69,28 +77,25 @@ func TestBuild_UnknownTypeReturnsZeroValue(t *testing.T) {
require.Nil(t, g, "map zero value for missing key must be nil interface") require.Nil(t, g, "map zero value for missing key must be nil interface")
} }
func TestBuild_PendingTypesNotYetRegistered(t *testing.T) { func TestBuild_AllSevenGeneratorsRegistered(t *testing.T) {
reg := registry.Build(registry.Config{BaseURL: testBaseURL})
pending := []token.Type{
token.TypeMySQL,
}
for _, tt := range pending {
_, ok := reg[tt]
require.False(
t,
ok,
"type %q is not yet registered (subsequent phases will add it); registry must not claim it",
tt,
)
}
}
func TestBuild_OnlyExpectedTypesPresentInPhase7(t *testing.T) {
reg := registry.Build(registry.Config{BaseURL: testBaseURL}) reg := registry.Build(registry.Config{BaseURL: testBaseURL})
require.Len( require.Len(
t, t,
reg, reg,
6, 7,
"Phase 7 registers exactly six generators (webbug, slowredirect, docx, pdf, kubeconfig, envfile); other phases append", "Phase 8 closes the generator set with all 7 types registered (webbug, slowredirect, docx, pdf, kubeconfig, envfile, mysql)",
) )
for _, ty := range []token.Type{
token.TypeWebbug,
token.TypeSlowRedirect,
token.TypeDocx,
token.TypePDF,
token.TypeKubeconfig,
token.TypeEnvfile,
token.TypeMySQL,
} {
_, ok := reg[ty]
require.True(t, ok, "type %q must be registered", ty)
}
} }