mirror of
https://github.com/larksuite/cli.git
synced 2026-07-05 15:47:54 +08:00
* feat(ics): add RFC 5545 iCalendar generator and parser Add shortcuts/mail/ics package: - builder.go: generates METHOD:REQUEST ICS with VEVENT, ORGANIZER, ATTENDEE, DTSTART/DTEND with timezone, UID, and X-LARK-MAIL-DRAFT - parser.go: parses ICS into ParsedEvent struct, detects IsLarkDraft - Handles CN quoting, control-char sanitization, email validation, line folding per RFC 5545, and TZID edge cases Change-Id: I01d13285a57a5a4de50891c54d655efa8423c3c1 * feat(mail): support calendar events in emails - Add --event-summary/start/end/location flags to +send, +reply, +reply-all, +forward, +draft-create - Build ICS and embed as text/calendar in multipart/alternative - Validate event time range and enforce --event/--send-time mutual exclusion (extracted into validateEventSendTimeExclusion) - CalendarBody() in emlbuilder places ICS correctly - Exclude BCC from ATTENDEE list Change-Id: Icf9e49ababebc4e8fcf36760ab613c64938c2744 * feat(mail): X-LARK-MAIL-DRAFT and read-only calendar guard - ics.Build() writes X-LARK-MAIL-DRAFT:TRUE so Feishu client recognizes CLI-created calendar events as editable - ics.ParseEvent() detects IsLarkDraft field - +draft-edit rejects --set-event-* on calendars without X-LARK-MAIL-DRAFT marker (read-only after send) - Export FindPartByMediaType from draft package for cross-package use - Add set_calendar/remove_calendar patch ops with full test coverage Change-Id: I7d547a4b40880e8d4ee3fecf68864d6ea89e66cd * feat(mail): forward preserves original calendar ICS When forwarding an email that contains a calendar event (body_calendar), pass through the original ICS bytes as text/calendar part if no new --event-* flags are specified. Change-Id: I67d2e82604eaf969cee8c7e0bedcf32198d12d57 * docs(mail): document calendar invitation feature - Add --event-* params to +send, +reply, +reply-all, +forward, +draft-create, +draft-edit reference docs - Add calendar_event output section to +message reference - Add calendar invitation workflow to skill-template/domains/mail.md - Regenerate SKILL.md via gen-skills Change-Id: Iccacd06990d91e1cf3beb896d5b772d27e5e29ff * fix(mail): reject --set-event-start/end/location without --set-event-summary Change-Id: Icb651ff28ede526ff96b22e7b304b7bdea86d01f Co-Authored-By: AI * fix(mail): include --event-location in validateEventFlags; fix stale comment Change-Id: I2f47016b6bfa11957dfe2c8c499cf36737efba53 Co-Authored-By: AI * fix(mail): clear stale headers when wrapping single-leaf body in multipart/alternative Change-Id: I29fe883c9151570f7939d372523b128cbea0b1ed Co-Authored-By: AI * fix(mail): add method=REQUEST to text/calendar MIME part created by set_calendar Change-Id: I4d23674e20e4c42adab36385ff5ee8bb6d97625d Co-Authored-By: AI * fix(mail): use post-edit recipients for ICS attendees when --set-to combined with --set-event-* Change-Id: I659e06635dd043f798d2f2e90d7dbca6e13d7f3d Co-Authored-By: AI * fix(mail): cover add_recipient/remove_recipient in ICS attendee resolution Extract effectiveRecipients() to replay all three recipient op types (set_recipients, add_recipient, remove_recipient) before building the ICS for set_calendar, so patch-file recipient changes are reflected in ATTENDEE metadata. Change-Id: I3a7a55f96df8fac7d924a4dbeedd5b3d0d9d443c Co-Authored-By: AI * fix(mail): derive method= from ICS body in writeCalendarPart instead of hardcoding REQUEST Passthrough ICS (e.g. forwarded METHOD:CANCEL) previously emitted a Content-Type with method=REQUEST, disagreeing with the body. Now extractICSMethod() scans the ICS for METHOD: and falls back to REQUEST when absent, keeping existing behavior for our own generated ICS. Change-Id: I4bf6c3755a189a436c2d26b082372d9f838f4051 Co-Authored-By: AI * fix(mail): normalize calendar_event start/end to UTC in output Callers expect RFC 3339 UTC strings; source ICS with TZID offsets previously emitted +08:00 instead of Z. Change-Id: I88bd4b925f8fc3b4f569e41712ae58ab50d94a2f Co-Authored-By: AI * fix(mail): make ICS parser case-insensitive and handle parameterized property names RFC 5545 §3.1 allows any case and optional parameters on all property names. Unify UID/SUMMARY/LOCATION/DTSTART/etc. to compare via strings.ToUpper(name) and add HasPrefix checks for the NAME; form, consistent with how ORGANIZER and ATTENDEE were already handled. Change-Id: I7dc642dd210a3256f2189a901a2d9518ea284815 Co-Authored-By: AI
1674 lines
58 KiB
Go
1674 lines
58 KiB
Go
// 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("<p>Hello</p>"))
|
|
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"] != "<p>Hello</p>" {
|
|
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: "<msg-1@example.com>",
|
|
ThreadID: "thread-1",
|
|
BodyHTML: "<p>Hello</p>",
|
|
BodyPlainText: "Hello",
|
|
InternalDate: "1711111111000",
|
|
References: []string{"<ref-1@example.com>"},
|
|
ReplyTo: "replyto@example.com",
|
|
}
|
|
|
|
orig := toOriginalMessageForCompose(out)
|
|
|
|
// Subject injection should be stripped
|
|
if strings.Contains(orig.subject, "\r") || strings.Contains(orig.subject, "\n") {
|
|
t.Errorf("subject should have CR/LF stripped, got: %q", orig.subject)
|
|
}
|
|
if !strings.Contains(orig.subject, "Test Subject") {
|
|
t.Errorf("subject should still contain original text, got: %q", orig.subject)
|
|
}
|
|
|
|
if orig.headFrom != "alice@example.com" {
|
|
t.Errorf("headFrom = %q, want alice@example.com", orig.headFrom)
|
|
}
|
|
if orig.headFromName != "Alice" {
|
|
t.Errorf("headFromName = %q, want Alice", orig.headFromName)
|
|
}
|
|
if orig.headTo != "bob@example.com" {
|
|
t.Errorf("headTo = %q, want bob@example.com", orig.headTo)
|
|
}
|
|
if orig.replyTo != "replyto@example.com" {
|
|
t.Errorf("replyTo = %q, want replyto@example.com", orig.replyTo)
|
|
}
|
|
if orig.smtpMessageId != "<msg-1@example.com>" {
|
|
t.Errorf("smtpMessageId = %q", orig.smtpMessageId)
|
|
}
|
|
if orig.threadId != "thread-1" {
|
|
t.Errorf("threadId = %q", orig.threadId)
|
|
}
|
|
if orig.bodyRaw != "<p>Hello</p>" {
|
|
t.Errorf("bodyRaw should prefer HTML, got: %q", orig.bodyRaw)
|
|
}
|
|
if orig.headDate == "" {
|
|
t.Error("headDate should be set from InternalDate")
|
|
}
|
|
if orig.references != "<ref-1@example.com>" {
|
|
t.Errorf("references = %q", orig.references)
|
|
}
|
|
if len(orig.toAddresses) != 1 || orig.toAddresses[0] != "bob@example.com" {
|
|
t.Errorf("toAddresses = %v", orig.toAddresses)
|
|
}
|
|
if len(orig.ccAddresses) != 1 || orig.ccAddresses[0] != "carol@example.com" {
|
|
t.Errorf("ccAddresses = %v", orig.ccAddresses)
|
|
}
|
|
if len(orig.toAddressesFull) != 1 {
|
|
t.Errorf("toAddressesFull = %v", orig.toAddressesFull)
|
|
}
|
|
if len(orig.ccAddressesFull) != 1 {
|
|
t.Errorf("ccAddressesFull = %v", orig.ccAddressesFull)
|
|
}
|
|
}
|
|
|
|
// TestToOriginalMessageForCompose_NoHTML verifies to original message for compose no HTML.
|
|
func TestToOriginalMessageForCompose_NoHTML(t *testing.T) {
|
|
out := normalizedMessageForCompose{
|
|
Subject: "Plain email",
|
|
From: mailAddressOutput{Email: "alice@example.com"},
|
|
BodyPlainText: "Just plain text",
|
|
}
|
|
orig := toOriginalMessageForCompose(out)
|
|
if orig.bodyRaw != "Just plain text" {
|
|
t.Errorf("bodyRaw should fall back to plaintext, got: %q", orig.bodyRaw)
|
|
}
|
|
if orig.headTo != "" {
|
|
t.Errorf("headTo should be empty when To list is empty, got: %q", orig.headTo)
|
|
}
|
|
}
|
|
|
|
// TestToOriginalMessageForCompose_EmptyReferences verifies to original message for compose empty references.
|
|
func TestToOriginalMessageForCompose_EmptyReferences(t *testing.T) {
|
|
out := normalizedMessageForCompose{
|
|
From: mailAddressOutput{Email: "alice@example.com"},
|
|
References: nil,
|
|
}
|
|
orig := toOriginalMessageForCompose(out)
|
|
if orig.references != "" {
|
|
t.Errorf("references should be empty, got: %q", orig.references)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// validateInlineCIDs — bidirectional CID consistency
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestValidateInlineCIDs_UserOrphanError verifies validate inline c i ds user orphan error.
|
|
func TestValidateInlineCIDs_UserOrphanError(t *testing.T) {
|
|
// User-provided CID not referenced in body → error.
|
|
err := validateInlineCIDs(`<p>no image</p>`, []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(`<p>no image</p>`, 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 := `<p><img src="cid:src-used" /><img src="cid:user-img" /></p>`
|
|
err := validateInlineCIDs(html, []string{"user-img"}, []string{"src-used", "src-unused"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestValidateInlineCIDs_MissingRefError verifies validate inline c i ds missing ref error.
|
|
func TestValidateInlineCIDs_MissingRefError(t *testing.T) {
|
|
// Body references a CID that nobody provided → error.
|
|
html := `<p><img src="cid:exists" /><img src="cid:missing" /></p>`
|
|
err := validateInlineCIDs(html, []string{"exists"}, nil)
|
|
if err == nil {
|
|
t.Fatal("expected missing CID error")
|
|
}
|
|
if !strings.Contains(err.Error(), "missing") {
|
|
t.Fatalf("expected error mentioning missing, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestValidateInlineCIDs_MissingRefSatisfiedBySource verifies validate inline c i ds missing ref satisfied by source.
|
|
func TestValidateInlineCIDs_MissingRefSatisfiedBySource(t *testing.T) {
|
|
// Body references a CID that only exists in source (extraCIDs) → ok.
|
|
html := `<p><img src="cid:from-source" /></p>`
|
|
err := validateInlineCIDs(html, nil, []string{"from-source"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestValidateInlineCIDs_NoCIDsNoError verifies validate inline c i ds no c i ds no error.
|
|
func TestValidateInlineCIDs_NoCIDsNoError(t *testing.T) {
|
|
err := validateInlineCIDs(`<p>plain text</p>`, 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("<img src='cid:banner'>"))
|
|
images := []inlineSourcePart{
|
|
{ID: "img1", Filename: "banner.png", ContentType: "image/png", CID: "cid:banner", DownloadURL: srv.URL + "/img1"},
|
|
}
|
|
result, _, totalBytes, err := addInlineImagesToBuilder(rt, bld, images)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if totalBytes != int64(len("imagedata")) {
|
|
t.Errorf("expected totalBytes=%d, got %d", len("imagedata"), totalBytes)
|
|
}
|
|
raw, err := result.BuildBase64URL()
|
|
if err != nil {
|
|
t.Fatalf("failed to build EML: %v", err)
|
|
}
|
|
if raw == "" {
|
|
t.Error("expected non-empty EML output")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// normalizeInlineCID
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestNormalizeInlineCID verifies normalize inline CID.
|
|
func TestNormalizeInlineCID(t *testing.T) {
|
|
tests := []struct {
|
|
input, want string
|
|
}{
|
|
{"cid:banner", "banner"},
|
|
{"CID:banner", "banner"},
|
|
{"<banner>", "banner"},
|
|
{"cid:<banner>", "banner"},
|
|
{" cid:<banner> ", "banner"},
|
|
{"plain", "plain"},
|
|
{"", ""},
|
|
}
|
|
for _, tt := range tests {
|
|
got := normalizeInlineCID(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("normalizeInlineCID(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestResolveComposeMailboxID verifies resolve compose mailbox ID.
|
|
func TestResolveComposeMailboxID(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mailbox string
|
|
from string
|
|
want string
|
|
}{
|
|
{"default", "", "", "me"},
|
|
{"explicit from", "", "shared@example.com", "shared@example.com"},
|
|
{"explicit mailbox", "owner@example.com", "", "owner@example.com"},
|
|
{"mailbox takes priority over from", "owner@example.com", "alias@example.com", "owner@example.com"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cmd := &cobra.Command{Use: "test"}
|
|
cmd.Flags().String("from", "", "")
|
|
cmd.Flags().String("mailbox", "", "")
|
|
if tt.from != "" {
|
|
_ = cmd.Flags().Set("from", tt.from)
|
|
}
|
|
if tt.mailbox != "" {
|
|
_ = cmd.Flags().Set("mailbox", tt.mailbox)
|
|
}
|
|
rt := &common.RuntimeContext{Cmd: cmd}
|
|
if got := resolveComposeMailboxID(rt); got != tt.want {
|
|
t.Errorf("resolveComposeMailboxID() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestResolveComposeSenderEmail verifies resolve compose sender email.
|
|
func TestResolveComposeSenderEmail(t *testing.T) {
|
|
// Note: the "no flags" case falls through to fetchMailboxPrimaryEmail which
|
|
// requires an API client. That path is covered by integration/shortcut tests.
|
|
// Here we test the flag-based short-circuit paths only.
|
|
// Note: "mailbox=me without from" falls through to fetchMailboxPrimaryEmail
|
|
// (same as "no flags"), which requires an API client — covered by
|
|
// integration/shortcut tests.
|
|
tests := []struct {
|
|
name string
|
|
mailbox string
|
|
from string
|
|
want string
|
|
}{
|
|
{"from only", "", "alias@example.com", "alias@example.com"},
|
|
{"mailbox only", "shared@example.com", "", "shared@example.com"},
|
|
{"from takes priority over mailbox", "shared@example.com", "alias@example.com", "alias@example.com"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cmd := &cobra.Command{Use: "test"}
|
|
cmd.Flags().String("from", "", "")
|
|
cmd.Flags().String("mailbox", "", "")
|
|
if tt.from != "" {
|
|
_ = cmd.Flags().Set("from", tt.from)
|
|
}
|
|
if tt.mailbox != "" {
|
|
_ = cmd.Flags().Set("mailbox", tt.mailbox)
|
|
}
|
|
rt := &common.RuntimeContext{Cmd: cmd}
|
|
got := resolveComposeSenderEmail(rt)
|
|
if got != tt.want {
|
|
t.Errorf("resolveComposeSenderEmail() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestParseNetAddrs_Dedup verifies parse net addrs dedup.
|
|
func TestParseNetAddrs_Dedup(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want []string // expected email addresses in order
|
|
}{
|
|
{"no duplicates", "a@x.com, b@x.com", []string{"a@x.com", "b@x.com"}},
|
|
{"exact duplicate", "a@x.com, a@x.com", []string{"a@x.com"}},
|
|
{"case-insensitive duplicate", "Alice@X.COM, alice@x.com", []string{"Alice@X.COM"}},
|
|
{"mixed with names", "Alice <a@x.com>, Bob <b@x.com>, a@x.com", []string{"a@x.com", "b@x.com"}},
|
|
{"triple duplicate", "a@x.com, a@x.com, a@x.com", []string{"a@x.com"}},
|
|
{"empty", "", nil},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := parseNetAddrs(tt.input)
|
|
if len(got) != len(tt.want) {
|
|
t.Fatalf("parseNetAddrs(%q) returned %d addrs, want %d: %v", tt.input, len(got), len(tt.want), got)
|
|
}
|
|
for i, addr := range got {
|
|
if addr.Address != tt.want[i] {
|
|
t.Errorf("parseNetAddrs(%q)[%d].Address = %q, want %q", tt.input, i, addr.Address, tt.want[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// Verify dedup is per-field only, NOT cross-field: separate calls must
|
|
// maintain independent seen sets so the same address can appear in both
|
|
// To and CC.
|
|
t.Run("no cross-field dedup", func(t *testing.T) {
|
|
shared := "overlap@x.com"
|
|
toAddrs := parseNetAddrs(shared)
|
|
ccAddrs := parseNetAddrs(shared + ", other@x.com")
|
|
if len(toAddrs) != 1 || toAddrs[0].Address != shared {
|
|
t.Fatalf("to: got %v", toAddrs)
|
|
}
|
|
if len(ccAddrs) != 2 {
|
|
t.Fatalf("cc should have 2 addrs (no cross-field dedup), got %v", ccAddrs)
|
|
}
|
|
if ccAddrs[0].Address != shared {
|
|
t.Errorf("cc[0] = %q, want %q", ccAddrs[0].Address, shared)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// validateRecipientCount
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestValidateRecipientCount verifies validate recipient count.
|
|
func TestValidateRecipientCount(t *testing.T) {
|
|
t.Run("under limit", func(t *testing.T) {
|
|
err := validateRecipientCount("a@x.com, b@x.com", "c@x.com", "d@x.com")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("empty fields", func(t *testing.T) {
|
|
err := validateRecipientCount("", "", "")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error for empty fields: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("exactly at limit", func(t *testing.T) {
|
|
// Build a list of exactly MaxRecipientCount addresses
|
|
addrs := make([]string, MaxRecipientCount)
|
|
for i := range addrs {
|
|
addrs[i] = fmt.Sprintf("user%d@example.com", i)
|
|
}
|
|
all := strings.Join(addrs, ",")
|
|
err := validateRecipientCount(all, "", "")
|
|
if err != nil {
|
|
t.Fatalf("should accept exactly %d recipients, got error: %v", MaxRecipientCount, err)
|
|
}
|
|
})
|
|
|
|
t.Run("exceeds limit", func(t *testing.T) {
|
|
addrs := make([]string, MaxRecipientCount+1)
|
|
for i := range addrs {
|
|
addrs[i] = fmt.Sprintf("user%d@example.com", i)
|
|
}
|
|
all := strings.Join(addrs, ",")
|
|
err := validateRecipientCount(all, "", "")
|
|
if err == nil {
|
|
t.Fatal("expected error for exceeding recipient limit")
|
|
}
|
|
if !strings.Contains(err.Error(), "exceeds the limit") {
|
|
t.Fatalf("unexpected error message: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("combined across fields", func(t *testing.T) {
|
|
// Split across To, CC, BCC to exceed limit
|
|
half := MaxRecipientCount / 2
|
|
toAddrs := make([]string, half)
|
|
for i := range toAddrs {
|
|
toAddrs[i] = fmt.Sprintf("to%d@example.com", i)
|
|
}
|
|
ccAddrs := make([]string, half)
|
|
for i := range ccAddrs {
|
|
ccAddrs[i] = fmt.Sprintf("cc%d@example.com", i)
|
|
}
|
|
// This puts us at MaxRecipientCount, add 1 BCC to exceed
|
|
err := validateRecipientCount(
|
|
strings.Join(toAddrs, ","),
|
|
strings.Join(ccAddrs, ","),
|
|
"bcc-extra@example.com",
|
|
)
|
|
if err == nil {
|
|
t.Fatal("expected error when To+CC+BCC exceeds limit")
|
|
}
|
|
})
|
|
|
|
t.Run("deduplication within field", func(t *testing.T) {
|
|
// ParseMailboxList deduplicates, so duplicates should not inflate count
|
|
err := validateRecipientCount("a@x.com, a@x.com, a@x.com", "", "")
|
|
if err != nil {
|
|
t.Fatalf("duplicates should be deduplicated, got error: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestValidateComposeHasAtLeastOneRecipient_AlsoChecksCount verifies validate compose has at least one recipient also checks count.
|
|
func TestValidateComposeHasAtLeastOneRecipient_AlsoChecksCount(t *testing.T) {
|
|
// Verify that validateComposeHasAtLeastOneRecipient also enforces the count limit
|
|
addrs := make([]string, MaxRecipientCount+1)
|
|
for i := range addrs {
|
|
addrs[i] = fmt.Sprintf("user%d@example.com", i)
|
|
}
|
|
all := strings.Join(addrs, ",")
|
|
err := validateComposeHasAtLeastOneRecipient(all, "", "")
|
|
if err == nil {
|
|
t.Fatal("expected error for exceeding recipient limit via validateComposeHasAtLeastOneRecipient")
|
|
}
|
|
if !strings.Contains(err.Error(), "exceeds the limit") {
|
|
t.Fatalf("unexpected error message: %v", err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// validateSendTime
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func newSendTimeRuntime(t *testing.T, sendTime string, confirmSend bool) *common.RuntimeContext {
|
|
t.Helper()
|
|
cmd := &cobra.Command{Use: "test"}
|
|
cmd.Flags().String("send-time", "", "")
|
|
cmd.Flags().Bool("confirm-send", false, "")
|
|
if sendTime != "" {
|
|
_ = cmd.Flags().Set("send-time", sendTime)
|
|
}
|
|
if confirmSend {
|
|
_ = cmd.Flags().Set("confirm-send", "true")
|
|
}
|
|
return &common.RuntimeContext{Cmd: cmd}
|
|
}
|
|
|
|
// TestValidateSendTime_Empty verifies validate send time empty.
|
|
func TestValidateSendTime_Empty(t *testing.T) {
|
|
rt := newSendTimeRuntime(t, "", false)
|
|
if err := validateSendTime(rt); err != nil {
|
|
t.Fatalf("expected nil when send-time is empty, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestValidateSendTime_RequiresConfirmSend verifies validate send time requires confirm send.
|
|
func TestValidateSendTime_RequiresConfirmSend(t *testing.T) {
|
|
future := strconv.FormatInt(time.Now().Unix()+10*60, 10)
|
|
rt := newSendTimeRuntime(t, future, false)
|
|
err := validateSendTime(rt)
|
|
if err == nil {
|
|
t.Fatal("expected error when --send-time is set without --confirm-send")
|
|
}
|
|
if !strings.Contains(err.Error(), "--confirm-send") {
|
|
t.Errorf("expected error to mention --confirm-send, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestValidateSendTime_InvalidInteger verifies validate send time invalid integer.
|
|
func TestValidateSendTime_InvalidInteger(t *testing.T) {
|
|
rt := newSendTimeRuntime(t, "not-a-number", true)
|
|
err := validateSendTime(rt)
|
|
if err == nil {
|
|
t.Fatal("expected error when --send-time is not a valid integer")
|
|
}
|
|
if !strings.Contains(err.Error(), "Unix timestamp") {
|
|
t.Errorf("expected error to mention Unix timestamp, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestValidateSendTime_TooSoon verifies validate send time too soon.
|
|
func TestValidateSendTime_TooSoon(t *testing.T) {
|
|
// Just 1 minute in the future — below the 5-minute minimum.
|
|
soon := strconv.FormatInt(time.Now().Unix()+60, 10)
|
|
rt := newSendTimeRuntime(t, soon, true)
|
|
err := validateSendTime(rt)
|
|
if err == nil {
|
|
t.Fatal("expected error when --send-time is less than 5 minutes in the future")
|
|
}
|
|
if !strings.Contains(err.Error(), "5 minutes") {
|
|
t.Errorf("expected error to mention 5 minute minimum, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestValidateSendTime_Valid verifies validate send time valid.
|
|
func TestValidateSendTime_Valid(t *testing.T) {
|
|
future := strconv.FormatInt(time.Now().Unix()+10*60, 10)
|
|
rt := newSendTimeRuntime(t, future, true)
|
|
if err := validateSendTime(rt); err != nil {
|
|
t.Fatalf("expected nil for valid future send-time, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateEventSendTimeExclusion(t *testing.T) {
|
|
future := strconv.FormatInt(time.Now().Unix()+10*60, 10)
|
|
cases := []struct {
|
|
name string
|
|
eventFlag string
|
|
eventVal string
|
|
}{
|
|
{"event-summary triggers exclusion", "event-summary", "Team meeting"},
|
|
{"event-start triggers exclusion", "event-start", "2026-05-01T10:00+08:00"},
|
|
{"event-end triggers exclusion", "event-end", "2026-05-01T11:00+08:00"},
|
|
{"event-location triggers exclusion", "event-location", "Room 5F"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cmd := &cobra.Command{Use: "test"}
|
|
cmd.Flags().String("send-time", "", "")
|
|
for _, f := range []string{"event-summary", "event-start", "event-end", "event-location"} {
|
|
cmd.Flags().String(f, "", "")
|
|
}
|
|
_ = cmd.Flags().Set("send-time", future)
|
|
_ = cmd.Flags().Set(tc.eventFlag, tc.eventVal)
|
|
rt := &common.RuntimeContext{Cmd: cmd}
|
|
err := validateEventSendTimeExclusion(rt)
|
|
if err == nil {
|
|
t.Fatalf("expected error when --send-time and --%s are both set", tc.eventFlag)
|
|
}
|
|
if !strings.Contains(err.Error(), "--event-*") {
|
|
t.Errorf("expected error to mention --event-*, got: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParsePriority(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
wantErr bool
|
|
}{
|
|
{"empty", "", "", false},
|
|
{"high", "high", "1", false},
|
|
{"normal", "normal", "", false},
|
|
{"low", "low", "5", false},
|
|
{"case-insensitive HIGH", "HIGH", "1", false},
|
|
{"whitespace padding", " low ", "5", false},
|
|
{"invalid", "urgent", "", true},
|
|
{"numeric not accepted", "1", "", true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := parsePriority(tc.input)
|
|
if tc.wantErr {
|
|
if err == nil {
|
|
t.Fatalf("parsePriority(%q): expected error, got nil", tc.input)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("parsePriority(%q): unexpected error: %v", tc.input, err)
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("parsePriority(%q) = %q, want %q", tc.input, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBuildMessageOutput_PriorityFromLabels verifies build message output priority from labels.
|
|
func TestBuildMessageOutput_PriorityFromLabels(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
labels []interface{}
|
|
priorityType string
|
|
wantType string
|
|
wantText string
|
|
}{
|
|
{"high from label", []interface{}{"UNREAD", "HIGH_PRIORITY"}, "", "1", "high"},
|
|
{"low from label", []interface{}{"LOW_PRIORITY"}, "", "5", "low"},
|
|
{"no priority label", []interface{}{"UNREAD"}, "", "", ""},
|
|
{"label overrides priority_type field", []interface{}{"HIGH_PRIORITY"}, "5", "1", "high"},
|
|
{"priority_type fallback when no label", []interface{}{"UNREAD"}, "1", "1", "high"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
msg := map[string]interface{}{
|
|
"message_id": "m1",
|
|
"label_ids": tc.labels,
|
|
}
|
|
if tc.priorityType != "" {
|
|
msg["priority_type"] = tc.priorityType
|
|
}
|
|
out := buildMessageOutput(msg, false)
|
|
gotText, _ := out["priority_type_text"].(string)
|
|
if gotText != tc.wantText {
|
|
t.Errorf("priority_type_text = %q, want %q", gotText, tc.wantText)
|
|
}
|
|
gotType, _ := out["priority_type"].(string)
|
|
if gotType != tc.wantType {
|
|
t.Errorf("priority_type = %q, want %q", gotType, tc.wantType)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestApplyPriority verifies apply priority.
|
|
func TestApplyPriority(t *testing.T) {
|
|
// Empty priority: EML must not contain X-Cli-Priority header.
|
|
emptyBld := emlbuilder.New().
|
|
From("", "sender@example.com").
|
|
To("", "recipient@example.com").
|
|
Subject("no priority").
|
|
TextBody([]byte("body"))
|
|
emptyBld = applyPriority(emptyBld, "")
|
|
raw, err := emptyBld.BuildBase64URL()
|
|
if err != nil {
|
|
t.Fatalf("build EML failed: %v", err)
|
|
}
|
|
eml := decodeBase64URL(raw)
|
|
if strings.Contains(eml, "X-Cli-Priority") {
|
|
t.Errorf("expected no X-Cli-Priority header when priority is empty, got EML:\n%s", eml)
|
|
}
|
|
|
|
// Non-empty priority: header must be present with the exact value.
|
|
highBld := emlbuilder.New().
|
|
From("", "sender@example.com").
|
|
To("", "recipient@example.com").
|
|
Subject("high priority").
|
|
TextBody([]byte("body"))
|
|
highBld = applyPriority(highBld, "1")
|
|
raw, err = highBld.BuildBase64URL()
|
|
if err != nil {
|
|
t.Fatalf("build EML failed: %v", err)
|
|
}
|
|
eml = decodeBase64URL(raw)
|
|
if !strings.Contains(eml, "X-Cli-Priority: 1") {
|
|
t.Errorf("expected X-Cli-Priority: 1 in EML, got:\n%s", eml)
|
|
}
|
|
}
|
|
|
|
// TestValidatePriorityFlag verifies validate priority flag.
|
|
func TestValidatePriorityFlag(t *testing.T) {
|
|
makeRuntime := func(priority string) *common.RuntimeContext {
|
|
cmd := &cobra.Command{Use: "test"}
|
|
cmd.Flags().String("priority", "", "")
|
|
if priority != "" {
|
|
_ = cmd.Flags().Set("priority", priority)
|
|
}
|
|
return common.TestNewRuntimeContext(cmd, nil)
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
priority string
|
|
wantErr bool
|
|
}{
|
|
{"empty ok", "", false},
|
|
{"high ok", "high", false},
|
|
{"normal ok", "normal", false},
|
|
{"low ok", "low", false},
|
|
{"invalid urgent", "urgent", true},
|
|
{"invalid numeric", "1", true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := validatePriorityFlag(makeRuntime(tc.priority))
|
|
if tc.wantErr && err == nil {
|
|
t.Errorf("validatePriorityFlag(%q): expected error, got nil", tc.priority)
|
|
}
|
|
if !tc.wantErr && err != nil {
|
|
t.Errorf("validatePriorityFlag(%q): unexpected error: %v", tc.priority, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBuildMessageForCompose_InlineNoCID_ClassifiedAsAttachment verifies build message for compose inline no CID classified as attachment.
|
|
func TestBuildMessageForCompose_InlineNoCID_ClassifiedAsAttachment(t *testing.T) {
|
|
msg := map[string]interface{}{
|
|
"message_id": "msg1",
|
|
"subject": "test",
|
|
"attachments": []interface{}{
|
|
map[string]interface{}{"id": "att1", "filename": "with-cid.png", "is_inline": true, "cid": "cid123", "content_type": "image/png"},
|
|
map[string]interface{}{"id": "att2", "filename": "no-cid.png", "is_inline": true, "cid": "", "content_type": "image/png"},
|
|
map[string]interface{}{"id": "att3", "filename": "regular.pdf", "is_inline": false, "content_type": "application/pdf"},
|
|
},
|
|
}
|
|
out := buildMessageForCompose(msg, nil, true)
|
|
if len(out.Images) != 1 || out.Images[0].ID != "att1" {
|
|
t.Errorf("expected 1 image (att1), got %d: %+v", len(out.Images), out.Images)
|
|
}
|
|
if len(out.Attachments) != 2 {
|
|
t.Fatalf("expected 2 attachments, got %d: %+v", len(out.Attachments), out.Attachments)
|
|
}
|
|
ids := []string{out.Attachments[0].ID, out.Attachments[1].ID}
|
|
if ids[0] != "att2" || ids[1] != "att3" {
|
|
t.Errorf("expected attachments [att2, att3], got %v", ids)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// validateComposeInlineAndAttachments
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestValidateComposeInlineAndAttachments verifies validate compose inline and attachments.
|
|
func TestValidateComposeInlineAndAttachments(t *testing.T) {
|
|
chdirTemp(t)
|
|
fio := &localfileio.LocalFileIO{}
|
|
|
|
t.Run("empty flags pass", func(t *testing.T) {
|
|
if err := validateComposeInlineAndAttachments(fio, "", "", false, ""); err != nil {
|
|
t.Fatalf("expected nil, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("inline with plain-text rejected", func(t *testing.T) {
|
|
err := validateComposeInlineAndAttachments(fio, "", `[{"cid":"c1","file_path":"./img.png"}]`, true, "")
|
|
if err == nil || !strings.Contains(err.Error(), "--plain-text") {
|
|
t.Fatalf("expected plain-text rejection, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("inline with non-HTML body rejected", func(t *testing.T) {
|
|
err := validateComposeInlineAndAttachments(fio, "", `[{"cid":"c1","file_path":"./img.png"}]`, false, "plain text body")
|
|
if err == nil || !strings.Contains(err.Error(), "HTML body") {
|
|
t.Fatalf("expected HTML body rejection, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("inline with HTML body passes format check", func(t *testing.T) {
|
|
os.WriteFile("img.png", []byte("png"), 0o644)
|
|
err := validateComposeInlineAndAttachments(fio, "", `[{"cid":"c1","file_path":"./img.png"}]`, false, "<p>hello</p>")
|
|
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")
|
|
}
|
|
}
|