diff --git a/go/cmd/compass/agent.go b/go/cmd/compass/agent.go new file mode 100644 index 00000000..5b5c5655 --- /dev/null +++ b/go/cmd/compass/agent.go @@ -0,0 +1,96 @@ +//go:build unix + +package main + +import ( + "context" + "fmt" + "io" + "strings" + + "connectrpc.com/connect" + "github.com/spf13/cobra" + + compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" + "github.com/RigelBuild/compass/go/gen/compass/v1/compassv1connect" +) + +// newAgentCmd builds the agent noun: the agent-session inspection operator +// surface. It carries no logic of its own; each verb is a child that dials the +// Server and drives one CompassService RPC. It is distinct from the agent-config +// noun (fleet config bundles) — a separate Cobra command, no name clash. +func newAgentCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "agent", + Short: "Inspect agent sessions (status)", + } + cmd.AddCommand(newAgentStatusCmd()) + return cmd +} + +// newAgentStatusCmd builds `agent status [--session ]`: GetAgentStatus and +// render each session's id and state. An empty --session returns every live +// session; a set --session returns just that one (queryable even when terminal, +// per the by-id contract at internal/server/service.go:343-358). +func newAgentStatusCmd() *cobra.Command { + var session string + cmd := &cobra.Command{ + Use: "status", + Short: "Show live agent-session state (all sessions, or one with --session)", + RunE: func(cmd *cobra.Command, _ []string) error { + client, err := dialClient(cmd) + if err != nil { + return err + } + return runAgentStatus(cmd.Context(), client, session, cmd.OutOrStdout()) + }, + } + cmd.Flags().StringVar(&session, "session", "", + "Restrict to one session id (default: every live session).") + return cmd +} + +// runAgentStatus calls GetAgentStatus with the resolved session filter and +// renders the returned statuses. An empty result renders a clear message, not an +// error — no live sessions (or an unknown id) is a valid answer, not a failure. +func runAgentStatus(ctx context.Context, client compassv1connect.CompassServiceClient, session string, out io.Writer) error { + ctx, cancel := context.WithTimeout(ctx, rpcTimeout) + defer cancel() + resp, err := client.GetAgentStatus(ctx, + connect.NewRequest(&compassv1.GetAgentStatusRequest{SessionId: session})) + if err != nil { + return fmt.Errorf("getting agent status: %w", err) + } + statuses := resp.Msg.GetStatuses() + if len(statuses) == 0 { + _, err = fmt.Fprintln(out, "no live agent sessions") + return err + } + return renderAgentStatuses(out, statuses) +} + +// renderAgentStatuses prints a session-id + state column for each status. The +// state renders as the short operator-facing token (the enum name minus the +// AGENT_SESSION_STATE_ prefix, lowercased), so a WORKING session reads "working". +func renderAgentStatuses(out io.Writer, statuses []*compassv1.AgentSessionStatus) error { + if _, err := fmt.Fprintf(out, "%-40s %s\n", "SESSION", "STATE"); err != nil { + return err + } + for _, s := range statuses { + if _, err := fmt.Fprintf(out, "%-40s %s\n", s.GetSessionId(), stateLabel(s.GetState())); err != nil { + return err + } + } + return nil +} + +// stateLabel renders an AgentSessionState as the short operator-facing token: +// the generated enum name with the AGENT_SESSION_STATE_ prefix stripped and +// lowercased (WORKING → "working"). An unknown value falls back to "unspecified". +func stateLabel(state compassv1.AgentSessionState) string { + name, ok := compassv1.AgentSessionState_name[int32(state)] + if !ok { + return unspecifiedLabel + } + return strings.ToLower(strings.TrimPrefix(name, "AGENT_SESSION_STATE_")) +} diff --git a/go/cmd/compass/agent_test.go b/go/cmd/compass/agent_test.go new file mode 100644 index 00000000..c3601ff8 --- /dev/null +++ b/go/cmd/compass/agent_test.go @@ -0,0 +1,117 @@ +//go:build unix + +package main + +import ( + "context" + "strings" + "testing" + + "github.com/spf13/cobra" + + compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" +) + +// The agent subcommand drives GetAgentStatus against the shared fakeCompass / +// startFakeServer harness in cli_test.go (its statuses/gotStatus fields record +// the request and canned response), so the wiring is tested without a live +// Server or Postgres. + +// TestRunAgentStatusAll asserts an empty --session sends an empty session id +// (every live session) and renders each returned session's id and short state +// label, stamping the bearer token. +func TestRunAgentStatusAll(t *testing.T) { + fake := &fakeCompass{statuses: []*compassv1.AgentSessionStatus{ + {SessionId: "s1", State: compassv1.AgentSessionState_AGENT_SESSION_STATE_WORKING}, + {SessionId: "s2", State: compassv1.AgentSessionState_AGENT_SESSION_STATE_DISCONNECTED}, + }} + client := startFakeServer(t, fake) + + var out strings.Builder + if err := runAgentStatus(context.Background(), client, "", &out); err != nil { + t.Fatalf("runAgentStatus: %v", err) + } + if fake.gotStatus.GetSessionId() != "" { + t.Errorf("GetAgentStatus session_id = %q, want empty (all sessions)", fake.gotStatus.GetSessionId()) + } + got := out.String() + for _, want := range []string{"s1", "working", "s2", "disconnected", "SESSION", "STATE"} { + if !strings.Contains(got, want) { + t.Errorf("status output %q missing %q", got, want) + } + } + if fake.gotAuth != "Bearer test-token" { + t.Errorf("Authorization = %q, want Bearer test-token", fake.gotAuth) + } +} + +// TestRunAgentStatusByID asserts a set --session maps straight onto the request +// session_id (the by-id filter) and renders that one session. +func TestRunAgentStatusByID(t *testing.T) { + fake := &fakeCompass{statuses: []*compassv1.AgentSessionStatus{ + {SessionId: "s1", State: compassv1.AgentSessionState_AGENT_SESSION_STATE_STOPPED}, + }} + client := startFakeServer(t, fake) + + var out strings.Builder + if err := runAgentStatus(context.Background(), client, "s1", &out); err != nil { + t.Fatalf("runAgentStatus: %v", err) + } + if fake.gotStatus.GetSessionId() != "s1" { + t.Errorf("GetAgentStatus session_id = %q, want s1", fake.gotStatus.GetSessionId()) + } + if got := out.String(); !strings.Contains(got, "s1") || !strings.Contains(got, "stopped") { + t.Errorf("status output %q missing s1/stopped", got) + } +} + +// TestRunAgentStatusEmpty asserts an empty result renders a clear message, not +// an error — no live sessions (or an unknown id) is a valid answer. +func TestRunAgentStatusEmpty(t *testing.T) { + fake := &fakeCompass{} + client := startFakeServer(t, fake) + + var out strings.Builder + if err := runAgentStatus(context.Background(), client, "", &out); err != nil { + t.Fatalf("runAgentStatus: %v", err) + } + if got := out.String(); !strings.Contains(got, "no live agent sessions") { + t.Errorf("empty output %q, want the no-sessions message", got) + } +} + +// TestAgentStatusFlagParsing asserts the status verb parses --session off argv +// into the request (flag wiring, not just the run function). +func TestAgentStatusFlagParsing(t *testing.T) { + fake := &fakeCompass{} + client := startFakeServer(t, fake) + + cmd := newAgentStatusCmd() + cmd.RunE = func(cmd *cobra.Command, _ []string) error { + return runAgentStatus(cmd.Context(), client, cmd.Flag("session").Value.String(), cmd.OutOrStdout()) + } + cmd.SetArgs([]string{"--session", "abc"}) + cmd.SetOut(&strings.Builder{}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if fake.gotStatus.GetSessionId() != "abc" { + t.Errorf("parsed session_id = %q, want abc", fake.gotStatus.GetSessionId()) + } +} + +// TestStateLabel asserts the enum-name → short-token rendering, including the +// unspecified fallback for an out-of-range value. +func TestStateLabel(t *testing.T) { + cases := map[compassv1.AgentSessionState]string{ + compassv1.AgentSessionState_AGENT_SESSION_STATE_WORKING: "working", + compassv1.AgentSessionState_AGENT_SESSION_STATE_READY: "ready", + compassv1.AgentSessionState_AGENT_SESSION_STATE_DISCONNECTED: "disconnected", + compassv1.AgentSessionState(9999): "unspecified", + } + for state, want := range cases { + if got := stateLabel(state); got != want { + t.Errorf("stateLabel(%v) = %q, want %q", state, got, want) + } + } +} diff --git a/go/cmd/compass/cli_test.go b/go/cmd/compass/cli_test.go index 72abcf0e..bb62b28a 100644 --- a/go/cmd/compass/cli_test.go +++ b/go/cmd/compass/cli_test.go @@ -260,6 +260,8 @@ type fakeCompass struct { info *compassv1.GetAgentConfigInfoResponse deleteCalls int gotToken string + gotStatus *compassv1.GetAgentStatusRequest + statuses []*compassv1.AgentSessionStatus gotAuth string } @@ -290,6 +292,12 @@ func (f *fakeCompass) RevokeToken(_ context.Context, req *connect.Request[compas return connect.NewResponse(&compassv1.RevokeTokenResponse{}), nil } +func (f *fakeCompass) GetAgentStatus(_ context.Context, req *connect.Request[compassv1.GetAgentStatusRequest]) (*connect.Response[compassv1.GetAgentStatusResponse], error) { + f.gotStatus = req.Msg + f.gotAuth = req.Header().Get("Authorization") + return connect.NewResponse(&compassv1.GetAgentStatusResponse{Statuses: f.statuses}), nil +} + // startFakeServer stands up the fake CompassService over a plain-HTTP httptest // server and returns a client wired to it with the bearer interceptor. func startFakeServer(t *testing.T, fake *fakeCompass) compassv1connect.CompassServiceClient { diff --git a/go/cmd/compass/client.go b/go/cmd/compass/client.go index 9b15e84d..c25f6614 100644 --- a/go/cmd/compass/client.go +++ b/go/cmd/compass/client.go @@ -236,3 +236,26 @@ func dialSecretsClient(cmd *cobra.Command) (compassv1connect.SecretsServiceClien } return newSecretsClient(cfg) } + +// newCommsClient constructs the CommsService client for the resolved +// connection, reusing the same httpClient + bearer interceptor as newClient. +func newCommsClient(cfg connConfig) (compassv1connect.CommsServiceClient, error) { + httpClient, err := httpClientFor(cfg) + if err != nil { + return nil, err + } + return compassv1connect.NewCommsServiceClient( + httpClient, cfg.serverAddr, + connect.WithInterceptors(&bearerToken{token: cfg.token}), + ), nil +} + +// dialCommsClient resolves the connection config and builds the CommsService +// client in one step — the shared prelude every message subcommand runs. +func dialCommsClient(cmd *cobra.Command) (compassv1connect.CommsServiceClient, error) { + cfg, err := resolveConn(cmd) + if err != nil { + return nil, err + } + return newCommsClient(cfg) +} diff --git a/go/cmd/compass/main.go b/go/cmd/compass/main.go index eeebced2..881f6b28 100644 --- a/go/cmd/compass/main.go +++ b/go/cmd/compass/main.go @@ -25,6 +25,11 @@ import ( // "-X main.version=". var version = "0.1.0" +// unspecifiedLabel is the operator-facing token for an enum value the CLI does +// not recognize — the zero/unknown fallback shared by every enum renderer +// (deliveryLabel, kindLabel, stateLabel), so an unknown value reads uniformly. +const unspecifiedLabel = "unspecified" + func main() { if err := newRootCmd().Execute(); err != nil { fmt.Fprintln(os.Stderr, "compass:", err) @@ -45,7 +50,9 @@ func newRootCmd() *cobra.Command { } addConnFlags(root.PersistentFlags()) + root.AddCommand(newAgentCmd()) root.AddCommand(newAgentConfigCmd()) + root.AddCommand(newMessageCmd()) root.AddCommand(newSecretCmd()) root.AddCommand(newTokenCmd()) return root diff --git a/go/cmd/compass/message.go b/go/cmd/compass/message.go new file mode 100644 index 00000000..a8a617a7 --- /dev/null +++ b/go/cmd/compass/message.go @@ -0,0 +1,140 @@ +//go:build unix + +package main + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "connectrpc.com/connect" + "github.com/spf13/cobra" + + compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" + "github.com/RigelBuild/compass/go/gen/compass/v1/compassv1connect" +) + +// maxMessageBytes caps the stdin read in `message post`. A channel message is +// kilobytes of markdown at most; the cap only stops a stray +// `compass message post ... < largefile` from allocating an entire file into +// memory (mirrors maxSecretBytes at secret.go:36). +const maxMessageBytes = 1 << 20 // 1 MiB + +// newMessageCmd builds the message noun: the operator surface for posting into a +// channel/topic over CommsService/PostMessage. Like the secret noun it carries +// no logic of its own; the post verb dials the Server and drives one RPC. The +// message body is read from stdin, never argv, so it cannot leak into the +// process table (the load-bearing convention shared with the bearer token and +// secret values). +func newMessageCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "message", + Short: "Post messages into fleet channels (post)", + } + cmd.AddCommand(newMessagePostCmd()) + return cmd +} + +// newMessagePostCmd builds `message post --channel --topic [--mention +// ]`: post one message into a channel's topic, the body read from stdin. +// --topic is a get-or-create-by-name (an unknown name creates the topic per the +// PostMessage handler at internal/comms/comms.go:353). --mention prepends +// `@ ` to the body; the server parses @-mentions from the raw text, so +// there is no separate mention field on the wire (PostMessageRequest carries +// only container/topic/blocks). The body is read from stdin, never a flag or +// positional, so it cannot leak into the process table. +func newMessagePostCmd() *cobra.Command { + var channel, topic string + var mentions []string + cmd := &cobra.Command{ + Use: "post --channel --topic ", + Short: "Post a message into a channel topic (body read from stdin, admin)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + client, err := dialCommsClient(cmd) + if err != nil { + return err + } + return runMessagePost(cmd.Context(), client, messagePostArgs{ + channel: channel, + topic: topic, + mentions: mentions, + }, cmd.InOrStdin(), cmd.OutOrStdout()) + }, + } + cmd.Flags().StringVar(&channel, "channel", "", + "Channel id to post into (required).") + cmd.Flags().StringVar(&topic, "topic", "", + "Topic name within the channel (required; an unknown name creates the topic).") + cmd.Flags().StringArrayVar(&mentions, "mention", nil, + "Handle to @-mention; prepended to the body as `@ `. Repeatable.") + return cmd +} + +// errEmptyMessageBody names the empty-stdin rejection: a message body is +// required and it is read from stdin. +var errEmptyMessageBody = errors.New("a message body is required: pipe it on stdin (it is never taken from the command line)") + +// messagePostArgs is the resolved `message post` input: the channel and topic to +// address and the optional mentions to prepend, validated before any RPC. +type messagePostArgs struct { + channel string + topic string + mentions []string +} + +// runMessagePost validates the required channel/topic, reads the body from in +// (trimming a single trailing newline and rejecting an empty body), prepends any +// --mention handles as `@ `, and calls PostMessage. The body is never +// taken from argv, so it cannot leak into the process table. +func runMessagePost(ctx context.Context, client compassv1connect.CommsServiceClient, args messagePostArgs, in io.Reader, out io.Writer) error { + if args.channel == "" { + return errors.New("--channel is required: the channel id to post into") + } + if args.topic == "" { + return errors.New("--topic is required: the topic name within the channel") + } + raw, err := io.ReadAll(io.LimitReader(in, maxMessageBytes+2)) + if err != nil { + return fmt.Errorf("reading message body from stdin: %w", err) + } + body := strings.TrimSuffix(string(raw), "\n") + if len(body) > maxMessageBytes { + return fmt.Errorf("message body exceeds the %d-byte limit: pipe a smaller body on stdin", maxMessageBytes) + } + if body == "" { + return errEmptyMessageBody + } + // The server parses @-mentions from the raw text (there is no mention field + // on the wire), so a --mention becomes a literal `@ ` prefix. Multiple + // mentions prepend in flag order. + if len(args.mentions) > 0 { + var b strings.Builder + for _, m := range args.mentions { + fmt.Fprintf(&b, "@%s ", m) + } + b.WriteString(body) + body = b.String() + } + + ctx, cancel := context.WithTimeout(ctx, rpcTimeout) + defer cancel() + // No ClientRequestId: an operator re-running `compass message post` is a + // genuine second post, not a retry of a lost one (connect-go does not + // auto-retry), so the (author, client_request_id) idempotency lane is left + // unused deliberately rather than minting a per-invocation key. + resp, err := client.PostMessage(ctx, connect.NewRequest(&compassv1.PostMessageRequest{ + Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: args.channel}, + Topic: &compassv1.PostMessageRequest_TopicName{TopicName: args.topic}, + Blocks: []*compassv1.MessageBlock{ + {Block: &compassv1.MessageBlock_Text{Text: body}}, + }, + })) + if err != nil { + return fmt.Errorf("posting message to channel %s topic %s: %w", args.channel, args.topic, err) + } + _, err = fmt.Fprintln(out, resp.Msg.GetMessage().GetId()) + return err +} diff --git a/go/cmd/compass/message_test.go b/go/cmd/compass/message_test.go new file mode 100644 index 00000000..83577c25 --- /dev/null +++ b/go/cmd/compass/message_test.go @@ -0,0 +1,242 @@ +//go:build unix + +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "connectrpc.com/connect" + + compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" + "github.com/RigelBuild/compass/go/gen/compass/v1/compassv1connect" +) + +// fakeComms is a fake CommsService handler recording the PostMessage request the +// post verb constructs and returning a canned message id, so the message +// subcommand RPC wiring is tested without a live Server or Postgres. +type fakeComms struct { + compassv1connect.UnimplementedCommsServiceHandler + gotPost *compassv1.PostMessageRequest + gotAuth string +} + +func (f *fakeComms) PostMessage(_ context.Context, req *connect.Request[compassv1.PostMessageRequest]) (*connect.Response[compassv1.PostMessageResponse], error) { + f.gotPost = req.Msg + f.gotAuth = req.Header().Get("Authorization") + return connect.NewResponse(&compassv1.PostMessageResponse{ + Message: &compassv1.Message{Id: "msg-123"}, + }), nil +} + +// startFakeCommsServer stands up the fake CommsService over a plain-HTTP httptest +// server and returns a client wired to it with the bearer interceptor. +func startFakeCommsServer(t *testing.T, fake *fakeComms) compassv1connect.CommsServiceClient { + t.Helper() + path, handler := compassv1connect.NewCommsServiceHandler(fake) + mux := http.NewServeMux() + mux.Handle(path, handler) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + client, err := newCommsClient(connConfig{serverAddr: srv.URL, token: "test-token"}) + if err != nil { + t.Fatalf("newCommsClient: %v", err) + } + return client +} + +// TestRunMessagePost asserts post reads the body from the injected stdin (never +// argv), maps channel/topic onto the request's oneofs, lands the body as a text +// block, prints the returned message id, and stamps the bearer token. +func TestRunMessagePost(t *testing.T) { + fake := &fakeComms{} + client := startFakeCommsServer(t, fake) + + var out strings.Builder + in := strings.NewReader("hello fleet\n") + args := messagePostArgs{channel: "chan-1", topic: "general"} + if err := runMessagePost(context.Background(), client, args, in, &out); err != nil { + t.Fatalf("runMessagePost: %v", err) + } + if fake.gotPost == nil { + t.Fatal("PostMessage was not called") + } + if fake.gotPost.GetChannelId() != "chan-1" { + t.Errorf("channel = %q, want chan-1", fake.gotPost.GetChannelId()) + } + if fake.gotPost.GetTopicName() != "general" { + t.Errorf("topic = %q, want general", fake.gotPost.GetTopicName()) + } + blocks := fake.gotPost.GetBlocks() + if len(blocks) != 1 { + t.Fatalf("blocks = %d, want 1", len(blocks)) + } + if got := blocks[0].GetText(); got != "hello fleet" { + t.Errorf("block text = %q, want %q (trailing newline trimmed, from stdin)", got, "hello fleet") + } + if got := strings.TrimSpace(out.String()); got != "msg-123" { + t.Errorf("stdout = %q, want the returned message id msg-123", got) + } + if fake.gotAuth != "Bearer test-token" { + t.Errorf("Authorization = %q, want Bearer test-token", fake.gotAuth) + } +} + +// TestRunMessagePostMentions asserts each --mention prepends `@ ` to the +// body in flag order (the server parses @-mentions from the raw text; there is +// no mention field on the wire). +func TestRunMessagePostMentions(t *testing.T) { + fake := &fakeComms{} + client := startFakeCommsServer(t, fake) + + var out strings.Builder + args := messagePostArgs{channel: "chan-1", topic: "general", mentions: []string{"alice", "bob"}} + if err := runMessagePost(context.Background(), client, args, strings.NewReader("ping"), &out); err != nil { + t.Fatalf("runMessagePost: %v", err) + } + blocks := fake.gotPost.GetBlocks() + if len(blocks) != 1 { + t.Fatalf("blocks = %d, want 1", len(blocks)) + } + if got := blocks[0].GetText(); got != "@alice @bob ping" { + t.Errorf("block text = %q, want %q", got, "@alice @bob ping") + } +} + +// TestRunMessagePostRejections covers the client-side validation that fails +// before any RPC: a missing channel, a missing topic, and an empty stdin body. +func TestRunMessagePostRejections(t *testing.T) { + tests := []struct { + name string + args messagePostArgs + in string + want string + }{ + { + name: "missing channel", + args: messagePostArgs{channel: "", topic: "general"}, + in: "body", + want: "--channel", + }, + { + name: "missing topic", + args: messagePostArgs{channel: "chan-1", topic: ""}, + in: "body", + want: "--topic", + }, + { + name: "empty stdin body", + args: messagePostArgs{channel: "chan-1", topic: "general"}, + in: "\n", + want: "body is required", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := &fakeComms{} + client := startFakeCommsServer(t, fake) + var out strings.Builder + err := runMessagePost(context.Background(), client, tt.args, strings.NewReader(tt.in), &out) + if err == nil { + t.Fatalf("runMessagePost(%+v) = nil error, want rejection", tt.args) + } + if !strings.Contains(err.Error(), tt.want) { + t.Errorf("error %q does not mention %q", err.Error(), tt.want) + } + if fake.gotPost != nil { + t.Error("PostMessage was called despite validation failure") + } + }) + } +} + +// TestNewMessagePostFlagParsing asserts the post verb wires its flags: --channel, +// --topic and a repeatable --mention parse off the command line into the args the +// RunE closure forwards, exercised through cobra's flag parser. +func TestNewMessagePostFlagParsing(t *testing.T) { + cmd := newMessagePostCmd() + if err := cmd.Flags().Parse([]string{"--channel", "chan-1", "--topic", "general", "--mention", "alice", "--mention", "bob"}); err != nil { + t.Fatalf("Parse: %v", err) + } + channel, err := cmd.Flags().GetString("channel") + if err != nil { + t.Fatalf("GetString channel: %v", err) + } + if channel != "chan-1" { + t.Errorf("channel flag = %q, want chan-1", channel) + } + topic, err := cmd.Flags().GetString("topic") + if err != nil { + t.Fatalf("GetString topic: %v", err) + } + if topic != "general" { + t.Errorf("topic flag = %q, want general", topic) + } + mentions, err := cmd.Flags().GetStringArray("mention") + if err != nil { + t.Fatalf("GetStringArray mention: %v", err) + } + if len(mentions) != 2 || mentions[0] != "alice" || mentions[1] != "bob" { + t.Errorf("mention flags = %v, want [alice bob]", mentions) + } +} + +// TestRunMessagePostBound asserts a stdin body over maxMessageBytes is rejected +// before any RPC, while a body exactly at the cap (with or without a trailing +// newline) is accepted and lands as the full text block. Mirrors +// secret_test.go's TestRunSecretSetBound over the shared stdin-cap discipline. +func TestRunMessagePostBound(t *testing.T) { + args := messagePostArgs{channel: "chan-1", topic: "general"} + + t.Run("over the cap", func(t *testing.T) { + fake := &fakeComms{} + client := startFakeCommsServer(t, fake) + var out strings.Builder + in := strings.NewReader(strings.Repeat("a", maxMessageBytes+1)) + err := runMessagePost(context.Background(), client, args, in, &out) + if err == nil { + t.Fatal("runMessagePost with oversized stdin = nil error, want rejection") + } + if !strings.Contains(err.Error(), "limit") { + t.Errorf("error %q does not mention the limit", err.Error()) + } + if fake.gotPost != nil { + t.Error("PostMessage was called despite oversized body") + } + }) + + t.Run("exactly at the cap", func(t *testing.T) { + fake := &fakeComms{} + client := startFakeCommsServer(t, fake) + var out strings.Builder + in := strings.NewReader(strings.Repeat("a", maxMessageBytes)) + if err := runMessagePost(context.Background(), client, args, in, &out); err != nil { + t.Fatalf("runMessagePost at cap: %v", err) + } + if fake.gotPost == nil { + t.Fatal("PostMessage was not called for a body at the cap") + } + if got := len(fake.gotPost.GetBlocks()[0].GetText()); got != maxMessageBytes { + t.Errorf("body length = %d, want %d", got, maxMessageBytes) + } + }) + + t.Run("content at the cap with a trailing newline", func(t *testing.T) { + fake := &fakeComms{} + client := startFakeCommsServer(t, fake) + var out strings.Builder + in := strings.NewReader(strings.Repeat("a", maxMessageBytes) + "\n") + if err := runMessagePost(context.Background(), client, args, in, &out); err != nil { + t.Fatalf("runMessagePost at cap with trailing newline: %v", err) + } + if fake.gotPost == nil { + t.Fatal("PostMessage was not called for cap-sized content with a trailing newline") + } + if got := len(fake.gotPost.GetBlocks()[0].GetText()); got != maxMessageBytes { + t.Errorf("body length = %d, want %d (newline trimmed, content at cap accepted)", got, maxMessageBytes) + } + }) +} diff --git a/go/cmd/compass/secret.go b/go/cmd/compass/secret.go index cf2a40a1..2defa048 100644 --- a/go/cmd/compass/secret.go +++ b/go/cmd/compass/secret.go @@ -275,7 +275,7 @@ func deliveryLabel(d compassv1.SecretDelivery) string { case compassv1.SecretDelivery_SECRET_DELIVERY_FILE: return deliveryFile default: - return "unspecified" + return unspecifiedLabel } } @@ -290,7 +290,7 @@ func kindLabel(k compassv1.SecretKind) string { case compassv1.SecretKind_SECRET_KIND_GH: return kindGH default: - return "unspecified" + return unspecifiedLabel } }