adk-gateway 1.0.0

Multi-channel AI gateway for adk-rust agents — Telegram, Slack, WhatsApp, Discord, Matrix + control panel
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DelegationPermissions, { wouldCreateCycle } from './DelegationPermissions';
import type { AgentRecord, DelegationRule } from '../types';

// Mock the API client
vi.mock('../api/client', () => ({
  api: {
    delegationList: vi.fn(),
    delegationAdd: vi.fn(),
    delegationRemove: vi.fn(),
  },
}));

import { api } from '../api/client';

const mockAgents: AgentRecord[] = [
  {
    id: 'agent-a',
    name: 'Agent A',
    description: '',
    agent_type: 'Llm',
    state: 'running',
    port: null,
    model: 'gpt-4o',
    tools: [],
    instruction: '',
    api_key_env: '',
    auto_start: false,
    channel_bindings: [],
    created_at: '2024-01-01T00:00:00Z',
    updated_at: '2024-01-01T00:00:00Z',
  },
  {
    id: 'agent-b',
    name: 'Agent B',
    description: '',
    agent_type: 'Llm',
    state: 'stopped',
    port: null,
    model: 'gpt-4o',
    tools: [],
    instruction: '',
    api_key_env: '',
    auto_start: false,
    channel_bindings: [],
    created_at: '2024-01-01T00:00:00Z',
    updated_at: '2024-01-01T00:00:00Z',
  },
  {
    id: 'agent-c',
    name: 'Agent C',
    description: '',
    agent_type: 'Llm',
    state: 'running',
    port: null,
    model: 'gpt-4o',
    tools: [],
    instruction: '',
    api_key_env: '',
    auto_start: false,
    channel_bindings: [],
    created_at: '2024-01-01T00:00:00Z',
    updated_at: '2024-01-01T00:00:00Z',
  },
];

const mockPermissions: DelegationRule[] = [
  { caller_id: 'agent-a', target_id: 'agent-b', created_at: '2024-01-01T00:00:00Z' },
];

describe('DelegationPermissions', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('displays current delegation permissions', async () => {
    vi.mocked(api.delegationList).mockResolvedValue({
      ok: true,
      data: mockPermissions,
    });

    render(<DelegationPermissions agents={mockAgents} />);

    await waitFor(() => {
      expect(screen.getByText('agent-a')).toBeInTheDocument();
      expect(screen.getByText('agent-b')).toBeInTheDocument();
    });
  });

  it('shows empty state when no permissions exist', async () => {
    vi.mocked(api.delegationList).mockResolvedValue({
      ok: true,
      data: [],
    });

    render(<DelegationPermissions agents={mockAgents} />);

    await waitFor(() => {
      expect(screen.getByText('No delegation permissions configured')).toBeInTheDocument();
    });
  });

  it('adds a new delegation permission', async () => {
    vi.mocked(api.delegationList).mockResolvedValue({
      ok: true,
      data: [],
    });
    vi.mocked(api.delegationAdd).mockResolvedValue({
      ok: true,
      message: 'Delegation permission added',
    });

    render(<DelegationPermissions agents={mockAgents} />);

    await waitFor(() => {
      expect(screen.getByText('No delegation permissions configured')).toBeInTheDocument();
    });

    // Select caller
    const callerSelect = screen.getByLabelText('Caller agent');
    await userEvent.selectOptions(callerSelect, 'agent-a');

    // Select target
    const targetSelect = screen.getByLabelText('Target agent');
    await userEvent.selectOptions(targetSelect, 'agent-b');

    // Click Add
    const addButton = screen.getByRole('button', { name: /add/i });
    await userEvent.click(addButton);

    await waitFor(() => {
      expect(api.delegationAdd).toHaveBeenCalledWith('agent-a', 'agent-b');
    });
  });

  it('shows remove confirmation dialog', async () => {
    vi.mocked(api.delegationList).mockResolvedValue({
      ok: true,
      data: mockPermissions,
    });

    render(<DelegationPermissions agents={mockAgents} />);

    await waitFor(() => {
      expect(screen.getByText('agent-a')).toBeInTheDocument();
    });

    // Click Remove button
    const removeButton = screen.getByRole('button', { name: /remove/i });
    await userEvent.click(removeButton);

    // Confirmation dialog should appear
    await waitFor(() => {
      expect(screen.getByText(/Remove Delegation Permission/)).toBeInTheDocument();
    });
  });

  it('removes a delegation permission after confirmation', async () => {
    vi.mocked(api.delegationList).mockResolvedValue({
      ok: true,
      data: mockPermissions,
    });
    vi.mocked(api.delegationRemove).mockResolvedValue({
      ok: true,
      message: 'Delegation permission removed',
    });

    render(<DelegationPermissions agents={mockAgents} />);

    await waitFor(() => {
      expect(screen.getByText('agent-a')).toBeInTheDocument();
    });

    // Click Remove button in the table row
    const removeButton = screen.getByRole('button', { name: /remove/i });
    await userEvent.click(removeButton);

    // Confirm removal — the dialog has a "Remove" button with different styling
    const allRemoveButtons = screen.getAllByRole('button', { name: /^Remove$/ });
    // The confirmation dialog button is the last one (in the modal)
    const confirmButton = allRemoveButtons[allRemoveButtons.length - 1];
    await userEvent.click(confirmButton);

    await waitFor(() => {
      expect(api.delegationRemove).toHaveBeenCalledWith('agent-a', 'agent-b');
    });
  });

  it('shows cycle warning when selection would create a cycle', async () => {
    // Existing: agent-a → agent-b
    vi.mocked(api.delegationList).mockResolvedValue({
      ok: true,
      data: mockPermissions,
    });

    render(<DelegationPermissions agents={mockAgents} />);

    await waitFor(() => {
      expect(screen.getByText('agent-a')).toBeInTheDocument();
    });

    // Try to add agent-b → agent-a (would create cycle)
    const callerSelect = screen.getByLabelText('Caller agent');
    await userEvent.selectOptions(callerSelect, 'agent-b');

    const targetSelect = screen.getByLabelText('Target agent');
    await userEvent.selectOptions(targetSelect, 'agent-a');

    // Cycle warning should appear
    await waitFor(() => {
      expect(screen.getByRole('alert')).toBeInTheDocument();
      expect(screen.getByText(/would create a circular delegation chain/)).toBeInTheDocument();
    });

    // Add button should be disabled
    const addButton = screen.getByRole('button', { name: /add/i });
    expect(addButton).toBeDisabled();
  });

  it('does not show cycle warning for valid additions', async () => {
    vi.mocked(api.delegationList).mockResolvedValue({
      ok: true,
      data: mockPermissions,
    });

    render(<DelegationPermissions agents={mockAgents} />);

    await waitFor(() => {
      expect(screen.getByText('agent-a')).toBeInTheDocument();
    });

    // Try to add agent-b → agent-c (no cycle)
    const callerSelect = screen.getByLabelText('Caller agent');
    await userEvent.selectOptions(callerSelect, 'agent-b');

    const targetSelect = screen.getByLabelText('Target agent');
    await userEvent.selectOptions(targetSelect, 'agent-c');

    // No cycle warning should appear
    expect(screen.queryByRole('alert')).not.toBeInTheDocument();

    // Add button should be enabled
    const addButton = screen.getByRole('button', { name: /add/i });
    expect(addButton).not.toBeDisabled();
  });
});

// ── Unit tests for wouldCreateCycle ────────────────────────────────

describe('wouldCreateCycle', () => {
  it('detects self-delegation as a cycle', () => {
    expect(wouldCreateCycle([], 'a', 'a')).toBe(true);
  });

  it('detects direct cycle', () => {
    const perms: DelegationRule[] = [
      { caller_id: 'a', target_id: 'b', created_at: '' },
    ];
    expect(wouldCreateCycle(perms, 'b', 'a')).toBe(true);
  });

  it('detects transitive cycle', () => {
    const perms: DelegationRule[] = [
      { caller_id: 'a', target_id: 'b', created_at: '' },
      { caller_id: 'b', target_id: 'c', created_at: '' },
    ];
    expect(wouldCreateCycle(perms, 'c', 'a')).toBe(true);
  });

  it('allows valid non-cyclic addition', () => {
    const perms: DelegationRule[] = [
      { caller_id: 'a', target_id: 'b', created_at: '' },
    ];
    expect(wouldCreateCycle(perms, 'b', 'c')).toBe(false);
  });

  it('allows addition in disconnected graph', () => {
    const perms: DelegationRule[] = [
      { caller_id: 'a', target_id: 'b', created_at: '' },
      { caller_id: 'c', target_id: 'd', created_at: '' },
    ];
    expect(wouldCreateCycle(perms, 'd', 'a')).toBe(false);
  });
});