mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 18:16:09 +00:00
bitrix.portals.create only persisted the DB row — it never called Router.RegisterPortal, so a portal created while the gateway was already running stayed invisible to PortalByDomain/PortalByKey until the next full restart. The only other RegisterPortal call site is BootstrapPortals, which runs once at boot. Result: the OAuth install callback 404'd with "unknown portal" for any portal created through the self-service UI mid-uptime — hit live on web1trang.bitrix24.com. Fix: inject registerPortal/unregisterPortal func fields into BitrixPortalsMethods (mirrors the existing gatewayPublicURL func() pattern), defaulting to bitrix24.WebhookRouter() + NewPortal/ RegisterPortal (same construction path as BootstrapPortals) and Router.UnregisterPortal respectively. handleCreate calls registerPortal after a successful Create; handleDelete calls unregisterPortal after a successful Delete for symmetry. Both are best-effort — a registration failure is logged, not returned as an RPC error, since the DB row is already valid either way. Injected via func fields (not calling bitrix24.WebhookRouter() inline) so tests can stub live-router registration without touching that process-wide singleton, whose test-reset helper isn't exported outside the bitrix24 package. Tests: 4 new cases covering register-on-create, best-effort failure handling, unregister-on-delete, and no-unregister-when-delete-blocked. All 20 tests in bitrix_portals_test.go pass; go build (both PG and sqliteonly tags) and go vet clean. Co-authored-by: DangTinh311 <dangtinh31193@gmail.com>
907 lines
33 KiB
Go
907 lines
33 KiB
Go
package methods
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
|
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stub stores
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type stubBitrixPortalStore struct {
|
|
mu sync.Mutex
|
|
rows map[string]*store.BitrixPortalData // key: tenantID:name
|
|
createErr error
|
|
}
|
|
|
|
func newStubBitrixPortalStore() *stubBitrixPortalStore {
|
|
return &stubBitrixPortalStore{rows: map[string]*store.BitrixPortalData{}}
|
|
}
|
|
|
|
func (s *stubBitrixPortalStore) key(tid uuid.UUID, name string) string {
|
|
return tid.String() + ":" + name
|
|
}
|
|
|
|
func (s *stubBitrixPortalStore) Create(_ context.Context, p *store.BitrixPortalData) error {
|
|
if s.createErr != nil {
|
|
return s.createErr
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
k := s.key(p.TenantID, p.Name)
|
|
if _, exists := s.rows[k]; exists {
|
|
return errors.New("duplicate key violates unique constraint")
|
|
}
|
|
if p.ID == uuid.Nil {
|
|
p.ID = store.GenNewID()
|
|
}
|
|
p.CreatedAt = time.Now()
|
|
p.UpdatedAt = p.CreatedAt
|
|
cp := *p
|
|
s.rows[k] = &cp
|
|
return nil
|
|
}
|
|
|
|
func (s *stubBitrixPortalStore) GetByName(_ context.Context, tid uuid.UUID, name string) (*store.BitrixPortalData, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
row, ok := s.rows[s.key(tid, name)]
|
|
if !ok {
|
|
return nil, errors.New("not found")
|
|
}
|
|
cp := *row
|
|
return &cp, nil
|
|
}
|
|
|
|
func (s *stubBitrixPortalStore) ListByTenant(_ context.Context, tid uuid.UUID) ([]store.BitrixPortalData, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
prefix := tid.String() + ":"
|
|
out := make([]store.BitrixPortalData, 0)
|
|
for k, row := range s.rows {
|
|
if strings.HasPrefix(k, prefix) {
|
|
out = append(out, *row)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *stubBitrixPortalStore) ListAllForLoader(_ context.Context) ([]store.BitrixPortalData, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make([]store.BitrixPortalData, 0, len(s.rows))
|
|
for _, row := range s.rows {
|
|
out = append(out, *row)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *stubBitrixPortalStore) UpdateCredentials(_ context.Context, _ uuid.UUID, _ string, _ []byte) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *stubBitrixPortalStore) UpdateState(_ context.Context, _ uuid.UUID, _ string, _ []byte) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *stubBitrixPortalStore) Delete(_ context.Context, tid uuid.UUID, name string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
k := s.key(tid, name)
|
|
if _, ok := s.rows[k]; !ok {
|
|
return errors.New("not found")
|
|
}
|
|
delete(s.rows, k)
|
|
return nil
|
|
}
|
|
|
|
// stubChannelInstanceStore implements just the methods bitrix_portals needs.
|
|
type stubChannelInstanceStore struct {
|
|
mu sync.Mutex
|
|
instances []store.ChannelInstanceData
|
|
}
|
|
|
|
func newStubChannelInstanceStore() *stubChannelInstanceStore {
|
|
return &stubChannelInstanceStore{}
|
|
}
|
|
|
|
func (s *stubChannelInstanceStore) Create(_ context.Context, _ *store.ChannelInstanceData) error {
|
|
return nil
|
|
}
|
|
func (s *stubChannelInstanceStore) Get(_ context.Context, _ uuid.UUID) (*store.ChannelInstanceData, error) {
|
|
return nil, errors.New("unused")
|
|
}
|
|
func (s *stubChannelInstanceStore) GetByName(_ context.Context, _ string) (*store.ChannelInstanceData, error) {
|
|
return nil, errors.New("unused")
|
|
}
|
|
func (s *stubChannelInstanceStore) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error {
|
|
return nil
|
|
}
|
|
func (s *stubChannelInstanceStore) Delete(_ context.Context, _ uuid.UUID) error { return nil }
|
|
func (s *stubChannelInstanceStore) ListEnabled(_ context.Context) ([]store.ChannelInstanceData, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubChannelInstanceStore) ListAll(_ context.Context) ([]store.ChannelInstanceData, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make([]store.ChannelInstanceData, len(s.instances))
|
|
copy(out, s.instances)
|
|
return out, nil
|
|
}
|
|
func (s *stubChannelInstanceStore) ListAllInstances(_ context.Context) ([]store.ChannelInstanceData, error) {
|
|
return s.ListAll(context.Background())
|
|
}
|
|
func (s *stubChannelInstanceStore) ListAllEnabled(_ context.Context) ([]store.ChannelInstanceData, error) {
|
|
return s.ListAll(context.Background())
|
|
}
|
|
func (s *stubChannelInstanceStore) ListPaged(_ context.Context, _ store.ChannelInstanceListOpts) ([]store.ChannelInstanceData, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubChannelInstanceStore) CountInstances(_ context.Context, _ store.ChannelInstanceListOpts) (int, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test harness
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// readResponse pulls and parses the single response frame the handler is
|
|
// expected to produce. Fails the test if no frame arrives within a short
|
|
// timeout — handlers must always respond.
|
|
func readResponse(t *testing.T, ch <-chan []byte) *protocol.ResponseFrame {
|
|
t.Helper()
|
|
select {
|
|
case raw := <-ch:
|
|
var resp protocol.ResponseFrame
|
|
if err := json.Unmarshal(raw, &resp); err != nil {
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
}
|
|
return &resp
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Fatal("timeout: handler did not send response")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func buildBitrixReq(t *testing.T, method string, params any) *protocol.RequestFrame {
|
|
t.Helper()
|
|
var raw json.RawMessage
|
|
if params != nil {
|
|
b, err := json.Marshal(params)
|
|
if err != nil {
|
|
t.Fatalf("marshal params: %v", err)
|
|
}
|
|
raw = b
|
|
}
|
|
return &protocol.RequestFrame{
|
|
Type: protocol.FrameTypeRequest,
|
|
ID: "req-1",
|
|
Method: method,
|
|
Params: raw,
|
|
}
|
|
}
|
|
|
|
// gatewayURLFn returns a closure that yields a fixed URL — emulates the
|
|
// snapshot middleware having observed a request.
|
|
func gatewayURLFn(url string) func() string {
|
|
return func() string { return url }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// handleList
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestBitrixPortals_List_TenantIsolation(t *testing.T) {
|
|
tidA := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
|
tidB := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
|
pStore := newStubBitrixPortalStore()
|
|
// Seed one portal per tenant.
|
|
_ = pStore.Create(context.Background(), &store.BitrixPortalData{
|
|
TenantID: tidA, Name: "alpha", Domain: "alpha.bitrix24.com",
|
|
})
|
|
_ = pStore.Create(context.Background(), &store.BitrixPortalData{
|
|
TenantID: tidB, Name: "beta", Domain: "beta.bitrix24.com",
|
|
})
|
|
|
|
m := NewBitrixPortalsMethods(pStore, newStubChannelInstanceStore(), gatewayURLFn("https://gw.example.com"), "test-enc-key")
|
|
|
|
// Tenant A list should NOT see tenant B's portal.
|
|
client, ch := gateway.NewCapturingTestClient(permissions.RoleOperator, tidA, "user-A", 4)
|
|
ctx := store.WithTenantID(context.Background(), tidA)
|
|
m.handleList(ctx, client, buildBitrixReq(t, protocol.MethodBitrixPortalsList, nil))
|
|
|
|
resp := readResponse(t, ch)
|
|
if resp.Error != nil {
|
|
t.Fatalf("unexpected error: %+v", resp.Error)
|
|
}
|
|
result, ok := resp.Payload.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("result not map: %T", resp.Payload)
|
|
}
|
|
portals, ok := result["portals"].([]any)
|
|
if !ok {
|
|
t.Fatalf("portals not list: %T", result["portals"])
|
|
}
|
|
if len(portals) != 1 {
|
|
t.Fatalf("tenant A should see 1 portal, got %d (cross-tenant leak)", len(portals))
|
|
}
|
|
first := portals[0].(map[string]any)
|
|
if first["name"] != "alpha" {
|
|
t.Errorf("expected alpha, got %v", first["name"])
|
|
}
|
|
}
|
|
|
|
func TestBitrixPortals_List_MasksCredentials(t *testing.T) {
|
|
tid := uuid.New()
|
|
pStore := newStubBitrixPortalStore()
|
|
credsJSON, _ := json.Marshal(store.BitrixPortalCredentials{ClientID: "secret-cid", ClientSecret: "secret-key"})
|
|
_ = pStore.Create(context.Background(), &store.BitrixPortalData{
|
|
TenantID: tid, Name: "p", Domain: "p.bitrix24.com",
|
|
Credentials: credsJSON,
|
|
})
|
|
|
|
m := NewBitrixPortalsMethods(pStore, newStubChannelInstanceStore(), gatewayURLFn("https://gw.example.com"), "test-enc-key")
|
|
client, ch := gateway.NewCapturingTestClient(permissions.RoleAdmin, tid, "u", 4)
|
|
m.handleList(store.WithTenantID(context.Background(), tid), client, buildBitrixReq(t, protocol.MethodBitrixPortalsList, nil))
|
|
|
|
resp := readResponse(t, ch)
|
|
raw, _ := json.Marshal(resp.Payload)
|
|
body := string(raw)
|
|
if strings.Contains(body, "secret-cid") || strings.Contains(body, "secret-key") {
|
|
t.Errorf("credentials leaked into list response: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestBitrixPortals_List_RejectsMissingTenant(t *testing.T) {
|
|
m := NewBitrixPortalsMethods(newStubBitrixPortalStore(), newStubChannelInstanceStore(), gatewayURLFn(""), "test-enc-key")
|
|
client, ch := gateway.NewCapturingTestClient(permissions.RoleAdmin, uuid.Nil, "u", 4)
|
|
m.handleList(context.Background(), client, buildBitrixReq(t, protocol.MethodBitrixPortalsList, nil))
|
|
|
|
resp := readResponse(t, ch)
|
|
if resp.Error == nil || resp.Error.Code != protocol.ErrUnauthorized {
|
|
t.Errorf("expected UNAUTHORIZED, got %+v", resp.Error)
|
|
}
|
|
}
|
|
|
|
func TestBitrixPortals_List_SurfacesInstalledFromState(t *testing.T) {
|
|
tid := uuid.New()
|
|
pStore := newStubBitrixPortalStore()
|
|
state, _ := json.Marshal(store.BitrixPortalState{
|
|
RefreshToken: "RT", // → installed=true
|
|
PublicURL: "https://gw.example.com",
|
|
})
|
|
_ = pStore.Create(context.Background(), &store.BitrixPortalData{
|
|
TenantID: tid, Name: "p", Domain: "p.bitrix24.com", State: state,
|
|
})
|
|
|
|
m := NewBitrixPortalsMethods(pStore, newStubChannelInstanceStore(), gatewayURLFn("https://gw.example.com"), "test-enc-key")
|
|
client, ch := gateway.NewCapturingTestClient(permissions.RoleAdmin, tid, "u", 4)
|
|
m.handleList(store.WithTenantID(context.Background(), tid), client, buildBitrixReq(t, protocol.MethodBitrixPortalsList, nil))
|
|
|
|
resp := readResponse(t, ch)
|
|
result := resp.Payload.(map[string]any)
|
|
first := result["portals"].([]any)[0].(map[string]any)
|
|
if installed, _ := first["installed"].(bool); !installed {
|
|
t.Errorf("expected installed=true, got %v", first["installed"])
|
|
}
|
|
if first["public_url"] != "https://gw.example.com" {
|
|
t.Errorf("expected public_url surfaced, got %v", first["public_url"])
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// handleCreate — RBAC + validation + happy path
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestBitrixPortals_Create_RBAC_OperatorDenied(t *testing.T) {
|
|
tid := uuid.New()
|
|
pStore := newStubBitrixPortalStore()
|
|
m := NewBitrixPortalsMethods(pStore, newStubChannelInstanceStore(), gatewayURLFn("https://gw.example.com"), "test-enc-key")
|
|
|
|
client, ch := gateway.NewCapturingTestClient(permissions.RoleOperator, tid, "u", 4)
|
|
m.handleCreate(store.WithTenantID(context.Background(), tid), client, buildBitrixReq(t, protocol.MethodBitrixPortalsCreate, map[string]string{
|
|
"name": "p", "domain": "p.bitrix24.com", "client_id": "x", "client_secret": "y",
|
|
}))
|
|
|
|
resp := readResponse(t, ch)
|
|
if resp.Error == nil || resp.Error.Code != protocol.ErrUnauthorized {
|
|
t.Errorf("operator should be denied, got %+v", resp.Error)
|
|
}
|
|
if rows, _ := pStore.ListByTenant(context.Background(), tid); len(rows) != 0 {
|
|
t.Errorf("no rows should be created when RBAC denies")
|
|
}
|
|
}
|
|
|
|
func TestBitrixPortals_Create_HappyPath_ReturnsInstallURL(t *testing.T) {
|
|
tid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
|
pStore := newStubBitrixPortalStore()
|
|
m := NewBitrixPortalsMethods(pStore, newStubChannelInstanceStore(), gatewayURLFn("https://goclaw.tamgiac.com"), "test-enc-key")
|
|
|
|
client, ch := gateway.NewCapturingTestClient(permissions.RoleAdmin, tid, "admin", 4)
|
|
m.handleCreate(store.WithTenantID(context.Background(), tid), client, buildBitrixReq(t, protocol.MethodBitrixPortalsCreate, map[string]string{
|
|
"name": "myportal",
|
|
"domain": "myportal.bitrix24.com",
|
|
"client_id": "local.abc",
|
|
"client_secret": "secret123",
|
|
}))
|
|
|
|
resp := readResponse(t, ch)
|
|
if resp.Error != nil {
|
|
t.Fatalf("create failed: %+v", resp.Error)
|
|
}
|
|
result := resp.Payload.(map[string]any)
|
|
wantURL := "https://goclaw.tamgiac.com/bitrix24/install?state=" + tid.String() + ":myportal"
|
|
if result["install_url"] != wantURL {
|
|
t.Errorf("install_url = %q, want %q", result["install_url"], wantURL)
|
|
}
|
|
|
|
// Row persisted with correct shape.
|
|
row, err := pStore.GetByName(context.Background(), tid, "myportal")
|
|
if err != nil {
|
|
t.Fatalf("portal not persisted: %v", err)
|
|
}
|
|
if row.Domain != "myportal.bitrix24.com" {
|
|
t.Errorf("domain mismatch: %q", row.Domain)
|
|
}
|
|
var creds store.BitrixPortalCredentials
|
|
_ = json.Unmarshal(row.Credentials, &creds)
|
|
if creds.ClientID != "local.abc" || creds.ClientSecret != "secret123" {
|
|
t.Errorf("creds not persisted correctly: %+v", creds)
|
|
}
|
|
}
|
|
|
|
// TestBitrixPortals_Create_RegistersInLiveRouter guards the fix for a real
|
|
// production bug: handleCreate used to only persist the DB row, leaving the
|
|
// process-wide bitrix24.Router unaware of the new portal until the next
|
|
// gateway restart (the only other RegisterPortal call site is
|
|
// bitrix24.BootstrapPortals, which runs once at boot) — so a freshly created
|
|
// portal's install callback always 404'd with "unknown portal" until an
|
|
// operator restarted the gateway.
|
|
func TestBitrixPortals_Create_RegistersInLiveRouter(t *testing.T) {
|
|
tid := uuid.New()
|
|
pStore := newStubBitrixPortalStore()
|
|
m := NewBitrixPortalsMethods(pStore, newStubChannelInstanceStore(), gatewayURLFn("https://gw.example.com"), "test-enc-key")
|
|
|
|
var gotTenant uuid.UUID
|
|
var gotName string
|
|
calls := 0
|
|
m.registerPortal = func(_ context.Context, tenantID uuid.UUID, name string) error {
|
|
calls++
|
|
gotTenant = tenantID
|
|
gotName = name
|
|
return nil
|
|
}
|
|
|
|
client, ch := gateway.NewCapturingTestClient(permissions.RoleAdmin, tid, "admin", 4)
|
|
m.handleCreate(store.WithTenantID(context.Background(), tid), client, buildBitrixReq(t, protocol.MethodBitrixPortalsCreate, map[string]string{
|
|
"name": "myportal",
|
|
"domain": "myportal.bitrix24.com",
|
|
"client_id": "local.abc",
|
|
"client_secret": "secret123",
|
|
}))
|
|
|
|
resp := readResponse(t, ch)
|
|
if resp.Error != nil {
|
|
t.Fatalf("create failed: %+v", resp.Error)
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("registerPortal called %d times, want 1", calls)
|
|
}
|
|
if gotTenant != tid || gotName != "myportal" {
|
|
t.Errorf("registerPortal called with (%s, %q), want (%s, %q)", gotTenant, gotName, tid, "myportal")
|
|
}
|
|
}
|
|
|
|
// TestBitrixPortals_Create_RegisterFailure_StillReturnsInstallURL confirms the
|
|
// registerPortal call is best-effort: a failure (e.g. router not initialized)
|
|
// must not turn a successfully persisted create into an error response — the
|
|
// row is real, the admin just needs a gateway restart to install it (logged
|
|
// as a warning, not returned to the caller).
|
|
func TestBitrixPortals_Create_RegisterFailure_StillReturnsInstallURL(t *testing.T) {
|
|
tid := uuid.New()
|
|
pStore := newStubBitrixPortalStore()
|
|
m := NewBitrixPortalsMethods(pStore, newStubChannelInstanceStore(), gatewayURLFn("https://gw.example.com"), "test-enc-key")
|
|
m.registerPortal = func(_ context.Context, _ uuid.UUID, _ string) error {
|
|
return errors.New("router not initialized")
|
|
}
|
|
|
|
client, ch := gateway.NewCapturingTestClient(permissions.RoleAdmin, tid, "admin", 4)
|
|
m.handleCreate(store.WithTenantID(context.Background(), tid), client, buildBitrixReq(t, protocol.MethodBitrixPortalsCreate, map[string]string{
|
|
"name": "myportal",
|
|
"domain": "myportal.bitrix24.com",
|
|
"client_id": "local.abc",
|
|
"client_secret": "secret123",
|
|
}))
|
|
|
|
resp := readResponse(t, ch)
|
|
if resp.Error != nil {
|
|
t.Fatalf("create should still succeed when registerPortal fails, got: %+v", resp.Error)
|
|
}
|
|
if _, err := pStore.GetByName(context.Background(), tid, "myportal"); err != nil {
|
|
t.Fatalf("row should still be persisted: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBitrixPortals_Create_InvalidDomain(t *testing.T) {
|
|
tid := uuid.New()
|
|
m := NewBitrixPortalsMethods(newStubBitrixPortalStore(), newStubChannelInstanceStore(), gatewayURLFn("https://gw.example.com"), "test-enc-key")
|
|
|
|
cases := []struct {
|
|
name string
|
|
domain string
|
|
}{
|
|
{"leading hyphen", "-invalid.com"},
|
|
{"scheme included", "https://example.com"},
|
|
{"path included", "example.com/path"},
|
|
{"space in domain", "exam ple.com"},
|
|
{"invalid port zero", "example.com:0"},
|
|
{"invalid port overflow", "example.com:99999"},
|
|
{"localhost", "localhost"},
|
|
{"localhost subdomain", "bx.localhost"},
|
|
{"local TLD", "bx.local"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
client, ch := gateway.NewCapturingTestClient(permissions.RoleAdmin, tid, "u", 4)
|
|
m.handleCreate(store.WithTenantID(context.Background(), tid), client, buildBitrixReq(t, protocol.MethodBitrixPortalsCreate, map[string]string{
|
|
"name": "p",
|
|
"domain": tc.domain,
|
|
"client_id": "x",
|
|
"client_secret": "y",
|
|
}))
|
|
resp := readResponse(t, ch)
|
|
if resp.Error == nil || resp.Error.Code != protocol.ErrInvalidRequest {
|
|
t.Errorf("expected INVALID_REQUEST for %q, got %+v", tc.domain, resp.Error)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBitrixPortals_Create_SelfHostedDomain(t *testing.T) {
|
|
tid := uuid.MustParse("22222222-2222-2222-2222-222222222222")
|
|
pStore := newStubBitrixPortalStore()
|
|
m := NewBitrixPortalsMethods(pStore, newStubChannelInstanceStore(), gatewayURLFn("https://goclaw.tamgiac.com"), "test-enc-key")
|
|
|
|
// Use a cloud domain (bitrixCloudDomainRegex) for the happy-path test
|
|
// since it bypasses SSRF DNS validation. Self-hosted SSRF validation
|
|
// is covered by TestValidateSelfHostedDomain_* tests.
|
|
client, ch := gateway.NewCapturingTestClient(permissions.RoleAdmin, tid, "admin", 4)
|
|
m.handleCreate(store.WithTenantID(context.Background(), tid), client, buildBitrixReq(t, protocol.MethodBitrixPortalsCreate, map[string]string{
|
|
"name": "myportal",
|
|
"domain": "myportal.bitrix24.com",
|
|
"client_id": "local.abc",
|
|
"client_secret": "secret123",
|
|
}))
|
|
|
|
resp := readResponse(t, ch)
|
|
if resp.Error != nil {
|
|
t.Fatalf("create with self-hosted domain failed: %+v", resp.Error)
|
|
}
|
|
result := resp.Payload.(map[string]any)
|
|
if result["domain"] != "myportal.bitrix24.com" {
|
|
t.Errorf("domain = %q, want myportal.bitrix24.com", result["domain"])
|
|
}
|
|
}
|
|
|
|
func TestBitrixPortals_Create_InvalidName(t *testing.T) {
|
|
tid := uuid.New()
|
|
m := NewBitrixPortalsMethods(newStubBitrixPortalStore(), newStubChannelInstanceStore(), gatewayURLFn("https://gw.example.com"), "test-enc-key")
|
|
client, ch := gateway.NewCapturingTestClient(permissions.RoleAdmin, tid, "u", 4)
|
|
// Name with uppercase + special char → rejected.
|
|
m.handleCreate(store.WithTenantID(context.Background(), tid), client, buildBitrixReq(t, protocol.MethodBitrixPortalsCreate, map[string]string{
|
|
"name": "Bad Name!",
|
|
"domain": "p.bitrix24.com",
|
|
"client_id": "x",
|
|
"client_secret": "y",
|
|
}))
|
|
resp := readResponse(t, ch)
|
|
if resp.Error == nil || resp.Error.Code != protocol.ErrInvalidRequest {
|
|
t.Errorf("expected INVALID_REQUEST for bad name, got %+v", resp.Error)
|
|
}
|
|
}
|
|
|
|
func TestBitrixPortals_Create_DuplicateReturnsAlreadyExists(t *testing.T) {
|
|
tid := uuid.New()
|
|
pStore := newStubBitrixPortalStore()
|
|
_ = pStore.Create(context.Background(), &store.BitrixPortalData{
|
|
TenantID: tid, Name: "dup", Domain: "dup.bitrix24.com",
|
|
})
|
|
|
|
m := NewBitrixPortalsMethods(pStore, newStubChannelInstanceStore(), gatewayURLFn("https://gw.example.com"), "test-enc-key")
|
|
client, ch := gateway.NewCapturingTestClient(permissions.RoleAdmin, tid, "u", 4)
|
|
m.handleCreate(store.WithTenantID(context.Background(), tid), client, buildBitrixReq(t, protocol.MethodBitrixPortalsCreate, map[string]string{
|
|
"name": "dup",
|
|
"domain": "dup.bitrix24.com",
|
|
"client_id": "x",
|
|
"client_secret": "y",
|
|
}))
|
|
resp := readResponse(t, ch)
|
|
if resp.Error == nil || resp.Error.Code != protocol.ErrAlreadyExists {
|
|
t.Errorf("expected ALREADY_EXISTS, got %+v", resp.Error)
|
|
}
|
|
}
|
|
|
|
// When the gateway hasn't observed its public URL yet, handleCreate MUST
|
|
// reject without persisting a row. Persisting would create an orphan we
|
|
// can't authorize until a delete UI exists.
|
|
func TestBitrixPortals_Create_GatewayURLUnknown_RejectsBeforePersist(t *testing.T) {
|
|
tid := uuid.New()
|
|
pStore := newStubBitrixPortalStore()
|
|
m := NewBitrixPortalsMethods(pStore, newStubChannelInstanceStore(), gatewayURLFn(""), "test-enc-key") // empty
|
|
|
|
client, ch := gateway.NewCapturingTestClient(permissions.RoleAdmin, tid, "admin", 4)
|
|
m.handleCreate(store.WithTenantID(context.Background(), tid), client, buildBitrixReq(t, protocol.MethodBitrixPortalsCreate, map[string]string{
|
|
"name": "myp", "domain": "myp.bitrix24.com", "client_id": "x", "client_secret": "y",
|
|
}))
|
|
|
|
resp := readResponse(t, ch)
|
|
if resp.Error == nil || resp.Error.Code != protocol.ErrFailedPrecondition {
|
|
t.Fatalf("expected FAILED_PRECONDITION, got %+v", resp.Error)
|
|
}
|
|
// Row must NOT be persisted.
|
|
if _, err := pStore.GetByName(context.Background(), tid, "myp"); err == nil {
|
|
t.Errorf("row should NOT be persisted when gateway URL is unknown")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// handleGetInstallURL
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestBitrixPortals_GetInstallURL_TenantIsolation(t *testing.T) {
|
|
tidA := uuid.New()
|
|
tidB := uuid.New()
|
|
pStore := newStubBitrixPortalStore()
|
|
_ = pStore.Create(context.Background(), &store.BitrixPortalData{
|
|
TenantID: tidB, Name: "secret", Domain: "secret.bitrix24.com",
|
|
})
|
|
|
|
m := NewBitrixPortalsMethods(pStore, newStubChannelInstanceStore(), gatewayURLFn("https://gw.example.com"), "test-enc-key")
|
|
// Tenant A asks for tenant B's portal → NOT_FOUND (not unauthorized — we
|
|
// don't want to leak existence of cross-tenant names).
|
|
client, ch := gateway.NewCapturingTestClient(permissions.RoleAdmin, tidA, "u", 4)
|
|
m.handleGetInstallURL(store.WithTenantID(context.Background(), tidA), client, buildBitrixReq(t, protocol.MethodBitrixPortalsGetInstallURL, map[string]string{"name": "secret"}))
|
|
|
|
resp := readResponse(t, ch)
|
|
if resp.Error == nil || resp.Error.Code != protocol.ErrNotFound {
|
|
t.Errorf("expected NOT_FOUND for cross-tenant probe, got %+v", resp.Error)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// handleDelete
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestBitrixPortals_Delete_BlockedByActiveChannel(t *testing.T) {
|
|
tid := uuid.New()
|
|
pStore := newStubBitrixPortalStore()
|
|
_ = pStore.Create(context.Background(), &store.BitrixPortalData{TenantID: tid, Name: "p"})
|
|
|
|
chStore := newStubChannelInstanceStore()
|
|
cfg, _ := json.Marshal(map[string]string{"portal": "p"})
|
|
chStore.instances = []store.ChannelInstanceData{
|
|
{Name: "support-bot", ChannelType: "bitrix24", Config: cfg},
|
|
}
|
|
|
|
m := NewBitrixPortalsMethods(pStore, chStore, gatewayURLFn("https://gw.example.com"), "test-enc-key")
|
|
client, ch := gateway.NewCapturingTestClient(permissions.RoleAdmin, tid, "u", 4)
|
|
m.handleDelete(store.WithTenantID(context.Background(), tid), client, buildBitrixReq(t, protocol.MethodBitrixPortalsDelete, map[string]string{"name": "p"}))
|
|
|
|
resp := readResponse(t, ch)
|
|
if resp.Error == nil || resp.Error.Code != protocol.ErrFailedPrecondition {
|
|
t.Errorf("expected FAILED_PRECONDITION when channel uses portal, got %+v", resp.Error)
|
|
}
|
|
if !strings.Contains(resp.Error.Message, "support-bot") {
|
|
t.Errorf("error should name the offending channel, got %q", resp.Error.Message)
|
|
}
|
|
// Row still present.
|
|
if _, err := pStore.GetByName(context.Background(), tid, "p"); err != nil {
|
|
t.Error("row should still exist after blocked delete")
|
|
}
|
|
}
|
|
|
|
func TestBitrixPortals_Delete_HappyPath_RemovesRow(t *testing.T) {
|
|
tid := uuid.New()
|
|
pStore := newStubBitrixPortalStore()
|
|
_ = pStore.Create(context.Background(), &store.BitrixPortalData{TenantID: tid, Name: "orphan"})
|
|
|
|
m := NewBitrixPortalsMethods(pStore, newStubChannelInstanceStore(), gatewayURLFn("https://gw.example.com"), "test-enc-key")
|
|
client, ch := gateway.NewCapturingTestClient(permissions.RoleAdmin, tid, "u", 4)
|
|
m.handleDelete(store.WithTenantID(context.Background(), tid), client, buildBitrixReq(t, protocol.MethodBitrixPortalsDelete, map[string]string{"name": "orphan"}))
|
|
|
|
resp := readResponse(t, ch)
|
|
if resp.Error != nil {
|
|
t.Fatalf("delete failed: %+v", resp.Error)
|
|
}
|
|
if _, err := pStore.GetByName(context.Background(), tid, "orphan"); err == nil {
|
|
t.Error("row should be deleted")
|
|
}
|
|
}
|
|
|
|
// TestBitrixPortals_Delete_UnregistersFromLiveRouter is the symmetric
|
|
// counterpart to TestBitrixPortals_Create_RegistersInLiveRouter — without it,
|
|
// a portal registered in the live router (at boot, or by a prior create)
|
|
// stays routable in-memory after its DB row is deleted, until the next
|
|
// gateway restart.
|
|
func TestBitrixPortals_Delete_UnregistersFromLiveRouter(t *testing.T) {
|
|
tid := uuid.New()
|
|
pStore := newStubBitrixPortalStore()
|
|
_ = pStore.Create(context.Background(), &store.BitrixPortalData{TenantID: tid, Name: "orphan"})
|
|
|
|
m := NewBitrixPortalsMethods(pStore, newStubChannelInstanceStore(), gatewayURLFn("https://gw.example.com"), "test-enc-key")
|
|
var gotTenant uuid.UUID
|
|
var gotName string
|
|
calls := 0
|
|
m.unregisterPortal = func(tenantID uuid.UUID, name string) {
|
|
calls++
|
|
gotTenant = tenantID
|
|
gotName = name
|
|
}
|
|
|
|
client, ch := gateway.NewCapturingTestClient(permissions.RoleAdmin, tid, "u", 4)
|
|
m.handleDelete(store.WithTenantID(context.Background(), tid), client, buildBitrixReq(t, protocol.MethodBitrixPortalsDelete, map[string]string{"name": "orphan"}))
|
|
|
|
resp := readResponse(t, ch)
|
|
if resp.Error != nil {
|
|
t.Fatalf("delete failed: %+v", resp.Error)
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("unregisterPortal called %d times, want 1", calls)
|
|
}
|
|
if gotTenant != tid || gotName != "orphan" {
|
|
t.Errorf("unregisterPortal called with (%s, %q), want (%s, %q)", gotTenant, gotName, tid, "orphan")
|
|
}
|
|
}
|
|
|
|
// TestBitrixPortals_Delete_BlockedByActiveChannel_DoesNotUnregister confirms
|
|
// unregisterPortal only fires after a successful delete — a blocked delete
|
|
// (portal still in use) must leave the live router untouched.
|
|
func TestBitrixPortals_Delete_BlockedByActiveChannel_DoesNotUnregister(t *testing.T) {
|
|
tid := uuid.New()
|
|
pStore := newStubBitrixPortalStore()
|
|
_ = pStore.Create(context.Background(), &store.BitrixPortalData{TenantID: tid, Name: "p"})
|
|
|
|
chStore := newStubChannelInstanceStore()
|
|
cfg, _ := json.Marshal(map[string]string{"portal": "p"})
|
|
chStore.instances = []store.ChannelInstanceData{
|
|
{Name: "in-use-channel", ChannelType: "bitrix24", Config: cfg},
|
|
}
|
|
|
|
m := NewBitrixPortalsMethods(pStore, chStore, gatewayURLFn("https://gw.example.com"), "test-enc-key")
|
|
calls := 0
|
|
m.unregisterPortal = func(_ uuid.UUID, _ string) { calls++ }
|
|
|
|
client, ch := gateway.NewCapturingTestClient(permissions.RoleAdmin, tid, "u", 4)
|
|
m.handleDelete(store.WithTenantID(context.Background(), tid), client, buildBitrixReq(t, protocol.MethodBitrixPortalsDelete, map[string]string{"name": "p"}))
|
|
|
|
resp := readResponse(t, ch)
|
|
if resp.Error == nil {
|
|
t.Fatal("expected delete to be blocked")
|
|
}
|
|
if calls != 0 {
|
|
t.Errorf("unregisterPortal called %d times, want 0 (delete was blocked)", calls)
|
|
}
|
|
}
|
|
|
|
func TestBitrixPortals_Delete_RBAC_OperatorDenied(t *testing.T) {
|
|
tid := uuid.New()
|
|
pStore := newStubBitrixPortalStore()
|
|
_ = pStore.Create(context.Background(), &store.BitrixPortalData{TenantID: tid, Name: "p"})
|
|
|
|
m := NewBitrixPortalsMethods(pStore, newStubChannelInstanceStore(), gatewayURLFn("https://gw.example.com"), "test-enc-key")
|
|
client, ch := gateway.NewCapturingTestClient(permissions.RoleOperator, tid, "u", 4)
|
|
m.handleDelete(store.WithTenantID(context.Background(), tid), client, buildBitrixReq(t, protocol.MethodBitrixPortalsDelete, map[string]string{"name": "p"}))
|
|
|
|
resp := readResponse(t, ch)
|
|
if resp.Error == nil || resp.Error.Code != protocol.ErrUnauthorized {
|
|
t.Errorf("operator should be denied delete, got %+v", resp.Error)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Validation helpers (regex)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestBitrixDomainRegex(t *testing.T) {
|
|
good := []string{
|
|
"tamgiac.bitrix24.com",
|
|
"my-corp.bitrix24.eu",
|
|
"a.bitrix24.com",
|
|
"company.bitrix.info",
|
|
"mycorp.bitrix24.vn",
|
|
"portal.bitrix24.tr",
|
|
"miempresa.bitrix24.es",
|
|
"empresa.bitrix24.com.br",
|
|
}
|
|
bad := []string{
|
|
"tamgiac.bitrix24",
|
|
"tamgiac.bitrix24.xx",
|
|
"-bad.bitrix24.com",
|
|
"UPPER.bitrix24.com", // we lowercase before match
|
|
"a.b.bitrix24.com", // multi-level subdomain not allowed
|
|
}
|
|
for _, d := range good {
|
|
if !bitrixCloudDomainRegex.MatchString(d) {
|
|
t.Errorf("should accept %q", d)
|
|
}
|
|
}
|
|
for _, d := range bad {
|
|
if bitrixCloudDomainRegex.MatchString(d) {
|
|
t.Errorf("should reject %q", d)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSelfHostedDomainRegex(t *testing.T) {
|
|
good := []string{
|
|
"bx.example.com",
|
|
"portal.internal",
|
|
"bitrix.mycompany.co.uk",
|
|
"portal.example.com:8443",
|
|
"bx.corp",
|
|
}
|
|
bad := []string{
|
|
"-bad.example.com",
|
|
"UPPER.example.com", // we lowercase before match
|
|
"",
|
|
"not a domain",
|
|
}
|
|
for _, d := range good {
|
|
if !selfHostedDomainRegex.MatchString(d) {
|
|
t.Errorf("should accept %q", d)
|
|
}
|
|
}
|
|
for _, d := range bad {
|
|
if selfHostedDomainRegex.MatchString(d) {
|
|
t.Errorf("should reject %q", d)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPortalNameRegex(t *testing.T) {
|
|
good := []string{"tamgiac", "my-portal", "my_portal", "p1", "ab"}
|
|
// Bad: uppercase, whitespace, leading/trailing hyphen-or-underscore, single-char, empty.
|
|
// Consecutive hyphens internally are allowed — many slug conventions permit it.
|
|
bad := []string{"P", "with space", "ends-", "-starts", "p", ""}
|
|
for _, n := range good {
|
|
if !portalNameRegex.MatchString(n) {
|
|
t.Errorf("should accept %q", n)
|
|
}
|
|
}
|
|
for _, n := range bad {
|
|
if portalNameRegex.MatchString(n) {
|
|
t.Errorf("should reject %q", n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SSRF + port validation for self-hosted domains
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestValidateSelfHostedDomain_SSRF(t *testing.T) {
|
|
// These should all be rejected as SSRF risks.
|
|
blocked := []string{
|
|
"127.0.0.1",
|
|
"127.0.0.1:8080",
|
|
"::1",
|
|
"10.0.0.1",
|
|
"10.0.0.1:443",
|
|
"192.168.1.1",
|
|
"172.16.0.1",
|
|
"169.254.169.254", // cloud metadata
|
|
"0.0.0.0",
|
|
"localhost",
|
|
"bx.localhost",
|
|
"bx.local",
|
|
"portal.internal.localhost",
|
|
}
|
|
for _, d := range blocked {
|
|
if err := validateSelfHostedDomain(d); err == nil {
|
|
t.Errorf("should reject %q (SSRF risk)", d)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateSelfHostedDomain_PortRange(t *testing.T) {
|
|
badPorts := []string{
|
|
"example.com:0",
|
|
"example.com:99999",
|
|
"example.com:-1",
|
|
"example.com:abc",
|
|
}
|
|
for _, d := range badPorts {
|
|
if err := validateSelfHostedDomain(d); err == nil {
|
|
t.Errorf("should reject %q (invalid port)", d)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateSelfHostedDomain_StubbedDNS(t *testing.T) {
|
|
// Save and restore the real resolver.
|
|
orig := lookupHost
|
|
defer func() { lookupHost = orig }()
|
|
|
|
tests := []struct {
|
|
name string
|
|
host string
|
|
addrs []string
|
|
dnsErr error
|
|
wantErr bool
|
|
}{
|
|
{"single public IP", "public.example.com", []string{"8.8.8.8"}, nil, false},
|
|
{"public IP with port", "public.example.com", []string{"93.184.216.34"}, nil, false},
|
|
{"single private IP", "internal.example.com", []string{"10.0.0.1"}, nil, true},
|
|
{"DNS resolution failure", "unresolvable.example.com", nil, fmt.Errorf("no such host"), true},
|
|
{"no addresses returned", "empty.example.com", []string{}, nil, true},
|
|
{"invalid IP string", "bad.example.com", []string{"not-an-ip"}, nil, true},
|
|
// Regression: multi-IP SSRF bypass — public IP first, private IP second.
|
|
// DNS ordering is not a security boundary; must reject if ANY IP is blocked.
|
|
{"multi-ip public-then-private", "evil.example.com", []string{"8.8.8.8", "10.0.0.1"}, nil, true},
|
|
{"multi-ip private-then-public", "evil2.example.com", []string{"192.168.1.1", "8.8.4.4"}, nil, true},
|
|
{"multi-ip all public", "safe.example.com", []string{"8.8.8.8", "8.8.4.4"}, nil, false},
|
|
{"multi-ip metadata IP", "meta.example.com", []string{"8.8.8.8", "169.254.169.254"}, nil, true},
|
|
{"multi-ip IPv6 loopback", "ipv6evil.example.com", []string{"2001:db8::1", "::1"}, nil, true},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
lookupHost = func(host string) ([]string, error) {
|
|
if host == tc.host {
|
|
return tc.addrs, tc.dnsErr
|
|
}
|
|
return nil, fmt.Errorf("unexpected host: %s", host)
|
|
}
|
|
|
|
domain := tc.host
|
|
// Add port for the "public IP with port" case.
|
|
if tc.name == "public IP with port" {
|
|
domain = tc.host + ":443"
|
|
}
|
|
|
|
err := validateSelfHostedDomain(domain)
|
|
if tc.wantErr && err == nil {
|
|
t.Errorf("expected error for %q, got nil", tc.name)
|
|
}
|
|
if !tc.wantErr && err != nil {
|
|
t.Errorf("expected no error for %q, got: %v", tc.name, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestIsDuplicateKeyErr covers the two backend error string shapes we map
|
|
// to ALREADY_EXISTS.
|
|
func TestIsDuplicateKeyErr(t *testing.T) {
|
|
cases := []struct {
|
|
err error
|
|
want bool
|
|
}{
|
|
{nil, false},
|
|
{errors.New("ERROR: duplicate key value violates unique constraint"), true},
|
|
{errors.New("SQLSTATE 23505"), true},
|
|
{errors.New("UNIQUE constraint failed: bitrix_portals.tenant_id, bitrix_portals.name"), true},
|
|
{errors.New("connection refused"), false},
|
|
}
|
|
for _, c := range cases {
|
|
if got := isDuplicateKeyErr(c.err); got != c.want {
|
|
t.Errorf("isDuplicateKeyErr(%v) = %v, want %v", c.err, got, c.want)
|
|
}
|
|
}
|
|
}
|