// Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT package mail import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "strconv" "strings" "testing" "time" "github.com/spf13/cobra" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/vfs/localfileio" "github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/mail/emlbuilder" ) // TestDecodeBodyFields verifies decode body fields. func TestDecodeBodyFields(t *testing.T) { htmlEncoded := base64.URLEncoding.EncodeToString([]byte("
Hello
")) plainEncoded := base64.RawURLEncoding.EncodeToString([]byte("Hello plain")) src := map[string]interface{}{ "body_html": htmlEncoded, "body_plain_text": plainEncoded, "subject": "untouched", } dst := map[string]interface{}{} decodeBodyFields(src, dst) if dst["body_html"] != "Hello
" { t.Fatalf("body_html not decoded: %#v", dst["body_html"]) } if dst["body_plain_text"] != "Hello plain" { t.Fatalf("body_plain_text not decoded: %#v", dst["body_plain_text"]) } if _, ok := dst["subject"]; ok { t.Fatalf("subject should not be copied by decodeBodyFields") } // src must not be modified if src["body_html"] != htmlEncoded { t.Fatalf("src was mutated") } } // TestDecodeBodyFieldsSkipsAbsent verifies decode body fields skips absent. func TestDecodeBodyFieldsSkipsAbsent(t *testing.T) { src := map[string]interface{}{"subject": "no body"} dst := map[string]interface{}{} decodeBodyFields(src, dst) if len(dst) != 0 { t.Fatalf("expected empty dst, got %#v", dst) } } // TestMessageFieldPolicy verifies message field policy. func TestMessageFieldPolicy(t *testing.T) { if !shouldExposeRawMessageField("custom_meta") { t.Fatalf("custom metadata should be auto-passed through") } if shouldExposeRawMessageField("body_plain_text") { t.Fatalf("body_* fields should not be auto-passed through") } if !shouldExposeRawMessageField("head_from") { t.Fatalf("head_from should be auto-passed through") } if shouldExposeRawMessageField("attachments") { t.Fatalf("attachments should be derived, not auto-passed through") } if len(derivedMessageFields) == 0 { t.Fatalf("derivedMessageFields should document derived output fields") } } // TestToForwardSourceAttachments verifies to forward source attachments. func TestToForwardSourceAttachments(t *testing.T) { out := normalizedMessageForCompose{ Attachments: []mailAttachmentOutput{ { ID: "att1", Filename: "report.pdf", ContentType: "application/pdf", DownloadURL: "https://example.com/att1", }, }, } atts := toForwardSourceAttachments(out) if len(atts) != 1 { t.Fatalf("expected 1 attachment, got %d", len(atts)) } if atts[0].Filename != "report.pdf" { t.Fatalf("unexpected filename: %s", atts[0].Filename) } if atts[0].DownloadURL == "" { t.Fatalf("expected download_url to be set") } } // --------------------------------------------------------------------------- // parseInlineSpecs // --------------------------------------------------------------------------- // TestParseInlineSpecs_Empty verifies parse inline specs empty. func TestParseInlineSpecs_Empty(t *testing.T) { specs, err := parseInlineSpecs("") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(specs) != 0 { t.Fatalf("expected empty slice, got %v", specs) } } // TestParseInlineSpecs_Whitespace verifies parse inline specs whitespace. func TestParseInlineSpecs_Whitespace(t *testing.T) { specs, err := parseInlineSpecs(" ") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(specs) != 0 { t.Fatalf("expected empty slice for whitespace input, got %v", specs) } } // TestParseInlineSpecs_Valid verifies parse inline specs valid. func TestParseInlineSpecs_Valid(t *testing.T) { raw := `[{"cid":"YmFubmVyLnBuZw","file_path":"./banner.png"},{"cid":"bG9nby5wbmc","file_path":"/abs/logo.png"}]` specs, err := parseInlineSpecs(raw) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(specs) != 2 { t.Fatalf("expected 2 specs, got %d", len(specs)) } if specs[0].CID != "YmFubmVyLnBuZw" { t.Errorf("specs[0].CID = %q, want YmFubmVyLnBuZw", specs[0].CID) } if specs[0].FilePath != "./banner.png" { t.Errorf("specs[0].FilePath = %q, want ./banner.png", specs[0].FilePath) } if specs[1].CID != "bG9nby5wbmc" { t.Errorf("specs[1].CID = %q, want bG9nby5wbmc", specs[1].CID) } if specs[1].FilePath != "/abs/logo.png" { t.Errorf("specs[1].FilePath = %q, want /abs/logo.png", specs[1].FilePath) } } // TestParseInlineSpecs_InvalidJSON verifies parse inline specs invalid JSON. func TestParseInlineSpecs_InvalidJSON(t *testing.T) { _, err := parseInlineSpecs(`not-json`) if err == nil { t.Fatal("expected error for invalid JSON, got nil") } } // TestParseInlineSpecs_MissingCID verifies parse inline specs missing CID. func TestParseInlineSpecs_MissingCID(t *testing.T) { _, err := parseInlineSpecs(`[{"cid":"","file_path":"./banner.png"}]`) if err == nil { t.Fatal("expected error for empty cid, got nil") } } // TestParseInlineSpecs_MissingFilePath verifies parse inline specs missing file path. func TestParseInlineSpecs_MissingFilePath(t *testing.T) { _, err := parseInlineSpecs(`[{"cid":"YmFubmVyLnBuZw","file_path":""}]`) if err == nil { t.Fatal("expected error for empty file_path, got nil") } } // TestParseInlineSpecs_OldKeyRejected verifies parse inline specs old key rejected. func TestParseInlineSpecs_OldKeyRejected(t *testing.T) { // "file-path" (kebab) must not be recognised — only "file_path" (snake) is valid. // The JSON decoder will silently ignore unknown keys, so file_path stays empty → validation error. _, err := parseInlineSpecs(`[{"cid":"YmFubmVyLnBuZw","file-path":"./banner.png"}]`) if err == nil { t.Fatal("expected error when using old kebab-case key \"file-path\", got nil") } } // --------------------------------------------------------------------------- // inlineSpecFilePaths // --------------------------------------------------------------------------- // TestInlineSpecFilePaths verifies inline spec file paths. func TestInlineSpecFilePaths(t *testing.T) { specs := []InlineSpec{ {CID: "cid1", FilePath: "./a.png"}, {CID: "cid2", FilePath: "/b.jpg"}, } paths := inlineSpecFilePaths(specs) if len(paths) != 2 { t.Fatalf("expected 2 paths, got %d", len(paths)) } if paths[0] != "./a.png" { t.Errorf("paths[0] = %q, want ./a.png", paths[0]) } if paths[1] != "/b.jpg" { t.Errorf("paths[1] = %q, want /b.jpg", paths[1]) } } // TestInlineSpecFilePaths_Nil verifies inline spec file paths nil. func TestInlineSpecFilePaths_Nil(t *testing.T) { if paths := inlineSpecFilePaths(nil); paths != nil { t.Fatalf("expected nil for nil input, got %v", paths) } } // --------------------------------------------------------------------------- // validateForwardAttachmentURLs / validateInlineImageURLs // --------------------------------------------------------------------------- // TestValidateForwardAttachmentURLs_MissingDownloadURL verifies validate forward attachment URLs missing download URL. func TestValidateForwardAttachmentURLs_MissingDownloadURL(t *testing.T) { src := composeSourceMessage{ ForwardAttachments: []forwardSourceAttachment{ {ID: "att1", Filename: "report.pdf", DownloadURL: "https://example.com/att1"}, {ID: "att2", Filename: "budget.xlsx", DownloadURL: ""}, // missing }, } err := validateForwardAttachmentURLs(src) if err == nil { t.Fatal("expected error when attachment download URL is missing, got nil") } if !strings.Contains(err.Error(), "budget.xlsx") { t.Errorf("error should mention missing attachment filename, got: %v", err) } } // TestValidateForwardAttachmentURLs_IgnoresInlineImages verifies validate forward attachment URLs ignores inline images. func TestValidateForwardAttachmentURLs_IgnoresInlineImages(t *testing.T) { src := composeSourceMessage{ ForwardAttachments: []forwardSourceAttachment{ {ID: "att1", Filename: "report.pdf", DownloadURL: "https://example.com/att1"}, }, InlineImages: []inlineSourcePart{ {ID: "img1", Filename: "logo.png", CID: "cid1", DownloadURL: ""}, // missing but should NOT cause error }, } if err := validateForwardAttachmentURLs(src); err != nil { t.Fatalf("inline image missing URL should not affect forward attachment validation: %v", err) } } // TestValidateForwardAttachmentURLs_AllPresent verifies validate forward attachment URLs all present. func TestValidateForwardAttachmentURLs_AllPresent(t *testing.T) { src := composeSourceMessage{ ForwardAttachments: []forwardSourceAttachment{ {ID: "att1", Filename: "report.pdf", DownloadURL: "https://example.com/att1"}, }, InlineImages: []inlineSourcePart{ {ID: "img1", Filename: "logo.png", CID: "cid1", DownloadURL: "https://example.com/img1"}, }, } if err := validateForwardAttachmentURLs(src); err != nil { t.Fatalf("unexpected error: %v", err) } } // TestValidateInlineImageURLs_MissingDownloadURL verifies validate inline image URLs missing download URL. func TestValidateInlineImageURLs_MissingDownloadURL(t *testing.T) { src := composeSourceMessage{ ForwardAttachments: []forwardSourceAttachment{ {ID: "att1", Filename: "report.pdf", DownloadURL: ""}, // missing but should NOT cause error }, InlineImages: []inlineSourcePart{ {ID: "img1", Filename: "banner.png", CID: "cid1", DownloadURL: ""}, // missing }, } err := validateInlineImageURLs(src) if err == nil { t.Fatal("expected error when inline image download URL is missing, got nil") } if !strings.Contains(err.Error(), "banner.png") { t.Errorf("error should mention missing inline image filename, got: %v", err) } } // TestValidateInlineImageURLs_IgnoresAttachments verifies validate inline image URLs ignores attachments. func TestValidateInlineImageURLs_IgnoresAttachments(t *testing.T) { // Inline images are fine; attachments have missing URLs but should NOT be checked. src := composeSourceMessage{ ForwardAttachments: []forwardSourceAttachment{ {ID: "att1", Filename: "report.pdf", DownloadURL: ""}, // missing — irrelevant for this check }, InlineImages: []inlineSourcePart{ {ID: "img1", Filename: "logo.png", CID: "cid1", DownloadURL: "https://example.com/img1"}, }, } if err := validateInlineImageURLs(src); err != nil { t.Fatalf("unexpected error — attachment missing URL should not affect inline-only validation: %v", err) } } // TestToForwardSourceAttachments_PreservesMissingURL verifies to forward source attachments preserves missing URL. func TestToForwardSourceAttachments_PreservesMissingURL(t *testing.T) { out := normalizedMessageForCompose{ Attachments: []mailAttachmentOutput{ {ID: "att1", Filename: "ok.pdf", DownloadURL: "https://example.com/ok"}, {ID: "att2", Filename: "broken.pdf", DownloadURL: ""}, }, } atts := toForwardSourceAttachments(out) if len(atts) != 2 { t.Fatalf("expected 2 attachments (including missing URL), got %d", len(atts)) } } // TestToInlineSourceParts_PreservesMissingURL verifies to inline source parts preserves missing URL. func TestToInlineSourceParts_PreservesMissingURL(t *testing.T) { out := normalizedMessageForCompose{ Images: []mailImageOutput{ {ID: "img1", Filename: "ok.png", CID: "cid1", DownloadURL: "https://example.com/ok"}, {ID: "img2", Filename: "broken.png", CID: "cid2", DownloadURL: ""}, }, } parts := toInlineSourceParts(out) if len(parts) != 2 { t.Fatalf("expected 2 inline parts (including missing URL), got %d", len(parts)) } } // --- downloadAttachmentContent security tests --- // newDownloadRuntime builds a minimal RuntimeContext that uses the given // *http.Client for outbound requests. func newDownloadRuntime(t *testing.T, client *http.Client) *common.RuntimeContext { t.Helper() f := &cmdutil.Factory{ HttpClient: func() (*http.Client, error) { return client, nil }, } rt := common.TestNewRuntimeContextWithCtx(context.Background(), &cobra.Command{}, nil) rt.Factory = f return rt } // TestDownloadAttachmentContent_RejectsHTTP verifies download attachment content rejects h t t p. func TestDownloadAttachmentContent_RejectsHTTP(t *testing.T) { rt := newDownloadRuntime(t, &http.Client{}) _, err := downloadAttachmentContent(rt, "http://evil.example.com/file") if err == nil || !strings.Contains(err.Error(), "https") { t.Errorf("expected https-required error, got: %v", err) } } // TestDownloadAttachmentContent_RejectsFileScheme verifies download attachment content rejects file scheme. func TestDownloadAttachmentContent_RejectsFileScheme(t *testing.T) { rt := newDownloadRuntime(t, &http.Client{}) _, err := downloadAttachmentContent(rt, "file:///etc/passwd") if err == nil || !strings.Contains(err.Error(), "https") { t.Errorf("expected https-required error, got: %v", err) } } // TestDownloadAttachmentContent_RejectsEmptyHost verifies download attachment content rejects empty host. func TestDownloadAttachmentContent_RejectsEmptyHost(t *testing.T) { rt := newDownloadRuntime(t, &http.Client{}) _, err := downloadAttachmentContent(rt, "https:///no-host") if err == nil || !strings.Contains(err.Error(), "host") { t.Errorf("expected no-host error, got: %v", err) } } // TestDownloadAttachmentContent_NoAuthorizationHeader verifies download attachment content no authorization header. func TestDownloadAttachmentContent_NoAuthorizationHeader(t *testing.T) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "" { http.Error(w, "unexpected Authorization header", http.StatusForbidden) return } fmt.Fprint(w, "attachment data") })) defer srv.Close() rt := newDownloadRuntime(t, srv.Client()) data, err := downloadAttachmentContent(rt, srv.URL+"/file?code=presigned") if err != nil { t.Fatalf("unexpected error: %v", err) } if string(data) != "attachment data" { t.Errorf("unexpected content: %q", data) } } // --------------------------------------------------------------------------- // newOutputRuntime — helper for tests that call runtime.Out / runtime.IO() // --------------------------------------------------------------------------- func newOutputRuntime(t *testing.T) (*common.RuntimeContext, *bytes.Buffer, *bytes.Buffer) { t.Helper() stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} f := &cmdutil.Factory{ IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}, } rt := common.TestNewRuntimeContext(&cobra.Command{}, nil) rt.Factory = f return rt, stdout, stderr } // --------------------------------------------------------------------------- // printMessageOutputSchema // --------------------------------------------------------------------------- // TestPrintMessageOutputSchema verifies print message output schema. func TestPrintMessageOutputSchema(t *testing.T) { rt, stdout, _ := newOutputRuntime(t) printMessageOutputSchema(rt) out := stdout.String() // Verify key fields from the schema are present for _, key := range []string{ "body_plain_text", "body_html", "attachments", "head_from", "bcc", "date", "smtp_message_id", "in_reply_to", "references", "internal_date", "message_state", "message_state_text", "folder_id", "label_ids", "priority_type", "priority_type_text", "security_level", "draft_id", "reply_to", "reply_to_smtp_message_id", "body_preview", "thread_id", "message_count", "date_formatted", } { if !strings.Contains(out, key) { t.Errorf("printMessageOutputSchema output missing key %q", key) } } } // --------------------------------------------------------------------------- // printWatchOutputSchema // --------------------------------------------------------------------------- // TestPrintWatchOutputSchema verifies print watch output schema. func TestPrintWatchOutputSchema(t *testing.T) { rt, stdout, _ := newOutputRuntime(t) printWatchOutputSchema(rt) out := stdout.String() for _, key := range []string{ "event", "minimal", "metadata", "plain_text_full", "full", "event_id", "message_id", "body_plain_text", "body_html", "attachments", } { if !strings.Contains(out, key) { t.Errorf("printWatchOutputSchema output missing key %q", key) } } } // --------------------------------------------------------------------------- // hintMarkAsRead — sanitizeForTerminal integration // --------------------------------------------------------------------------- // TestHintMarkAsRead verifies hint mark as read. func TestHintMarkAsRead(t *testing.T) { rt, _, stderr := newOutputRuntime(t) // Inject ANSI escape + message ID to verify sanitization hintMarkAsRead(rt, "me", "msg-\x1b[31m123") out := stderr.String() if strings.Contains(out, "\x1b[") { t.Errorf("hintMarkAsRead should sanitize ANSI escapes, got: %q", out) } if !strings.Contains(out, "msg-123") { t.Errorf("hintMarkAsRead should contain sanitized message ID, got: %q", out) } } // --------------------------------------------------------------------------- // intVal — json.Number // --------------------------------------------------------------------------- // TestIntVal_JsonNumber verifies int val json number. func TestIntVal_JsonNumber(t *testing.T) { n := json.Number("42") got := intVal(n) if got != 42 { t.Errorf("intVal(json.Number(\"42\")) = %d, want 42", got) } } // TestIntVal_JsonNumberInvalid verifies int val json number invalid. func TestIntVal_JsonNumberInvalid(t *testing.T) { n := json.Number("not-a-number") got := intVal(n) if got != 0 { t.Errorf("intVal(json.Number(\"not-a-number\")) = %d, want 0", got) } } // --------------------------------------------------------------------------- // toOriginalMessageForCompose // --------------------------------------------------------------------------- // TestToOriginalMessageForCompose verifies to original message for compose. func TestToOriginalMessageForCompose(t *testing.T) { out := normalizedMessageForCompose{ Subject: "Test Subject\r\nBcc: evil@evil.com", From: mailAddressOutput{Email: "alice@example.com", Name: "Alice"}, To: []mailAddressOutput{{Email: "bob@example.com", Name: "Bob"}}, CC: []mailAddressOutput{{Email: "carol@example.com", Name: "Carol"}}, SMTPMessageID: "Hello
", BodyPlainText: "Hello", InternalDate: "1711111111000", References: []string{"Hello
" { t.Errorf("bodyRaw should prefer HTML, got: %q", orig.bodyRaw) } if orig.headDate == "" { t.Error("headDate should be set from InternalDate") } if orig.references != "no image
`, []string{"orphan-cid"}, nil) if err == nil { t.Fatal("expected orphaned CID error") } if !strings.Contains(err.Error(), "orphan-cid") { t.Fatalf("expected error mentioning orphan-cid, got: %v", err) } } // TestValidateInlineCIDs_SourceOrphanAllowed verifies validate inline c i ds source orphan allowed. func TestValidateInlineCIDs_SourceOrphanAllowed(t *testing.T) { // Source-message CID not referenced in body → allowed (quoting may drop references). err := validateInlineCIDs(`no image
`, nil, []string{"source-unused"}) if err != nil { t.Fatalf("source CID orphan should not error, got: %v", err) } } // TestValidateInlineCIDs_SourceAndUserMixed verifies validate inline c i ds source and user mixed. func TestValidateInlineCIDs_SourceAndUserMixed(t *testing.T) { // Body references both a source CID and a user CID. // Source has an extra unreferenced CID — should not error. html := `plain text
`, nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } } // --------------------------------------------------------------------------- // downloadAttachmentContent — size limit enforcement // --------------------------------------------------------------------------- // TestDownloadAttachmentContent_HTTP4xx verifies download attachment content h t t p4xx. func TestDownloadAttachmentContent_HTTP4xx(t *testing.T) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "not found", http.StatusNotFound) })) defer srv.Close() rt := newDownloadRuntime(t, srv.Client()) _, err := downloadAttachmentContent(rt, srv.URL+"/missing") if err == nil || !strings.Contains(err.Error(), "HTTP 404") { t.Errorf("expected HTTP 404 error, got: %v", err) } } // TestDownloadAttachmentContent_SizeLimit verifies download attachment content size limit. func TestDownloadAttachmentContent_SizeLimit(t *testing.T) { // Return a response that claims to be larger than MaxAttachmentDownloadBytes // We can't actually write 35MB in a test, but we can test the limit logic // by creating a server that returns slightly more than the limit. // For efficiency, just verify the error message pattern with a small payload // and a temporarily reduced limit is not feasible. Instead test the boundary. bigPayload := strings.Repeat("x", MaxAttachmentDownloadBytes+1) srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, bigPayload) })) defer srv.Close() rt := newDownloadRuntime(t, srv.Client()) _, err := downloadAttachmentContent(rt, srv.URL+"/big") if err == nil || !strings.Contains(err.Error(), "size limit") { t.Errorf("expected size limit error, got: %v", err) } } // --------------------------------------------------------------------------- // buildReplyAllRecipients — no-mutation of excluded map (tests the copy fix) // --------------------------------------------------------------------------- // TestBuildReplyAllRecipients_DoesNotMutateExcluded verifies build reply all recipients does not mutate excluded. func TestBuildReplyAllRecipients_DoesNotMutateExcluded(t *testing.T) { excluded := map[string]bool{"blocked@example.com": true} originalLen := len(excluded) buildReplyAllRecipients("alice@example.com", nil, nil, "me@example.com", excluded, false) if len(excluded) != originalLen { t.Errorf("excluded map was mutated: had %d entries, now has %d", originalLen, len(excluded)) } } // --------------------------------------------------------------------------- // addInlineImagesToBuilder — with empty CID skip // --------------------------------------------------------------------------- // TestAddInlineImagesToBuilder_EmptyCIDSkipped verifies add inline images to builder empty CID skipped. func TestAddInlineImagesToBuilder_EmptyCIDSkipped(t *testing.T) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "imagedata") })) defer srv.Close() rt := newDownloadRuntime(t, srv.Client()) bld := emlbuilder.New().TextBody([]byte("test")) images := []inlineSourcePart{ {ID: "img1", Filename: "logo.png", ContentType: "image/png", CID: "", DownloadURL: srv.URL + "/img1"}, } _, _, totalBytes, err := addInlineImagesToBuilder(rt, bld, images) if err != nil { t.Fatalf("unexpected error: %v", err) } if totalBytes != 0 { t.Errorf("expected 0 totalBytes for skipped CID, got %d", totalBytes) } } // TestAddInlineImagesToBuilder_Success verifies add inline images to builder success. func TestAddInlineImagesToBuilder_Success(t *testing.T) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "imagedata") })) defer srv.Close() rt := newDownloadRuntime(t, srv.Client()) bld := emlbuilder.New(). From("Test", "test@example.com"). To("Recipient", "to@example.com"). Subject("test"). HTMLBody([]byte("hello
") if err != nil { t.Fatalf("expected nil, got %v", err) } }) t.Run("attach missing file rejected", func(t *testing.T) { err := validateComposeInlineAndAttachments(fio, "nonexistent.pdf", "", false, "") if err == nil || !strings.Contains(err.Error(), "stat") { t.Fatalf("expected stat error for missing file, got %v", err) } }) t.Run("attach blocked extension rejected", func(t *testing.T) { os.WriteFile("malware.exe", []byte("bad"), 0o644) err := validateComposeInlineAndAttachments(fio, "malware.exe", "", false, "") if err == nil || !strings.Contains(err.Error(), "not allowed") { t.Fatalf("expected blocked extension error, got %v", err) } }) t.Run("attach valid file passes", func(t *testing.T) { os.WriteFile("report.pdf", []byte("pdf content"), 0o644) err := validateComposeInlineAndAttachments(fio, "report.pdf", "", false, "") if err != nil { t.Fatalf("expected nil, got %v", err) } }) t.Run("invalid inline JSON rejected", func(t *testing.T) { err := validateComposeInlineAndAttachments(fio, "", "not-json", false, "") if err == nil { t.Fatal("expected error for invalid inline JSON") } }) } // newRequestReceiptRuntime registers the --request-receipt bool flag alone // (no --from), so requireSenderForRequestReceipt tests can drive the flag // directly without pulling in unrelated compose plumbing. func newRequestReceiptRuntime(t *testing.T, requestReceipt bool) *common.RuntimeContext { t.Helper() cmd := &cobra.Command{Use: "test"} cmd.Flags().Bool("request-receipt", false, "") if requestReceipt { _ = cmd.Flags().Set("request-receipt", "true") } return &common.RuntimeContext{Cmd: cmd} } func TestRequireSenderForRequestReceipt(t *testing.T) { cases := []struct { name string requestReceipt bool senderEmail string wantErr bool }{ {"flag unset, empty sender ok", false, "", false}, {"flag unset, with sender ok", false, "alice@example.com", false}, {"flag set, empty sender errors", true, "", true}, {"flag set, whitespace-only sender errors", true, " ", true}, {"flag set, with sender ok", true, "alice@example.com", false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := requireSenderForRequestReceipt( newRequestReceiptRuntime(t, tc.requestReceipt), tc.senderEmail) if tc.wantErr && err == nil { t.Errorf("expected error, got nil") } if !tc.wantErr && err != nil { t.Errorf("unexpected error: %v", err) } if tc.wantErr && err != nil && !strings.Contains(err.Error(), "--request-receipt") { t.Errorf("error message should mention --request-receipt, got: %v", err) } }) } } func TestShellQuoteForHint(t *testing.T) { cases := []struct { name string in string want string }{ {"plain", "user@example.com", "user@example.com"}, {"with single quote", "O'Brien", `O'\''Brien`}, {"with space", "hello world", "hello world"}, {"mixed", "it's a test", `it'\''s a test`}, {"empty", "", ""}, // The single-line sanitizer must strip embedded newlines so a crafted // mailboxID / messageID can't forge extra lines in a hint. {"with newline stripped", "abc\ndef", "abcdef"}, {"with CR + LF stripped", "abc\r\ndef", "abcdef"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := shellQuoteForHint(tc.in); got != tc.want { t.Errorf("shellQuoteForHint(%q) = %q, want %q", tc.in, got, tc.want) } }) } } func TestSanitizeForSingleLine(t *testing.T) { cases := []struct { name string in string want string }{ {"plain passes through", "alice@example.com", "alice@example.com"}, {"strips LF", "alice@example.com\ntip: forged", "alice@example.comtip: forged"}, {"strips CR+LF", "x\r\ny", "xy"}, {"strips ANSI + LF", "\x1b[31mred\x1b[0m\nnext", "rednext"}, {"keeps tab", "a\tb", "a\tb"}, {"strips bidi override", "a\u202eb", "ab"}, {"empty", "", ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := sanitizeForSingleLine(tc.in); got != tc.want { t.Errorf("sanitizeForSingleLine(%q) = %q, want %q", tc.in, got, tc.want) } }) } } func TestValidateHeaderAddress(t *testing.T) { cases := []struct { name string in string wantErr string // substring expected in error, "" = no error }{ {"plain", "alice@example.com", ""}, {"tab allowed for folded headers", "alice@example.com\tcomment", ""}, {"lf rejected", "alice@example.com\nX-Injected: 1", "control character"}, {"cr rejected", "alice@example.com\rsomething", "control character"}, {"del rejected", "alice@example.com\x7f", "control character"}, {"bidi override rejected", "alice@example.com\u202e", "dangerous Unicode"}, {"zero-width rejected", "ali\u200bce@example.com", "dangerous Unicode"}, {"empty ok", "", ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := validateHeaderAddress(tc.in) if tc.wantErr == "" && err != nil { t.Errorf("expected no error, got %v", err) } if tc.wantErr != "" { if err == nil { t.Errorf("expected error containing %q, got nil", tc.wantErr) } else if !strings.Contains(err.Error(), tc.wantErr) { t.Errorf("expected error containing %q, got %v", tc.wantErr, err) } } }) } } // --------------------------------------------------------------------------- // parseEventTimeRange // --------------------------------------------------------------------------- func TestParseEventTimeRange_OK(t *testing.T) { s, e, err := parseEventTimeRange("2026-04-25T14:00+08:00", "2026-04-25T15:00+08:00") if err != nil { t.Fatalf("unexpected error: %v", err) } if !e.After(s) { t.Errorf("end should be after start; got start=%v end=%v", s, e) } } func TestParseEventTimeRange_EndBeforeStart(t *testing.T) { _, _, err := parseEventTimeRange("2026-04-25T15:00+08:00", "2026-04-25T14:00+08:00") if err == nil { t.Fatal("expected error when end < start") } if !strings.Contains(err.Error(), "end time must be after start time") { t.Errorf("unexpected error message: %v", err) } } func TestParseEventTimeRange_EndEqualsStart(t *testing.T) { _, _, err := parseEventTimeRange("2026-04-25T14:00+08:00", "2026-04-25T14:00+08:00") if err == nil { t.Fatal("expected error when end == start (zero duration)") } } func TestParseEventTimeRange_InvalidStart(t *testing.T) { _, _, err := parseEventTimeRange("not-a-time", "2026-04-25T15:00+08:00") if err == nil || !strings.Contains(err.Error(), "start: invalid ISO 8601") { t.Errorf("expected start parse error, got: %v", err) } } func TestParseEventTimeRange_InvalidEnd(t *testing.T) { _, _, err := parseEventTimeRange("2026-04-25T14:00+08:00", "not-a-time") if err == nil || !strings.Contains(err.Error(), "end: invalid ISO 8601") { t.Errorf("expected end parse error, got: %v", err) } } func TestPrefixEventRangeError(t *testing.T) { start := fmt.Errorf("start: invalid ISO 8601 time %q", "x") if got := prefixEventRangeError("--event-", start).Error(); got != `--event-start: invalid ISO 8601 time "x"` { t.Errorf("got %q", got) } end := fmt.Errorf("end: invalid ISO 8601 time %q", "x") if got := prefixEventRangeError("--set-event-", end).Error(); got != `--set-event-end: invalid ISO 8601 time "x"` { t.Errorf("got %q", got) } // Non-prefixed error passes through unchanged. other := fmt.Errorf("end time must be after start time") if got := prefixEventRangeError("--event-", other).Error(); got != "end time must be after start time" { t.Errorf("got %q", got) } } // --------------------------------------------------------------------------- // validateEventFlags (runtime-backed) // --------------------------------------------------------------------------- func newEventFlagsRuntime(t *testing.T, summary, start, end string) *common.RuntimeContext { t.Helper() cmd := &cobra.Command{Use: "test"} cmd.Flags().String("event-summary", "", "") cmd.Flags().String("event-start", "", "") cmd.Flags().String("event-end", "", "") cmd.Flags().String("event-location", "", "") if summary != "" { _ = cmd.Flags().Set("event-summary", summary) } if start != "" { _ = cmd.Flags().Set("event-start", start) } if end != "" { _ = cmd.Flags().Set("event-end", end) } return &common.RuntimeContext{Cmd: cmd} } func TestValidateEventFlags_AllEmptyOK(t *testing.T) { rt := newEventFlagsRuntime(t, "", "", "") if err := validateEventFlags(rt); err != nil { t.Errorf("expected no error, got %v", err) } } func TestValidateEventFlags_AllSetOK(t *testing.T) { rt := newEventFlagsRuntime(t, "Meeting", "2026-04-25T10:00+08:00", "2026-04-25T11:00+08:00") if err := validateEventFlags(rt); err != nil { t.Errorf("expected no error, got %v", err) } } func TestValidateEventFlags_PartialRejected(t *testing.T) { cases := []struct { name string summary string start string end string }{ {"only_summary", "Meeting", "", ""}, {"only_start", "", "2026-04-25T10:00+08:00", ""}, {"only_end", "", "", "2026-04-25T11:00+08:00"}, {"missing_end", "Meeting", "2026-04-25T10:00+08:00", ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { rt := newEventFlagsRuntime(t, tc.summary, tc.start, tc.end) err := validateEventFlags(rt) if err == nil || !strings.Contains(err.Error(), "must all be provided together") { t.Errorf("expected 'all together' error, got %v", err) } }) } } func TestValidateEventFlags_EndBeforeStartRejected(t *testing.T) { rt := newEventFlagsRuntime(t, "Meeting", "2026-04-25T11:00+08:00", "2026-04-25T10:00+08:00") err := validateEventFlags(rt) if err == nil || !strings.Contains(err.Error(), "after start") { t.Errorf("expected end-after-start error, got %v", err) } } func TestValidateEventFlags_InvalidTimeFormatRejected(t *testing.T) { rt := newEventFlagsRuntime(t, "Meeting", "not-a-time", "2026-04-25T11:00+08:00") err := validateEventFlags(rt) if err == nil || !strings.Contains(err.Error(), "--event-start") { t.Errorf("expected --event-start error, got %v", err) } } // --------------------------------------------------------------------------- // buildCalendarBodyFromArgs // --------------------------------------------------------------------------- func TestBuildCalendarBodyFromArgs_EmptySummaryReturnsNil(t *testing.T) { got := buildCalendarBodyFromArgs("", "2026-04-25T10:00+08:00", "2026-04-25T11:00+08:00", "", "sender@example.com", "to@example.com", "") if got != nil { t.Errorf("expected nil for empty summary, got %d bytes", len(got)) } } func TestBuildCalendarBodyFromArgs_IncludesSummaryAndAddresses(t *testing.T) { got := buildCalendarBodyFromArgs( "Product Review", "2026-04-25T14:00+08:00", "2026-04-25T15:00+08:00", "5F Room", "sender@example.com", "a@example.com,b@example.com", "c@example.com", ) if got == nil { t.Fatal("expected non-nil ICS bytes") } s := string(got) checks := []string{ "BEGIN:VCALENDAR", "SUMMARY:Product Review", "LOCATION:5F Room", "sender@example.com", "a@example.com", "b@example.com", "c@example.com", } for _, want := range checks { if !strings.Contains(s, want) { t.Errorf("missing %q in generated ICS:\n%s", want, s) } } } func TestBuildCalendarBodyFromArgs_NoCcWorks(t *testing.T) { got := buildCalendarBodyFromArgs( "Meeting", "2026-04-25T10:00+08:00", "2026-04-25T11:00+08:00", "", "sender@example.com", "to@example.com", "", ) if got == nil { t.Fatal("expected non-nil ICS bytes") } if !strings.Contains(string(got), "to@example.com") { t.Error("attendee missing") } }