Skip to content
Closed
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
99 changes: 99 additions & 0 deletions apps/api/internal/httpapi/mutations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,68 @@ import (
sqlitestore "github.com/openclaw/clickclack/apps/api/internal/store/sqlite"
)

func TestManagedChannelReconciliationBotAuthorization(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", "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]
otherWorkspace, err := st.CreateWorkspace(ctx, store.CreateWorkspaceInput{Name: "Other Workspace"}, owner.ID)
if err != nil {
t.Fatal(err)
}
bot, token, err := st.CreateBot(ctx, store.CreateBotInput{
WorkspaceID: workspace.ID,
OwnerUserID: owner.ID,
DisplayName: "Managed Channel Bot",
Scopes: []string{"channels:write"},
CreatedBy: owner.ID,
})
if err != nil {
t.Fatal(err)
}
if err := st.AddWorkspaceMember(ctx, otherWorkspace.ID, bot.ID, "bot"); err != nil {
t.Fatal(err)
}

server := httptest.NewServer(New(st, realtime.NewHub(), Options{UploadDir: filepath.Join(dataDir, "uploads")}).Handler())
t.Cleanup(server.Close)
body := `{"external_provider":"github","external_ref":"openclaw/clickclack#125","name":"pr-125"}`
result, status := postJSONWithBearerStatus[store.ReconcileManagedChannelResult](
t,
token.Token,
server.URL+"/api/workspaces/"+workspace.ID+"/managed-channels/reconcile",
body,
)
if status != http.StatusCreated || result.Action != store.ManagedChannelActionCreated {
t.Fatalf("expected authorized bot reconciliation, status=%d result=%#v", status, result)
}
_, status = postJSONWithBearerStatus[store.ReconcileManagedChannelResult](
t,
token.Token,
server.URL+"/api/workspaces/"+otherWorkspace.ID+"/managed-channels/reconcile",
body,
)
if status != http.StatusForbidden {
t.Fatalf("expected workspace-bound bot token to be rejected, got %d", status)
}
}

func TestMutationAndEphemeralEndpoints(t *testing.T) {
t.Parallel()
ctx := context.Background()
Expand Down Expand Up @@ -97,6 +159,43 @@ func TestMutationAndEphemeralEndpoints(t *testing.T) {
if payload, ok := archivedManaged.Event.Payload.(map[string]any); !ok || payload["archived"] != true {
t.Fatalf("archive state missing from channel.updated metadata: %#v", archivedManaged.Event.Payload)
}
reconcileURL := server.URL + "/api/workspaces/" + workspaces[0].ID + "/managed-channels/reconcile"
reconcileBody := map[string]any{
"external_provider": "github",
"external_ref": "repo:42:pull:125",
"name": "pr-125",
"external_url": "https://github.com/openclaw/clickclack/pull/125",
"sidebar_section": "Pull requests",
}
reconciled, status := postJSONWithStatus[store.ReconcileManagedChannelResult](t, reconcileURL, reconcileBody)
if status != http.StatusCreated || reconciled.Action != store.ManagedChannelActionCreated || reconciled.Event == nil || reconciled.Channel.ExternalProvider == nil || *reconciled.Channel.ExternalProvider != "github" {
t.Fatalf("unexpected managed channel create reconciliation: status=%d result=%#v", status, reconciled)
}
replayed, status := postJSONWithStatus[store.ReconcileManagedChannelResult](t, reconcileURL, reconcileBody)
if status != http.StatusOK || replayed.Action != store.ManagedChannelActionUnchanged || replayed.Event != nil || replayed.Channel.ID != reconciled.Channel.ID {
t.Fatalf("unexpected managed channel replay: status=%d result=%#v", status, replayed)
}
reconcileBody["name"] = "pr-125-review"
reconcileBody["archived"] = true
reconcileBody["kind"] = "private"
changed, status := postJSONWithStatus[store.ReconcileManagedChannelResult](t, reconcileURL, reconcileBody)
if status != http.StatusOK || changed.Action != store.ManagedChannelActionUpdated || changed.Event == nil || changed.Event.Type != "channel.updated" || changed.Channel.ID != reconciled.Channel.ID || changed.Channel.ArchivedAt == nil || changed.Channel.Kind != "private" {
t.Fatalf("unexpected managed channel update reconciliation: status=%d result=%#v", status, changed)
}
reconcileBody["archived"] = false
reopened, status := postJSONWithStatus[store.ReconcileManagedChannelResult](t, reconcileURL, reconcileBody)
if status != http.StatusOK || reopened.Action != store.ManagedChannelActionUpdated || reopened.Channel.ID != reconciled.Channel.ID || reopened.Channel.ArchivedAt != nil {
t.Fatalf("unexpected managed channel reopen reconciliation: status=%d result=%#v", status, reopened)
}
delete(reconcileBody, "kind")
delete(reconcileBody, "archived")
delete(reconcileBody, "external_url")
delete(reconcileBody, "sidebar_section")
defaulted, status := postJSONWithStatus[store.ReconcileManagedChannelResult](t, reconcileURL, reconcileBody)
if status != http.StatusOK || defaulted.Action != store.ManagedChannelActionUpdated || defaulted.Channel.Kind != "public" || defaulted.Channel.ArchivedAt != nil || defaulted.Channel.ExternalURL != nil || defaulted.Channel.SidebarSection != nil {
t.Fatalf("omitted desired fields did not reconcile to defaults: status=%d result=%#v", status, defaulted)
}
expectStatus(t, http.MethodPatch, server.URL+"/api/channels/"+reconciled.Channel.ID, strings.NewReader(`{"external_ref":""}`), http.StatusBadRequest)
message := postJSON[struct {
Message store.Message `json:"message"`
}](t, server.URL+"/api/channels/"+channels[0].ID+"/messages", map[string]string{"body": "original"}).Message
Expand Down
54 changes: 54 additions & 0 deletions apps/api/internal/httpapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ func (s *Server) Handler() http.Handler {
r.Patch("/workspaces/{workspace_id}/moderation/members/{user_id}", s.updateWorkspaceMemberModeration)
r.Get("/workspaces/{workspace_id}/channels", s.listChannels)
r.Post("/workspaces/{workspace_id}/channels", s.createChannel)
r.Post("/workspaces/{workspace_id}/managed-channels/reconcile", s.reconcileManagedChannel)
r.Get("/workspaces/{workspace_id}/topics", s.listTopics)
r.Post("/workspaces/{workspace_id}/topics", s.createTopic)
r.Get("/workspaces/{workspace_id}/bots", s.listBots)
Expand Down Expand Up @@ -889,6 +890,59 @@ func (s *Server) createChannel(w http.ResponseWriter, r *http.Request) {
writeResultStatus(w, http.StatusCreated, map[string]any{"channel": channel, "event": event}, err)
}

func (s *Server) reconcileManagedChannel(w http.ResponseWriter, r *http.Request) {
act, err := s.currentActor(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
if err := act.requireScope("channels:write"); err != nil {
writeError(w, http.StatusForbidden, err)
return
}
workspaceID := chi.URLParam(r, "workspace_id")
if err := act.requireWorkspace(workspaceID); err != nil {
writeError(w, http.StatusForbidden, err)
return
}
var body struct {
ExternalProvider string `json:"external_provider"`
ExternalRef string `json:"external_ref"`
Name string `json:"name"`
Kind string `json:"kind"`
Archived bool `json:"archived"`
ExternalURL string `json:"external_url"`
SidebarSection string `json:"sidebar_section"`
}
if err := readJSON(w, r, &body); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
result, err := s.store.ReconcileManagedChannel(r.Context(), store.ReconcileManagedChannelInput{
WorkspaceID: workspaceID,
UserID: act.user.ID,
ExternalProvider: body.ExternalProvider,
ExternalRef: body.ExternalRef,
Name: body.Name,
Kind: body.Kind,
Archived: body.Archived,
ExternalURL: body.ExternalURL,
SidebarSection: body.SidebarSection,
})
if err != nil {
writeStoreError(w, err)
return
}
if result.Event != nil {
s.publishEvent(r.Context(), *result.Event)
}
status := http.StatusOK
if result.Action == store.ManagedChannelActionCreated {
status = http.StatusCreated
}
writeJSON(w, status, result)
}

func (s *Server) listTopics(w http.ResponseWriter, r *http.Request) {
act, err := s.currentActor(r)
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions apps/api/internal/httpapi/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1725,6 +1725,7 @@ func TestHTTPErrorPathsAndSPA(t *testing.T) {
{"get workspace", http.MethodGet, "/api/workspaces/" + workspace.ID, "", ""},
{"list channels", http.MethodGet, "/api/workspaces/" + workspace.ID + "/channels", "", ""},
{"create channel", http.MethodPost, "/api/workspaces/" + workspace.ID + "/channels", `{"name":"bot-channel"}`, "application/json"},
{"reconcile managed channel", http.MethodPost, "/api/workspaces/" + workspace.ID + "/managed-channels/reconcile", `{"external_provider":"github","external_ref":"repo:42:pull:125","name":"pr-125"}`, "application/json"},
{"update channel", http.MethodPatch, "/api/channels/" + channel.ID, `{"name":"bot-channel"}`, "application/json"},
{"list messages", http.MethodGet, "/api/channels/" + channel.ID + "/messages", "", ""},
{"update message", http.MethodPatch, "/api/messages/" + messageID, `{"body":"blocked"}`, "application/json"},
Expand Down
61 changes: 61 additions & 0 deletions apps/api/internal/store/managed_channels.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package store

import (
"errors"
"regexp"
"strings"
"unicode"
"unicode/utf8"
)

const (
ManagedChannelActionCreated = "created"
ManagedChannelActionUpdated = "updated"
ManagedChannelActionUnchanged = "unchanged"

maxExternalRefRunes = 512
)

var (
externalProviderPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)

ErrManagedChannelIdentityImmutable = errors.New("reconciled managed channel identity is immutable")
)

type ReconcileManagedChannelInput struct {
WorkspaceID string
UserID string
ExternalProvider string
ExternalRef string
Name string
Kind string
Archived bool
ExternalURL string
SidebarSection string
}

type ReconcileManagedChannelResult struct {
Channel Channel `json:"channel"`
Action string `json:"action"`
Event *Event `json:"event,omitempty"`
}

func NormalizeManagedChannelIdentity(provider, ref string) (string, string, error) {
provider = strings.TrimSpace(provider)
ref = strings.TrimSpace(ref)
if !externalProviderPattern.MatchString(provider) {
return "", "", errors.New("external_provider must be 1-64 lowercase letters, digits, dots, underscores, or hyphens")
}
if ref == "" {
return "", "", errors.New("external_ref is required")
}
if !utf8.ValidString(ref) || utf8.RuneCountInString(ref) > maxExternalRefRunes {
return "", "", errors.New("external_ref must be valid UTF-8 with at most 512 characters")
}
for _, value := range ref {
if unicode.IsControl(value) {
return "", "", errors.New("external_ref cannot contain control characters")
}
}
return provider, ref, nil
}
38 changes: 38 additions & 0 deletions apps/api/internal/store/managed_channels_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package store

import (
"strings"
"testing"
)

func TestNormalizeManagedChannelIdentity(t *testing.T) {
t.Parallel()
provider, ref, err := NormalizeManagedChannelIdentity(" github ", " openclaw/clickclack#125 ")
if err != nil {
t.Fatal(err)
}
if provider != "github" || ref != "openclaw/clickclack#125" {
t.Fatalf("unexpected normalized identity: provider=%q ref=%q", provider, ref)
}

tests := []struct {
name string
provider string
ref string
}{
{name: "empty provider", ref: "item"},
{name: "uppercase provider", provider: "GitHub", ref: "item"},
{name: "provider too long", provider: strings.Repeat("a", 65), ref: "item"},
{name: "empty ref", provider: "github"},
{name: "ref too long", provider: "github", ref: strings.Repeat("x", 513)},
{name: "control character in ref", provider: "github", ref: "pull\n125"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if _, _, err := NormalizeManagedChannelIdentity(tt.provider, tt.ref); err == nil {
t.Fatal("expected invalid managed channel identity")
}
})
}
}
Loading