From 13cd6cd1a3c5f1c1450797282122b457e3b64661 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 13 May 2026 13:54:24 -0400 Subject: [PATCH] feat(canary): pdf generator (byte-replace fixed-width URI action) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generator runs at Token-create time. Substitutes the 76-byte placeholder in the embedded template.pdf with the canary trigger URL padded to the same byte length. Length preservation is critical: the PDF's xref table indexes objects by byte offset, so anything that shifts bytes after the catalog corrupts the file. Algorithm (spec §9.4 lines 1145-1165): triggerURL := strings.TrimRight(baseURL, "/") + "/c/" + t.ID if len(triggerURL) > 76 → ErrTriggerURLTooLong padded := triggerURL + strings.Repeat("_", 76 - len(triggerURL)) out := bytes.Replace(pdfTemplate, placeholder, padded, 1) Defensive checks after substitution: - len(out) == len(pdfTemplate) (xref offsets stay valid) - bytes.Contains(out, triggerURL) (substitution actually happened) Trigger is a content-copy of webbug's response (sanctioned per Phase 2/3/4 anti-relitigation set): 200 + 43-byte pixel.Clone() + Cache-Control + Pragma headers. Nil-token path returns the same artifact shape with no event (spec §8.5 defense-in-depth). realIP / lastNonEmptyXFF / optionalHeader are duplicated byte-identically with docx and slowredirect — Phase 9 collapses this into a middleware helper. Test suite (~32 cases, mirrors the docx Phase 4 shape plus PDF-specific structural guards): Template invariants (5): - placeholder root byte-locatable exactly once (literal, not encoded) - full 76-byte placeholder byte-locatable exactly once - starts with %PDF- header - ends with %%EOF marker - pdfcpu.api.Validate passes on the raw template Generator behavior (10): - Type() is TypePDF, Artifact.Kind is KindFile - ContentType is application/pdf - Filename defaulting (nil / empty / whitespace / set) - trigger URL precedence (trailing-slash trim, subpath preserve, distinct ids → distinct outputs) - output length == template length (xref preservation) - output contains trigger URL - placeholder root removed from output - URL > 76 chars returns ErrTriggerURLTooLong (errors.Is) - boundary URL at exactly 76 chars succeeds (zero padding) - api.Validate still passes on substituted output - substitution preserves byte offset (URL occupies the same position the placeholder did) Trigger behavior (~16 incl subtests): - response shape identical to webbug (200 + pixel.Clone + headers) - records event with token id / source IP / UA / Referer - source-IP precedence (CF > XFF rightmost > XRI > RemoteAddr), 11 subcases mirroring docx + webbug for full symmetry - missing UA/Referer → nil pointers - per-call body is an independent slice (mutate one, other unchanged) - nil-token path returns GIF + nil event (FK-safe) go test -race -timeout=60s ./internal/token/generators/pdf/... passes. --- .../token/generators/pdf/generator.go | 182 +++++ .../token/generators/pdf/generator_test.go | 628 ++++++++++++++++++ 2 files changed, 810 insertions(+) create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pdf/generator.go create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pdf/generator_test.go diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pdf/generator.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pdf/generator.go new file mode 100644 index 00000000..09499d59 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pdf/generator.go @@ -0,0 +1,182 @@ +// ©AngelaMos | 2026 +// generator.go + +package pdf + +import ( + "bytes" + "context" + _ "embed" + "errors" + "fmt" + "net" + "net/http" + "strings" + + "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" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/pixel" +) + +const ( + headerCFConnectingIP = "CF-Connecting-IP" + headerXForwardedFor = "X-Forwarded-For" + headerXRealIP = "X-Real-IP" + headerReferer = "Referer" + headerCacheControl = "Cache-Control" + headerPragma = "Pragma" + + cacheControlNoStore = "no-store, no-cache, must-revalidate, max-age=0" + pragmaNoCache = "no-cache" + + triggerPathPrefix = "/c/" + + placeholderRoot = "HONEY_TRACK_URL_PADDED_TO_FIXED_WIDTH" + + PlaceholderLength = 76 + + padChar = "_" + + contentType = "application/pdf" + defaultFilename = "Document.pdf" +) + +var ErrTriggerURLTooLong = errors.New( + "pdf: trigger URL exceeds placeholder length", +) + +//go:embed template/template.pdf +var pdfTemplate []byte + +var placeholder = placeholderRoot + + strings.Repeat(padChar, PlaceholderLength-len(placeholderRoot)) + +type Generator struct{} + +func New() *Generator { return &Generator{} } + +func (g *Generator) Type() token.Type { return token.TypePDF } + +func (g *Generator) Generate( + _ context.Context, + t *token.Token, + baseURL string, +) (generators.Artifact, error) { + triggerURL := strings.TrimRight(baseURL, "/") + triggerPathPrefix + t.ID + + if len(triggerURL) > PlaceholderLength { + return generators.Artifact{}, fmt.Errorf( + "%w: url=%d max=%d", + ErrTriggerURLTooLong, + len(triggerURL), + PlaceholderLength, + ) + } + + padded := triggerURL + + strings.Repeat(padChar, PlaceholderLength-len(triggerURL)) + + out := bytes.Replace( + pdfTemplate, + []byte(placeholder), + []byte(padded), + 1, + ) + + if len(out) != len(pdfTemplate) { + return generators.Artifact{}, fmt.Errorf( + "pdf: substitution changed byte length (was %d, now %d)", + len(pdfTemplate), + len(out), + ) + } + if !bytes.Contains(out, []byte(triggerURL)) { + return generators.Artifact{}, fmt.Errorf( + "pdf: substitution did not embed trigger URL", + ) + } + + return generators.Artifact{ + Kind: generators.KindFile, + Filename: resolveFilename(t.Filename), + Content: out, + ContentType: contentType, + }, nil +} + +func (g *Generator) Trigger( + _ context.Context, + t *token.Token, + r *http.Request, +) (*event.Event, *generators.TriggerResponse, error) { + resp := &generators.TriggerResponse{ + StatusCode: http.StatusOK, + ContentType: pixel.ContentType, + Body: pixel.Clone(), + ExtraHeaders: map[string]string{ + headerCacheControl: cacheControlNoStore, + headerPragma: pragmaNoCache, + }, + } + + if t == nil { + return nil, resp, nil + } + + evt := &event.Event{ + TokenID: t.ID, + SourceIP: realIP(r), + UserAgent: optionalHeader(r.UserAgent()), + Referer: optionalHeader(r.Header.Get(headerReferer)), + } + return evt, resp, nil +} + +func resolveFilename(name *string) string { + if name == nil { + return defaultFilename + } + trimmed := strings.TrimSpace(*name) + if trimmed == "" { + return defaultFilename + } + return trimmed +} + +func optionalHeader(v string) *string { + v = strings.TrimSpace(v) + if v == "" { + return nil + } + return &v +} + +func realIP(r *http.Request) string { + if v := strings.TrimSpace(r.Header.Get(headerCFConnectingIP)); v != "" { + return v + } + if v := lastNonEmptyXFF(r.Header.Get(headerXForwardedFor)); v != "" { + return v + } + if v := strings.TrimSpace(r.Header.Get(headerXRealIP)); v != "" { + return v + } + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + return r.RemoteAddr +} + +func lastNonEmptyXFF(header string) string { + if header == "" { + return "" + } + parts := strings.Split(header, ",") + for i := len(parts) - 1; i >= 0; i-- { + if v := strings.TrimSpace(parts[i]); v != "" { + return v + } + } + return "" +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pdf/generator_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pdf/generator_test.go new file mode 100644 index 00000000..a87edba1 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pdf/generator_test.go @@ -0,0 +1,628 @@ +// ©AngelaMos | 2026 +// generator_test.go + +package pdf_test + +import ( + "bytes" + "context" + _ "embed" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/pdfcpu/pdfcpu/pkg/api" + "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/pdf" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/pixel" +) + +//go:embed template/template.pdf +var rawTemplate []byte + +const ( + testBaseURL = "https://canary.example.com" + placeholderRootLiteral = "HONEY_TRACK_URL_PADDED_TO_FIXED_WIDTH" + pdfContentTypeMIME = "application/pdf" + defaultFilename = "Document.pdf" + cacheControlNoStoreValue = "no-store, no-cache, must-revalidate, max-age=0" + pragmaNoCacheValue = "no-cache" + gifByteLength = 43 + pdfHeaderPrefix = "%PDF-" + pdfTrailer = "%%EOF\n" +) + +func fullPlaceholder() string { + return placeholderRootLiteral + + strings.Repeat("_", pdf.PlaceholderLength-len(placeholderRootLiteral)) +} + +func newPDFToken(id string) *token.Token { + return &token.Token{ + ID: id, + ManageID: "manage-" + id, + Type: token.TypePDF, + Memo: "unit test pdf", + AlertChannel: token.ChannelWebhook, + Enabled: true, + } +} + +func newPDFTokenWithFilename(id, filename string) *token.Token { + tok := newPDFToken(id) + tok.Filename = &filename + return tok +} + +func TestTemplate_PlaceholderRootIsByteLocatableExactlyOnce(t *testing.T) { + require.Equal( + t, + 1, + bytes.Count(rawTemplate, []byte(placeholderRootLiteral)), + "template must contain the placeholder root exactly once as literal bytes", + ) +} + +func TestTemplate_FullPlaceholderIsByteLocatableExactlyOnce(t *testing.T) { + full := fullPlaceholder() + require.Len( + t, + full, + pdf.PlaceholderLength, + "derived placeholder must be %d bytes", + pdf.PlaceholderLength, + ) + require.Equal( + t, + 1, + bytes.Count(rawTemplate, []byte(full)), + "template must contain the full %d-byte placeholder exactly once as literal bytes (not encoded inside a Flate stream)", + pdf.PlaceholderLength, + ) +} + +func TestTemplate_StartsWithPDFHeader(t *testing.T) { + require.True( + t, + bytes.HasPrefix(rawTemplate, []byte(pdfHeaderPrefix)), + "template must start with %q", + pdfHeaderPrefix, + ) +} + +func TestTemplate_EndsWithEOFMarker(t *testing.T) { + require.True( + t, + bytes.HasSuffix(rawTemplate, []byte(pdfTrailer)), + "template must end with %q", + pdfTrailer, + ) +} + +func TestTemplate_PdfcpuValidates(t *testing.T) { + require.NoError( + t, + api.Validate(bytes.NewReader(rawTemplate), nil), + "template must pass pdfcpu validation", + ) +} + +func TestGenerator_TypeIsPDF(t *testing.T) { + g := pdf.New() + require.Equal(t, token.TypePDF, g.Type()) +} + +func TestGenerate_ArtifactKindIsFile(t *testing.T) { + g := pdf.New() + art, err := g.Generate( + context.Background(), + newPDFToken("abc"), + testBaseURL, + ) + require.NoError(t, err) + require.Equal(t, generators.KindFile, art.Kind) +} + +func TestGenerate_ContentTypeIsPDFMIME(t *testing.T) { + g := pdf.New() + art, err := g.Generate( + context.Background(), + newPDFToken("abc"), + testBaseURL, + ) + require.NoError(t, err) + require.Equal(t, pdfContentTypeMIME, art.ContentType) +} + +func TestGenerate_Filename(t *testing.T) { + g := pdf.New() + + t.Run("nil Filename defaults to Document.pdf", func(t *testing.T) { + art, err := g.Generate( + context.Background(), + newPDFToken("abc"), + testBaseURL, + ) + require.NoError(t, err) + require.Equal(t, defaultFilename, art.Filename) + }) + + t.Run( + "empty Filename pointer defaults to Document.pdf", + func(t *testing.T) { + art, err := g.Generate( + context.Background(), + newPDFTokenWithFilename("abc", ""), + testBaseURL, + ) + require.NoError(t, err) + require.Equal(t, defaultFilename, art.Filename) + }, + ) + + t.Run( + "whitespace-only Filename defaults to Document.pdf", + func(t *testing.T) { + art, err := g.Generate( + context.Background(), + newPDFTokenWithFilename("abc", " "), + testBaseURL, + ) + require.NoError(t, err) + require.Equal(t, defaultFilename, art.Filename) + }, + ) + + t.Run("set Filename is preserved (trimmed)", func(t *testing.T) { + art, err := g.Generate( + context.Background(), + newPDFTokenWithFilename("abc", " Q4-Plan.pdf "), + testBaseURL, + ) + require.NoError(t, err) + require.Equal(t, "Q4-Plan.pdf", art.Filename) + }) +} + +func TestGenerate_TriggerURL(t *testing.T) { + g := pdf.New() + + t.Run("base URL trailing slash trimmed", func(t *testing.T) { + artA, err := g.Generate( + context.Background(), + newPDFToken("tk1"), + "https://canary.example.com", + ) + require.NoError(t, err) + artB, err := g.Generate( + context.Background(), + newPDFToken("tk1"), + "https://canary.example.com/", + ) + require.NoError(t, err) + + require.Contains( + t, + string(artA.Content), + "https://canary.example.com/c/tk1", + ) + require.Contains( + t, + string(artB.Content), + "https://canary.example.com/c/tk1", + ) + }) + + t.Run("base URL subpath preserved", func(t *testing.T) { + art, err := g.Generate( + context.Background(), + newPDFToken("tk2"), + "https://example.com/canary", + ) + require.NoError(t, err) + require.Contains( + t, + string(art.Content), + "https://example.com/canary/c/tk2", + ) + }) + + t.Run( + "different token ids produce distinct outputs", + func(t *testing.T) { + artA, err := g.Generate( + context.Background(), + newPDFToken("aaa"), + testBaseURL, + ) + require.NoError(t, err) + artB, err := g.Generate( + context.Background(), + newPDFToken("bbb"), + testBaseURL, + ) + require.NoError(t, err) + require.NotEqual(t, artA.Content, artB.Content) + }, + ) +} + +func TestGenerate_LengthUnchanged(t *testing.T) { + g := pdf.New() + art, err := g.Generate( + context.Background(), + newPDFToken("abc"), + testBaseURL, + ) + require.NoError(t, err) + require.Len( + t, + art.Content, + len(rawTemplate), + "output byte length must equal template byte length so xref offsets remain valid", + ) +} + +func TestGenerate_ContainsTriggerURL(t *testing.T) { + g := pdf.New() + art, err := g.Generate( + context.Background(), + newPDFToken("token42"), + testBaseURL, + ) + require.NoError(t, err) + + require.True( + t, + bytes.Contains( + art.Content, + []byte("https://canary.example.com/c/token42"), + ), + "output must contain the canary trigger URL after substitution", + ) +} + +func TestGenerate_PlaceholderRemoved(t *testing.T) { + g := pdf.New() + art, err := g.Generate( + context.Background(), + newPDFToken("xyz"), + testBaseURL, + ) + require.NoError(t, err) + + require.False( + t, + bytes.Contains(art.Content, []byte(placeholderRootLiteral)), + "placeholder root must be fully substituted out of the output", + ) +} + +func TestGenerate_TooLongURL_ReturnsError(t *testing.T) { + g := pdf.New() + + overLongID := strings.Repeat("x", pdf.PlaceholderLength) + _, err := g.Generate( + context.Background(), + newPDFToken(overLongID), + testBaseURL, + ) + require.Error(t, err) + require.ErrorIs(t, err, pdf.ErrTriggerURLTooLong) +} + +func TestGenerate_BoundaryURL_AtPlaceholderLength_Succeeds(t *testing.T) { + g := pdf.New() + + base := "http://x" + prefix := base + "/c/" + idLen := pdf.PlaceholderLength - len(prefix) + require.Positive( + t, + idLen, + "test base+/c/ must leave room for at least one id char", + ) + id := strings.Repeat("a", idLen) + + art, err := g.Generate( + context.Background(), + newPDFToken(id), + base, + ) + require.NoError( + t, + err, + "URL whose length exactly equals placeholder length must succeed (no padding needed)", + ) + require.True( + t, + bytes.Contains(art.Content, []byte(prefix+id)), + "boundary URL must be present in output", + ) +} + +func TestGenerate_PdfcpuValidates(t *testing.T) { + g := pdf.New() + art, err := g.Generate( + context.Background(), + newPDFToken("validate-me"), + testBaseURL, + ) + require.NoError(t, err) + require.NoError( + t, + api.Validate(bytes.NewReader(art.Content), nil), + "output PDF must remain valid after placeholder substitution", + ) +} + +func TestGenerate_OutputDifferentFromTemplate(t *testing.T) { + g := pdf.New() + art, err := g.Generate( + context.Background(), + newPDFToken("abc"), + testBaseURL, + ) + require.NoError(t, err) + require.NotEqual( + t, + rawTemplate, + art.Content, + "output must differ from template after substitution", + ) +} + +func TestGenerate_SubstitutionAtSamePlaceholderPosition(t *testing.T) { + g := pdf.New() + art, err := g.Generate( + context.Background(), + newPDFToken("position-check"), + testBaseURL, + ) + require.NoError(t, err) + + placeholderOffset := bytes.Index( + rawTemplate, + []byte(placeholderRootLiteral), + ) + require.GreaterOrEqual(t, placeholderOffset, 0) + + urlOffset := bytes.Index( + art.Content, + []byte("https://canary.example.com/c/position-check"), + ) + require.Equal( + t, + placeholderOffset, + urlOffset, + "trigger URL must occupy the same byte offset the placeholder did", + ) +} + +func TestTrigger_ReturnsGIFLikeWebbug(t *testing.T) { + g := pdf.New() + tok := newPDFToken("abc") + r := httptest.NewRequest(http.MethodGet, "/c/abc", nil) + + _, resp, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, pixel.ContentType, resp.ContentType) + require.Len(t, resp.Body, gifByteLength) + require.Equal(t, pixel.Clone(), resp.Body) + require.Equal( + t, + cacheControlNoStoreValue, + resp.ExtraHeaders["Cache-Control"], + ) + require.Equal(t, pragmaNoCacheValue, resp.ExtraHeaders["Pragma"]) +} + +func TestTrigger_RecordsEventWithRequestMetadata(t *testing.T) { + g := pdf.New() + tok := newPDFToken("token1") + + t.Run( + "captures token id, source ip, user agent, referer", + func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/c/token1", nil) + r.Header.Set("CF-Connecting-IP", "203.0.113.50") + r.Header.Set("User-Agent", "AcrobatReader/2024.001") + r.Header.Set("Referer", "https://victim.example.com/share") + + evt, _, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + require.NotNil(t, evt) + require.Equal(t, "token1", evt.TokenID) + require.Equal(t, "203.0.113.50", evt.SourceIP) + require.NotNil(t, evt.UserAgent) + require.Equal(t, "AcrobatReader/2024.001", *evt.UserAgent) + require.NotNil(t, evt.Referer) + require.Equal( + t, + "https://victim.example.com/share", + *evt.Referer, + ) + }, + ) + + t.Run("source ip precedence", func(t *testing.T) { + cases := []struct { + name string + headers map[string]string + remote string + wantIP string + }{ + { + name: "CF wins over XFF and XRI", + headers: map[string]string{ + "CF-Connecting-IP": "203.0.113.10", + "X-Forwarded-For": "198.51.100.1, 198.51.100.2", + "X-Real-IP": "192.0.2.99", + }, + remote: "127.0.0.1:9999", + wantIP: "203.0.113.10", + }, + { + name: "XFF rightmost wins over XRI when no CF", + headers: map[string]string{ + "X-Forwarded-For": "198.51.100.1, 198.51.100.7", + "X-Real-IP": "192.0.2.99", + }, + remote: "127.0.0.1:9999", + wantIP: "198.51.100.7", + }, + { + name: "XFF trailing-comma falls through to last non-empty", + headers: map[string]string{ + "X-Forwarded-For": "198.51.100.1, ", + "X-Real-IP": "192.0.2.99", + }, + remote: "127.0.0.1:9999", + wantIP: "198.51.100.1", + }, + { + name: "XFF entirely empty entries fall through to XRI", + headers: map[string]string{ + "X-Forwarded-For": ", ,", + "X-Real-IP": "192.0.2.99", + }, + remote: "127.0.0.1:9999", + wantIP: "192.0.2.99", + }, + { + name: "XRI when no CF or XFF", + headers: map[string]string{ + "X-Real-IP": "192.0.2.99", + }, + remote: "127.0.0.1:9999", + wantIP: "192.0.2.99", + }, + { + name: "RemoteAddr IPv4 strips port", + headers: nil, + remote: "127.0.0.1:9999", + wantIP: "127.0.0.1", + }, + { + name: "RemoteAddr IPv6 strips brackets and port", + headers: nil, + remote: "[2001:db8::1]:54321", + wantIP: "2001:db8::1", + }, + { + name: "RemoteAddr loopback IPv6 strips brackets and port", + headers: nil, + remote: "[::1]:9999", + wantIP: "::1", + }, + { + name: "RemoteAddr without port falls back to raw value", + headers: nil, + remote: "127.0.0.1", + wantIP: "127.0.0.1", + }, + { + name: "XFF IPv6 rightmost", + headers: map[string]string{ + "X-Forwarded-For": "198.51.100.1, 2001:db8::dead", + }, + remote: "127.0.0.1:9999", + wantIP: "2001:db8::dead", + }, + { + name: "CF value is trimmed of whitespace", + headers: map[string]string{ + "CF-Connecting-IP": " 203.0.113.10 ", + }, + remote: "127.0.0.1:9999", + wantIP: "203.0.113.10", + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/c/token1", nil) + for k, v := range tc.headers { + r.Header.Set(k, v) + } + r.RemoteAddr = tc.remote + evt, _, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + require.NotNil(t, evt) + require.Equal(t, tc.wantIP, evt.SourceIP) + }) + } + }) + + t.Run( + "missing user agent and referer record as nil pointers", + func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/c/token1", nil) + r.Header.Del("User-Agent") + r.Header.Del("Referer") + r.Header.Set("CF-Connecting-IP", "203.0.113.5") + + evt, _, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + require.NotNil(t, evt) + require.Nil( + t, + evt.UserAgent, + "absent user agent must map to nil, not empty string", + ) + require.Nil( + t, + evt.Referer, + "absent referer must map to nil, not empty string", + ) + }, + ) +} + +func TestTrigger_ResponseBodyIsIndependentCopyPerCall(t *testing.T) { + g := pdf.New() + tok := newPDFToken("abc") + r := httptest.NewRequest(http.MethodGet, "/c/abc", nil) + + _, resp1, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + _, resp2, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + + resp1.Body[0] = 0x00 + require.Equal( + t, + byte(0x47), + resp2.Body[0], + "each Trigger call must produce an independent body slice", + ) +} + +func TestTrigger_TokenNotFound_StillReturnsGIF(t *testing.T) { + g := pdf.New() + r := httptest.NewRequest(http.MethodGet, "/c/does-not-exist", nil) + r.Header.Set("CF-Connecting-IP", "203.0.113.100") + r.Header.Set("User-Agent", "AcrobatReader/2024") + + evt, resp, err := g.Trigger(context.Background(), nil, r) + require.NoError( + t, + err, + "nil-token path must not error (spec §8.5 defense-in-depth)", + ) + require.NotNil(t, resp, "nil-token path must still return GIF response") + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, pixel.ContentType, resp.ContentType) + require.Equal(t, pixel.Clone(), resp.Body) + require.Nil( + t, + evt, + "nil-token path returns nil event so the handler cannot persist a row with empty TokenID (FK violation)", + ) +}