X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/d7bc0a6ac452bfc646610fdfd7c8eccb5c55218f..6c7859cfea123479aba046614ee81028651ddaf5:/notify_test.go diff --git a/notify_test.go b/notify_test.go index 55f3c01..c0a565d 100644 --- a/notify_test.go +++ b/notify_test.go @@ -1,6 +1,12 @@ package main import ( + "bytes" + "context" + "encoding/json" + "net" + "os" + "path/filepath" "strings" "testing" "time" @@ -8,6 +14,134 @@ import ( "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{ @@ -22,3 +156,49 @@ func TestNotificationTextDoesNotCreateImageAttachments(t *testing.T) { 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"`) +}