From 82bb3a9f14c625d02aa9b0a7279eda72afcc6db9 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 13 May 2026 03:14:12 -0400 Subject: [PATCH] feat(canary): docx generator (zip-patch INCLUDEPICTURE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 Task 4.3+4.4 — docx Generator that produces a tracked Word document by embedding template.docx and runtime-substituting the HONEY_TRACK_URL placeholder in word/footer2.xml with the per-token trigger URL. Preserves per-entry zip Method (STORE/DEFLATE) so Word/LibreOffice still opens the result; non-footer entry bodies are byte-identical to the template. Generate returns {Kind: KindFile, Filename: t.Filename ?? "Document.docx", Content: , ContentType: wordprocessingml MIME}. The spec §9.3 reference snippet uses `t.Filename == ""` which won't compile against the real schema — Token.Filename is *string. The generator dereferences safely via resolveFilename, trimming and falling back to the default for nil/empty/whitespace pointers. Trigger mirrors webbug exactly per spec §9.3 line 1118: 200 + 43-byte transparent GIF via pixel.Clone() + cache-control no-store + pragma no-cache. Nil-token returns the same response with nil event (spec §8.5 — no token-existence enumeration). realIP / lastNonEmptyXFF / optionalHeader helpers are copied from webbug per the standing rule (sanctioned duplication until Phase 9 middleware extraction). 24 test cases including the six prescribed by implementation plan §4.3 (OutputIsValidZip, FooterContainsTriggerURL, FooterDoesNotContainPlaceholder, OtherEntriesUnchanged, PreservesCompressionMethods, ReturnsGIFLikeWebbug) plus surrounding correctness/regression coverage (type, kind, content type, filename defaulting incl. whitespace, trigger-URL trailing-slash + subpath + uniqueness, source-IP precedence with 9 subtests mirroring webbug, GIF body independence per Trigger call, nil-token defense). Also fixes pre-existing lint debt in cmd/builddocxtemplate from commit f36cce9a: errcheck on the deferred f.Close (now propagates via named return) and gosec G304 on os.OpenFile (now filepath.Clean on the operator-supplied -out path). Rebuilding template.docx after this change produces byte-identical output (verified via sha256sum). --- .../backend/cmd/builddocxtemplate/main.go | 27 +- .../token/generators/docx/generator.go | 192 ++++++ .../token/generators/docx/generator_test.go | 577 ++++++++++++++++++ 3 files changed, 787 insertions(+), 9 deletions(-) create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/docx/generator.go create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/docx/generator_test.go diff --git a/PROJECTS/beginner/canary-token-generator/backend/cmd/builddocxtemplate/main.go b/PROJECTS/beginner/canary-token-generator/backend/cmd/builddocxtemplate/main.go index 5bfbba1d..23aa207b 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/cmd/builddocxtemplate/main.go +++ b/PROJECTS/beginner/canary-token-generator/backend/cmd/builddocxtemplate/main.go @@ -98,16 +98,25 @@ func main() { fmt.Printf("wrote %s\n", *out) } -func buildTemplate(out string) error { - if err := os.MkdirAll(filepath.Dir(out), dirPerm); err != nil { - return fmt.Errorf("mkdir parent: %w", err) +func buildTemplate(out string) (err error) { + cleaned := filepath.Clean(out) + if mkErr := os.MkdirAll(filepath.Dir(cleaned), dirPerm); mkErr != nil { + return fmt.Errorf("mkdir parent: %w", mkErr) } - f, err := os.OpenFile(out, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, filePerm) - if err != nil { - return fmt.Errorf("open output: %w", err) + f, oErr := os.OpenFile( + cleaned, + os.O_CREATE|os.O_TRUNC|os.O_WRONLY, + filePerm, + ) + if oErr != nil { + return fmt.Errorf("open output: %w", oErr) } - defer f.Close() + defer func() { + if cerr := f.Close(); cerr != nil && err == nil { + err = fmt.Errorf("close output: %w", cerr) + } + }() w := zip.NewWriter(f) for _, e := range entries() { @@ -120,8 +129,8 @@ func buildTemplate(out string) error { return fmt.Errorf("write %s: %w", e.name, wErr) } } - if err := w.Close(); err != nil { - return fmt.Errorf("close zip writer: %w", err) + if zErr := w.Close(); zErr != nil { + return fmt.Errorf("close zip writer: %w", zErr) } return nil } diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/docx/generator.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/docx/generator.go new file mode 100644 index 00000000..c8c1575e --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/docx/generator.go @@ -0,0 +1,192 @@ +// ©AngelaMos | 2026 +// generator.go + +package docx + +import ( + "archive/zip" + "bytes" + "context" + _ "embed" + "fmt" + "io" + "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/" + + placeholder = "HONEY_TRACK_URL" + footerEntry = "word/footer2.xml" + + contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + defaultFilename = "Document.docx" +) + +//go:embed template/template.docx +var docxTemplate []byte + +type Generator struct{} + +func New() *Generator { return &Generator{} } + +func (g *Generator) Type() token.Type { return token.TypeDocx } + +func (g *Generator) Generate( + _ context.Context, + t *token.Token, + baseURL string, +) (generators.Artifact, error) { + triggerURL := strings.TrimRight(baseURL, "/") + triggerPathPrefix + t.ID + + patched, err := patchTemplate(docxTemplate, triggerURL) + if err != nil { + return generators.Artifact{}, err + } + + return generators.Artifact{ + Kind: generators.KindFile, + Filename: resolveFilename(t.Filename), + Content: patched, + 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 patchTemplate(template []byte, triggerURL string) ([]byte, error) { + in, err := zip.NewReader(bytes.NewReader(template), int64(len(template))) + if err != nil { + return nil, fmt.Errorf("docx: parse template: %w", err) + } + + var out bytes.Buffer + w := zip.NewWriter(&out) + for _, f := range in.File { + rc, oErr := f.Open() + if oErr != nil { + return nil, fmt.Errorf("docx: open %s: %w", f.Name, oErr) + } + body, rErr := io.ReadAll(rc) + cErr := rc.Close() + if rErr != nil { + return nil, fmt.Errorf("docx: read %s: %w", f.Name, rErr) + } + if cErr != nil { + return nil, fmt.Errorf("docx: close %s: %w", f.Name, cErr) + } + + if f.Name == footerEntry { + body = bytes.Replace( + body, + []byte(placeholder), + []byte(triggerURL), + 1, + ) + } + + hdr := &zip.FileHeader{Name: f.Name, Method: f.Method} + fw, cErr := w.CreateHeader(hdr) + if cErr != nil { + return nil, fmt.Errorf("docx: create %s: %w", f.Name, cErr) + } + if _, wErr := fw.Write(body); wErr != nil { + return nil, fmt.Errorf("docx: write %s: %w", f.Name, wErr) + } + } + if err := w.Close(); err != nil { + return nil, fmt.Errorf("docx: close zip writer: %w", err) + } + return out.Bytes(), nil +} + +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/docx/generator_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/docx/generator_test.go new file mode 100644 index 00000000..3dd5135a --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/docx/generator_test.go @@ -0,0 +1,577 @@ +// ©AngelaMos | 2026 +// generator_test.go + +package docx_test + +import ( + "archive/zip" + "bytes" + "context" + _ "embed" + "io" + "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/docx" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/pixel" +) + +//go:embed template/template.docx +var rawTemplate []byte + +const ( + testBaseURL = "https://canary.example.com" + placeholderLiteral = "HONEY_TRACK_URL" + footerEntryName = "word/footer2.xml" + docxContentTypeMIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + defaultFilename = "Document.docx" + cacheControlNoStoreValue = "no-store, no-cache, must-revalidate, max-age=0" + pragmaNoCacheValue = "no-cache" + gifByteLength = 43 +) + +func newDocxToken(id string) *token.Token { + return &token.Token{ + ID: id, + ManageID: "manage-" + id, + Type: token.TypeDocx, + Memo: "unit test docx", + AlertChannel: token.ChannelWebhook, + Enabled: true, + } +} + +func newDocxTokenWithFilename(id, filename string) *token.Token { + tok := newDocxToken(id) + tok.Filename = &filename + return tok +} + +func readFooterXML(t *testing.T, archive []byte) []byte { + t.Helper() + r, err := zip.NewReader( + bytes.NewReader(archive), + int64(len(archive)), + ) + require.NoError(t, err) + for _, f := range r.File { + if f.Name != footerEntryName { + continue + } + rc, oErr := f.Open() + require.NoError(t, oErr) + body, rErr := io.ReadAll(rc) + require.NoError(t, rc.Close()) + require.NoError(t, rErr) + return body + } + t.Fatalf("entry %q not found in zip", footerEntryName) + return nil +} + +func zipEntryBodies(t *testing.T, archive []byte) map[string][]byte { + t.Helper() + r, err := zip.NewReader( + bytes.NewReader(archive), + int64(len(archive)), + ) + require.NoError(t, err) + out := make(map[string][]byte, len(r.File)) + for _, f := range r.File { + rc, oErr := f.Open() + require.NoError(t, oErr) + body, rErr := io.ReadAll(rc) + require.NoError(t, rc.Close()) + require.NoError(t, rErr) + out[f.Name] = body + } + return out +} + +func zipEntryMethods(t *testing.T, archive []byte) map[string]uint16 { + t.Helper() + r, err := zip.NewReader( + bytes.NewReader(archive), + int64(len(archive)), + ) + require.NoError(t, err) + out := make(map[string]uint16, len(r.File)) + for _, f := range r.File { + out[f.Name] = f.Method + } + return out +} + +func TestGenerator_TypeIsDocx(t *testing.T) { + g := docx.New() + require.Equal(t, token.TypeDocx, g.Type()) +} + +func TestGenerate_ArtifactKindIsFile(t *testing.T) { + g := docx.New() + art, err := g.Generate( + context.Background(), + newDocxToken("abc"), + testBaseURL, + ) + require.NoError(t, err) + require.Equal(t, generators.KindFile, art.Kind) +} + +func TestGenerate_ContentTypeIsDocxMIME(t *testing.T) { + g := docx.New() + art, err := g.Generate( + context.Background(), + newDocxToken("abc"), + testBaseURL, + ) + require.NoError(t, err) + require.Equal(t, docxContentTypeMIME, art.ContentType) +} + +func TestGenerate_Filename(t *testing.T) { + g := docx.New() + + t.Run("nil Filename defaults to Document.docx", func(t *testing.T) { + art, err := g.Generate( + context.Background(), + newDocxToken("abc"), + testBaseURL, + ) + require.NoError(t, err) + require.Equal(t, defaultFilename, art.Filename) + }) + + t.Run( + "empty Filename pointer defaults to Document.docx", + func(t *testing.T) { + art, err := g.Generate( + context.Background(), + newDocxTokenWithFilename("abc", ""), + testBaseURL, + ) + require.NoError(t, err) + require.Equal(t, defaultFilename, art.Filename) + }, + ) + + t.Run( + "whitespace-only Filename defaults to Document.docx", + func(t *testing.T) { + art, err := g.Generate( + context.Background(), + newDocxTokenWithFilename("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(), + newDocxTokenWithFilename("abc", " Q4-Plan.docx "), + testBaseURL, + ) + require.NoError(t, err) + require.Equal(t, "Q4-Plan.docx", art.Filename) + }) +} + +func TestGenerate_TriggerURL(t *testing.T) { + g := docx.New() + + t.Run("base URL trailing slash trimmed", func(t *testing.T) { + artA, err := g.Generate( + context.Background(), + newDocxToken("tk1"), + "https://canary.example.com", + ) + require.NoError(t, err) + artB, err := g.Generate( + context.Background(), + newDocxToken("tk1"), + "https://canary.example.com/", + ) + require.NoError(t, err) + + bodyA := readFooterXML(t, artA.Content) + bodyB := readFooterXML(t, artB.Content) + require.Contains(t, string(bodyA), "https://canary.example.com/c/tk1") + require.Contains(t, string(bodyB), "https://canary.example.com/c/tk1") + }) + + t.Run("base URL subpath preserved", func(t *testing.T) { + art, err := g.Generate( + context.Background(), + newDocxToken("tk2"), + "https://example.com/canary", + ) + require.NoError(t, err) + body := readFooterXML(t, art.Content) + require.Contains(t, string(body), "https://example.com/canary/c/tk2") + }) + + t.Run("different token ids produce distinct outputs", func(t *testing.T) { + artA, err := g.Generate( + context.Background(), + newDocxToken("aaa"), + testBaseURL, + ) + require.NoError(t, err) + artB, err := g.Generate( + context.Background(), + newDocxToken("bbb"), + testBaseURL, + ) + require.NoError(t, err) + require.NotEqual(t, artA.Content, artB.Content) + }) +} + +func TestGenerate_OutputIsValidZip(t *testing.T) { + g := docx.New() + art, err := g.Generate( + context.Background(), + newDocxToken("abc"), + testBaseURL, + ) + require.NoError(t, err) + + r, zErr := zip.NewReader( + bytes.NewReader(art.Content), + int64(len(art.Content)), + ) + require.NoError(t, zErr, "generated docx must parse as a zip archive") + require.NotEmpty(t, r.File, "zip must contain at least one entry") +} + +func TestGenerate_FooterContainsTriggerURL(t *testing.T) { + g := docx.New() + art, err := g.Generate( + context.Background(), + newDocxToken("token42"), + testBaseURL, + ) + require.NoError(t, err) + + body := readFooterXML(t, art.Content) + require.Contains( + t, + string(body), + "https://canary.example.com/c/token42", + "footer must reference the canary trigger URL after substitution", + ) +} + +func TestGenerate_FooterDoesNotContainPlaceholder(t *testing.T) { + g := docx.New() + art, err := g.Generate( + context.Background(), + newDocxToken("xyz"), + testBaseURL, + ) + require.NoError(t, err) + + body := readFooterXML(t, art.Content) + require.NotContains( + t, + string(body), + placeholderLiteral, + "placeholder must be fully substituted in footer2.xml", + ) +} + +func TestGenerate_OtherEntriesUnchanged(t *testing.T) { + g := docx.New() + art, err := g.Generate( + context.Background(), + newDocxToken("abc"), + testBaseURL, + ) + require.NoError(t, err) + + templateBodies := zipEntryBodies(t, rawTemplate) + outputBodies := zipEntryBodies(t, art.Content) + require.Len( + t, + outputBodies, + len(templateBodies), + "output must have the same entry count as the template", + ) + + for name, tmplBody := range templateBodies { + outBody, ok := outputBodies[name] + require.True(t, ok, "output missing template entry %q", name) + if name == footerEntryName { + require.NotEqual( + t, + tmplBody, + outBody, + "footer2.xml must change after substitution", + ) + continue + } + require.Equal( + t, + tmplBody, + outBody, + "non-footer entry %q must be byte-identical to template", + name, + ) + } +} + +func TestGenerate_PreservesCompressionMethods(t *testing.T) { + g := docx.New() + art, err := g.Generate( + context.Background(), + newDocxToken("abc"), + testBaseURL, + ) + require.NoError(t, err) + + templateMethods := zipEntryMethods(t, rawTemplate) + outputMethods := zipEntryMethods(t, art.Content) + + hasStore, hasDeflate := false, false + for _, m := range templateMethods { + switch m { + case zip.Store: + hasStore = true + case zip.Deflate: + hasDeflate = true + } + } + require.True( + t, + hasStore && hasDeflate, + "template must mix STORE and DEFLATE for this test to be a real regression guard", + ) + + for name, tmplMethod := range templateMethods { + outMethod, ok := outputMethods[name] + require.True(t, ok, "output missing template entry %q", name) + require.Equal( + t, + tmplMethod, + outMethod, + "entry %q method mismatch (template=%d, output=%d)", + name, + tmplMethod, + outMethod, + ) + } +} + +func TestTrigger_ReturnsGIFLikeWebbug(t *testing.T) { + g := docx.New() + tok := newDocxToken("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 := docx.New() + tok := newDocxToken("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", "LibreOffice/24.2") + r.Header.Set("Referer", "https://victim.example.com/inbox") + + 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, "LibreOffice/24.2", *evt.UserAgent) + require.NotNil(t, evt.Referer) + require.Equal(t, "https://victim.example.com/inbox", *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 without port falls back to raw value", + headers: nil, + remote: "127.0.0.1", + wantIP: "127.0.0.1", + }, + { + 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 := docx.New() + tok := newDocxToken("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 := docx.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", "curl/8.0.0") + + 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)", + ) +}