package main
import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net"
+ "os"
+ "path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
+func TestNotifyCLIReachesOnlyTheRootThroughTheDaemon(t *testing.T) {
+ server := newModelFake(t)
+ socket := daemonStartTest(t, server)
+ t.Setenv("ONECHAT_DIR", filepath.Dir(socket))
+
+ var out bytes.Buffer
+
+ args := []string{"--source", "test-check", "--id", "event-1", "--text", "A result needs review."}
+ require.NoError(t, notify(t.Context(), args, &out))
+
+ var receipt Event
+
+ require.NoError(t, json.Unmarshal(out.Bytes(), &receipt))
+ require.Equal(t, "notify", receipt.Kind)
+ require.Equal(t, "accepted", receipt.Status)
+ require.Positive(t, receipt.ID)
+ require.NotContains(t, out.String(), "needs review")
+ require.Eventually(t, func() bool {
+ return server.Calls() == 1
+ }, 10*time.Second, time.Millisecond)
+
+ texts := turnInputTexts(t, server.Call(0))
+ require.Contains(t, texts[len(texts)-1], "Untrusted external data")
+ require.Contains(t, texts[len(texts)-1], `"source":"test-check"`)
+ require.Contains(t, texts[len(texts)-1], `"id":"event-1"`)
+
+ out.Reset()
+ require.NoError(t, notify(t.Context(), args, &out))
+
+ var duplicate Event
+
+ require.NoError(t, json.Unmarshal(out.Bytes(), &duplicate))
+ require.Equal(t, receipt.ID, duplicate.ID)
+ require.Equal(t, "duplicate", duplicate.Status)
+
+ args[len(args)-1] = "Changed result."
+ require.ErrorContains(t, notify(t.Context(), args, &out), "conflicts")
+}
+
+func TestNotifyFlagsValidateBeforeConnecting(t *testing.T) {
+ testCases := []struct {
+ name string
+ args []string
+ }{
+ {
+ name: "requires all fields",
+ args: []string{"--source", "check", "--id", "1"},
+ },
+ {
+ name: "rejects short long flags",
+ args: []string{"-source", "check", "--id", "1", "--text", "data"},
+ },
+ {
+ name: "rejects child targets in the root-only slice",
+ args: []string{"--agent", "Garden", "--source", "check", "--id", "1", "--text", "data"},
+ },
+ {
+ name: "rejects a flag without a value",
+ args: []string{"--source"},
+ },
+ {
+ name: "rejects a repeated flag",
+ args: []string{"--source", "check", "--source", "check", "--id", "1", "--text", "data"},
+ },
+ {
+ name: "bounds text bytes",
+ args: []string{"--source", "check", "--id", "1", "--text", strings.Repeat("x", notifyTextBytes+1)},
+ },
+ {
+ name: "rejects invalid text encoding",
+ args: []string{"--source", "check", "--id", "1", "--text", string([]byte{0xff})},
+ },
+ {
+ name: "rejects unbounded identifiers",
+ args: []string{"--source", strings.Repeat("x", notifyIDBytes+1), "--id", "1", "--text", "data"},
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv("ONECHAT_DIR", t.TempDir())
+ require.ErrorContains(t, notify(t.Context(), tc.args, &bytes.Buffer{}), "parse notification")
+ })
+ }
+
+ input, err := notifyParse([]string{"--source=check", "--id=event.1", "--text=Ol\u00e1"})
+ require.NoError(t, err)
+ require.Equal(t, "check", input.Source)
+ require.Equal(t, "event.1", input.ID)
+}
+
+func TestNotifyHelpAndOfflineDaemon(t *testing.T) {
+ t.Setenv("ONECHAT_DIR", t.TempDir())
+
+ var out bytes.Buffer
+
+ require.NoError(t, notify(t.Context(), []string{"--help"}, &out))
+ require.Contains(t, out.String(), "not processed")
+ require.ErrorContains(t, notify(t.Context(), []string{"--source", "check", "--id", "1", "--text", "data"}, &out), "running daemon")
+}
+
+func TestNotifyCancellationInterruptsAReceiptWait(t *testing.T) {
+ directory := daemonTestSocketDir(t)
+ t.Setenv("ONECHAT_DIR", directory)
+
+ listener, err := net.Listen("unix", filepath.Join(directory, socketFileName))
+ require.NoError(t, err)
+
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+
+ done := make(chan error, 1)
+ go func() {
+ done <- notify(ctx, []string{"--source", "check", "--id", "1", "--text", "data"}, &bytes.Buffer{})
+ }()
+
+ conn, err := listener.Accept()
+ require.NoError(t, err)
+
+ var request SocketRequest
+
+ require.NoError(t, json.NewDecoder(conn).Decode(&request))
+ cancel()
+ require.ErrorIs(t, <-done, context.Canceled)
+ require.NoError(t, conn.Close())
+ require.NoError(t, listener.Close())
+}
+
func TestNotificationTextDoesNotCreateImageAttachments(t *testing.T) {
marker := "[image " + strings.Repeat("a", 64) + ".jpg]"
text, err := notifyText(NotifyInput{
require.NoError(t, err)
require.Equal(t, modelUserMessage(text), item)
}
+
+func TestNotifyRejectsMalformedDaemonReceipts(t *testing.T) {
+ directory := daemonTestSocketDir(t)
+ t.Setenv("ONECHAT_DIR", directory)
+
+ listener, err := net.Listen("unix", filepath.Join(directory, socketFileName))
+ require.NoError(t, err)
+
+ done := make(chan error, 1)
+ go func() {
+ done <- notify(t.Context(), []string{"--source", "check", "--id", "1", "--text", "data"}, &bytes.Buffer{})
+ }()
+
+ conn, err := listener.Accept()
+ require.NoError(t, err)
+
+ var request SocketRequest
+
+ require.NoError(t, json.NewDecoder(conn).Decode(&request))
+ require.NoError(t, json.NewEncoder(conn).Encode(Event{
+ Kind: "notify",
+ ID: 1,
+ Status: "processed",
+ }))
+ require.ErrorContains(t, <-done, "invalid notification receipt")
+ require.NoError(t, conn.Close())
+ require.NoError(t, listener.Close())
+}
+
+func TestNotifyOutputFailureDoesNotUndoAcceptance(t *testing.T) {
+ server := newModelFake(t)
+ socket := daemonStartTest(t, server)
+ t.Setenv("ONECHAT_DIR", filepath.Dir(socket))
+
+ file, err := os.CreateTemp(t.TempDir(), "closed-output")
+ require.NoError(t, err)
+ require.NoError(t, file.Close())
+
+ args := []string{"--source", "check", "--id", "1", "--text", "data"}
+ require.ErrorContains(t, notify(t.Context(), args, file), "write notification receipt")
+
+ var out bytes.Buffer
+
+ require.NoError(t, notify(t.Context(), args, &out))
+ require.Contains(t, out.String(), `"status":"duplicate"`)
+}