Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions apps/api/cmd/clickclack/channels_delete_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package main

import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/openclaw/clickclack/apps/api/internal/store"
)

type channelDeletionServer struct {
*httptest.Server
blocker string
deletes []string
}

func newChannelDeletionServer(t *testing.T) *channelDeletionServer {
t.Helper()
fake := &channelDeletionServer{}
fake.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/api/workspaces":
_ = json.NewEncoder(w).Encode(map[string]any{"workspaces": []store.Workspace{{ID: "wsp_1", Slug: "one", Name: "One"}}})
case r.URL.Path == "/api/workspaces/wsp_1/channels":
_ = json.NewEncoder(w).Encode(map[string]any{"channels": []store.Channel{
{ID: "chn_1", WorkspaceID: "wsp_1", Name: "general"},
{ID: "chn_2", WorkspaceID: "wsp_1", Name: "doomed"},
}})
case r.Method == http.MethodGet && r.URL.Path == "/api/channels/chn_2/deletion-preview":
_ = json.NewEncoder(w).Encode(store.ChannelDeletionPreview{
Channel: store.Channel{ID: "chn_2", Name: "doomed"},
Counts: store.ChannelDeletionCounts{Messages: 12, ThreadReplies: 3, Files: 2},
Blocker: fake.blocker,
})
case r.Method == http.MethodDelete && r.URL.Path == "/api/channels/chn_2":
fake.deletes = append(fake.deletes, r.URL.Path)
w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(fake.Close)
return fake
}

func TestChannelsDeleteRequiresExplicitConfirmation(t *testing.T) {
server := newChannelDeletionServer(t)
c := apiClient{opts: clientOptions{Server: server.URL, UserID: "usr_1", Workspace: "wsp_1"}, http: server.Client()}

if err := c.channels([]string{"delete"}); err == nil || !strings.Contains(err.Error(), "--channel CHANNEL --yes") {
t.Fatalf("expected an explicit channel requirement, got %v", err)
}
if err := c.channels([]string{"delete", "--channel", "doomed"}); err == nil || !strings.Contains(err.Error(), "#doomed: 12 messages, 3 thread replies, 2 files without --yes") {
t.Fatalf("expected a confirmation requirement, got %v", err)
}
server.blocker = store.ChannelDeletionBlockedLastChannel
if err := c.channels([]string{"delete", "--channel", "doomed", "--yes"}); err == nil || !strings.Contains(err.Error(), "(last_channel)") {
t.Fatalf("expected the blocker to stop deletion, got %v", err)
}
if len(server.deletes) != 0 {
t.Fatalf("unconfirmed or blocked commands sent deletes: %v", server.deletes)
}
server.blocker = ""
output := captureStdout(t, func() error {
return c.channels([]string{"delete", "--channel", "doomed", "--yes"})
})
if len(server.deletes) != 1 || output != "deleted #doomed: 12 messages, 3 thread replies, 2 files\n" {
t.Fatalf("deletes=%v output=%q", server.deletes, output)
}
}

func TestChannelsDeleteIgnoresDefaultChannels(t *testing.T) {
server := newChannelDeletionServer(t)
configHome := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", configHome)
t.Setenv("CLICKCLACK_SERVER", server.URL)
t.Setenv("CLICKCLACK_USER_ID", "usr_1")
t.Setenv("CLICKCLACK_WORKSPACE", "wsp_1")

t.Setenv("CLICKCLACK_CHANNEL", "doomed")
if err := client([]string{"channels", "delete", "--yes"}); err == nil || !strings.Contains(err.Error(), "--channel CHANNEL --yes") {
t.Fatalf("CLICKCLACK_CHANNEL chose the channel to delete: %v", err)
}
t.Setenv("CLICKCLACK_CHANNEL", "")
config, err := json.Marshal(clientConfig{Server: server.URL, Workspace: "wsp_1", Channel: "doomed"})
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(configHome, "clickclack"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(configHome, "clickclack", "config.json"), config, 0o600); err != nil {
t.Fatal(err)
}
if err := client([]string{"channels", "delete", "--yes"}); err == nil || !strings.Contains(err.Error(), "--channel CHANNEL --yes") {
t.Fatalf("the saved default channel chose the channel to delete: %v", err)
}
if len(server.deletes) != 0 {
t.Fatalf("a default channel was deleted: %v", server.deletes)
}

for _, args := range [][]string{
{"--channel", "doomed", "channels", "delete", "--yes"},
{"channels", "delete", "--channel", "doomed", "--yes"},
} {
captureStdout(t, func() error { return client(args) })
}
if len(server.deletes) != 2 {
t.Fatalf("named channels were not deleted: %v", server.deletes)
}
}
12 changes: 12 additions & 0 deletions apps/api/cmd/clickclack/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ type clientOptions struct {
Plain bool `json:"-"`
NoInput bool `json:"-"`
Verbose bool `json:"-"`
// ChannelFromFlag is set when the command line named the channel rather
// than CLICKCLACK_CHANNEL or the saved config.
ChannelFromFlag bool `json:"-"`
}

type clientConfig struct {
Expand Down Expand Up @@ -58,6 +61,7 @@ func client(args []string) error {
if err := flags.Parse(args); err != nil {
return err
}
opts.ChannelFromFlag = flagWasSet(flags, "channel")
rest := flags.Args()
if len(rest) == 0 {
return errors.New("client command is required")
Expand Down Expand Up @@ -157,6 +161,14 @@ func addClientFlags(flags *flag.FlagSet, opts *clientOptions) {
flags.BoolVar(&opts.Verbose, "verbose", opts.Verbose, "print diagnostics to stderr")
}

func flagWasSet(flags *flag.FlagSet, name string) bool {
set := false
flags.Visit(func(f *flag.Flag) {
set = set || f.Name == name
})
return set
}

func (c apiClient) withOptions(opts clientOptions, useStoredToken bool) apiClient {
if useStoredToken {
applyStoredDefaults(&opts, c.defaults)
Expand Down
42 changes: 41 additions & 1 deletion apps/api/cmd/clickclack/client_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,11 @@ func (c apiClient) workspaces(args []string) error {
}

func (c apiClient) channels(args []string) error {
if len(args) > 0 && args[0] == "delete" {
return c.channelsDelete(args[1:])
}
if len(args) == 0 || args[0] != "list" {
return errors.New("usage: clickclack channels list [--workspace WORKSPACE]")
return errors.New("usage: clickclack channels list [--workspace WORKSPACE] | channels delete --channel CHANNEL --yes")
}
opts := c.opts
flags := flag.NewFlagSet("channels list", flag.ExitOnError)
Expand All @@ -155,6 +158,43 @@ func (c apiClient) channels(args []string) error {
return nil
}

// channelsDelete requires --yes and a channel named on the command line because
// the deletion cannot be undone. CLICKCLACK_CHANNEL and the saved default
// channel never choose what gets deleted.
func (c apiClient) channelsDelete(args []string) error {
opts := c.opts
flags := flag.NewFlagSet("channels delete", flag.ExitOnError)
addClientFlags(flags, &opts)
confirmed := flags.Bool("yes", false, "confirm permanent deletion")
if err := flags.Parse(args); err != nil {
return err
}
named := opts.ChannelFromFlag || flagWasSet(flags, "channel")
if !named || strings.TrimSpace(opts.Channel) == "" {
return errors.New("usage: clickclack channels delete --channel CHANNEL --yes")
}
c = c.withOptions(opts, true)
_, channel, err := c.resolveChannel()
if err != nil {
return err
}
var preview store.ChannelDeletionPreview
if err := c.get("/api/channels/"+url.PathEscape(channel.ID)+"/deletion-preview", &preview); err != nil {
return err
}
summary := fmt.Sprintf("#%s: %d messages, %d thread replies, %d files", channel.Name, preview.Counts.Messages, preview.Counts.ThreadReplies, preview.Counts.Files)
if preview.Blocker != "" {
return fmt.Errorf("cannot delete %s (%s)", summary, preview.Blocker)
}
if !*confirmed {
return fmt.Errorf("refusing to permanently delete %s without --yes", summary)
}
if err := c.doJSON(context.Background(), http.MethodDelete, "/api/channels/"+url.PathEscape(channel.ID), nil, nil); err != nil {
return err
}
return c.write(map[string]any{"deleted_channel": channel, "counts": preview.Counts}, channel.ID, "deleted "+summary+"\n")
}

func (c apiClient) messages(args []string) error {
if len(args) == 0 {
return errors.New("usage: clickclack messages <send|list>")
Expand Down
143 changes: 143 additions & 0 deletions apps/api/internal/httpapi/channel_deletion_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package httpapi

import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"

"github.com/coder/websocket"
"github.com/openclaw/clickclack/apps/api/internal/realtime"
"github.com/openclaw/clickclack/apps/api/internal/store"
sqlitestore "github.com/openclaw/clickclack/apps/api/internal/store/sqlite"
)

func TestChannelDeletionHTTP(t *testing.T) {
t.Parallel()
ctx := context.Background()
dataDir := t.TempDir()
st, err := sqlitestore.Open("sqlite://" + filepath.Join(dataDir, "clickclack.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(ctx); err != nil {
t.Fatal(err)
}
owner, err := st.EnsureBootstrap(ctx, "Owner", "channel-http-owner@example.com")
if err != nil {
t.Fatal(err)
}
workspaces, err := st.ListWorkspaces(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
workspace := workspaces[0]
member, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Member", Email: "channel-http-member@example.com"})
if err != nil {
t.Fatal(err)
}
if err := st.AddWorkspaceMember(ctx, workspace.ID, member.ID, store.WorkspaceRoleMember); err != nil {
t.Fatal(err)
}
_, botToken, err := st.CreateBot(ctx, store.CreateBotInput{
WorkspaceID: workspace.ID,
DisplayName: "Channel Admin Bot",
Scopes: []string{"bot:admin"},
CreatedBy: owner.ID,
})
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer(New(st, realtime.NewHub(), Options{UploadDir: filepath.Join(dataDir, "uploads")}).Handler())
t.Cleanup(server.Close)

doomed := postJSONAsUser[struct {
Channel store.Channel `json:"channel"`
}](t, owner.ID, server.URL+"/api/workspaces/"+workspace.ID+"/channels", map[string]string{"name": "doomed"}).Channel
upload := uploadFileAsUserWithContentType(t, owner.ID, server.URL+"/api/uploads", workspace.ID, "notes.txt", "text/plain", "doomed notes")
created := postJSONAsUser[struct {
Message store.Message `json:"message"`
Event store.Event `json:"event"`
}](t, owner.ID, server.URL+"/api/channels/"+doomed.ID+"/messages", map[string]string{"body": "doomed", "upload_id": upload.ID})
storedUpload, err := st.GetUpload(ctx, upload.ID, owner.ID)
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(storedUpload.StoragePath); err != nil {
t.Fatalf("expected the upload object before deletion: %v", err)
}

previewURL := server.URL + "/api/channels/" + doomed.ID + "/deletion-preview"
deleteURL := server.URL + "/api/channels/" + doomed.ID
preview := getJSONAsUser[store.ChannelDeletionPreview](t, owner.ID, previewURL)
if preview.Channel.ID != doomed.ID || preview.Counts.Messages != 1 || preview.Counts.Files != 1 || preview.Counts.FileBytes != int64(len("doomed notes")) || preview.Blocker != "" {
t.Fatalf("unexpected preview: %#v", preview)
}
expectStatusAsUser(t, member.ID, http.MethodGet, previewURL, nil, http.StatusForbidden)
expectStatusAsUser(t, member.ID, http.MethodDelete, deleteURL, nil, http.StatusForbidden)
expectStatusWithBearer(t, botToken.Token, http.MethodGet, previewURL, nil, http.StatusForbidden)
expectStatusWithBearer(t, botToken.Token, http.MethodDelete, deleteURL, nil, http.StatusForbidden)
expectStatusAsUser(t, owner.ID, http.MethodDelete, server.URL+"/api/channels/chn_missing", nil, http.StatusNotFound)

wsURL := strings.Replace(server.URL, "http://", "ws://", 1) + "/api/realtime/ws?workspace_id=" + url.QueryEscape(workspace.ID)
memberConn, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{
HTTPHeader: http.Header{"X-ClickClack-User": []string{member.ID}},
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = memberConn.Close(websocket.StatusNormalClosure, "done") })

expectStatusAsUser(t, owner.ID, http.MethodDelete, deleteURL, nil, http.StatusNoContent)
live := readEventType(t, memberConn, "channel.deleted")
payload, ok := live.Payload.(map[string]any)
if live.ChannelID != "" || !ok || payload["channel_id"] != doomed.ID || payload["deleted_by"] != owner.ID {
t.Fatalf("unexpected live deletion event: %#v", live)
}
// A client that reconnects from one of the channel's events keeps its place
// and replays forward to the deletion instead of being forced to resync.
resumed, _, err := websocket.Dial(ctx, wsURL+"&after_cursor="+url.QueryEscape(created.Event.Cursor), &websocket.DialOptions{
HTTPHeader: http.Header{"X-ClickClack-User": []string{member.ID}},
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = resumed.Close(websocket.StatusNormalClosure, "done") })
if replayed := readEventType(t, resumed, "channel.deleted"); replayed.ID != live.ID {
t.Fatalf("resumed socket replayed %#v, want %#v", replayed, live)
}
if _, err := os.Stat(storedUpload.StoragePath); !os.IsNotExist(err) {
t.Fatalf("expected the upload object to be removed, got %v", err)
}
expectStatusAsUser(t, owner.ID, http.MethodGet, previewURL, nil, http.StatusNotFound)
expectStatusAsUser(t, owner.ID, http.MethodDelete, deleteURL, nil, http.StatusNotFound)
auditLog := getJSONAsUser[struct {
AuditLogEntries []store.AuditLogEntry `json:"audit_log_entries"`
}](t, owner.ID, server.URL+"/api/workspaces/"+workspace.ID+"/audit-log")
foundAudit := false
for _, entry := range auditLog.AuditLogEntries {
if entry.Action == "channel.deleted" && entry.TargetID == doomed.ID && entry.Metadata["name"] == "doomed" {
foundAudit = true
}
}
if !foundAudit {
t.Fatalf("expected a channel.deleted audit entry, got %#v", auditLog.AuditLogEntries)
}

solo := postJSONAsUser[struct {
Workspace store.Workspace `json:"workspace"`
}](t, owner.ID, server.URL+"/api/workspaces", map[string]string{"name": "Solo"}).Workspace
only := postJSONAsUser[struct {
Channel store.Channel `json:"channel"`
}](t, owner.ID, server.URL+"/api/workspaces/"+solo.ID+"/channels", map[string]string{"name": "only"}).Channel
lastPreview := getJSONAsUser[store.ChannelDeletionPreview](t, owner.ID, server.URL+"/api/channels/"+only.ID+"/deletion-preview")
if lastPreview.Blocker != store.ChannelDeletionBlockedLastChannel {
t.Fatalf("expected last-channel blocker, got %#v", lastPreview)
}
expectStatusAsUser(t, owner.ID, http.MethodDelete, server.URL+"/api/channels/"+only.ID, nil, http.StatusConflict)
}
Loading
Loading