Skip to content

Commit e8597ee

Browse files
committed
chore: add unit tests for agent proxy status handling and validation
1 parent ad324ff commit e8597ee

5 files changed

Lines changed: 465 additions & 1 deletion

File tree

backend/internal/api/handlers/orthrus_handler_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,3 +713,112 @@ func TestOrthrusHandler_GetProxyStatus_Connected(t *testing.T) {
713713
assert.Equal(t, "0.0.0.0:2375", resp["bind_address"])
714714
assert.Equal(t, "tcp://charon:2375", resp["connection_string"])
715715
}
716+
717+
// TestOrthrusHandler_SetProxyResolver_TypedNilClearsResolver verifies that
718+
// passing a typed nil pointer (non-nil interface wrapping nil pointer) causes
719+
// SetProxyResolver to clear the stored resolver via the reflect.IsNil branch.
720+
func TestOrthrusHandler_SetProxyResolver_TypedNilClearsResolver(t *testing.T) {
721+
h, _ := newOrthrusTestSetup(t)
722+
723+
// Wire a valid resolver first.
724+
h.SetProxyResolver(&mockProxyResolver{ok: true})
725+
726+
// Pass a typed nil *mockProxyResolver — the interface value is non-nil but
727+
// rv.IsNil() returns true, exercising the h.proxyResolver = nil / return path.
728+
var nilResolver *mockProxyResolver
729+
h.SetProxyResolver(nilResolver)
730+
731+
// Confirm the resolver was cleared: provision an agent and verify
732+
// GetProxyStatus returns agent_online=false (no resolver active).
733+
wProv := httptest.NewRecorder()
734+
cProv, _ := gin.CreateTestContext(wProv)
735+
cProv.Request = httptest.NewRequest(http.MethodPost, "/management/orthrus/agents",
736+
bytes.NewBufferString(`{"name":"typed-nil-agent"}`))
737+
cProv.Request.Header.Set("Content-Type", "application/json")
738+
h.Provision(cProv)
739+
require.Equal(t, http.StatusCreated, wProv.Code)
740+
var provisioned map[string]any
741+
require.NoError(t, json.Unmarshal(wProv.Body.Bytes(), &provisioned))
742+
agentUUID := provisioned["agent"].(map[string]any)["uuid"].(string)
743+
744+
w := httptest.NewRecorder()
745+
c, _ := gin.CreateTestContext(w)
746+
c.Request = httptest.NewRequest(http.MethodGet, "/management/orthrus/agents/"+agentUUID+"/proxy-status", http.NoBody)
747+
c.Params = gin.Params{{Key: "uuid", Value: agentUUID}}
748+
h.GetProxyStatus(c)
749+
750+
assert.Equal(t, http.StatusOK, w.Code)
751+
var resp map[string]any
752+
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
753+
assert.Equal(t, false, resp["agent_online"])
754+
}
755+
756+
// TestOrthrusHandler_Patch_MalformedJSON verifies that sending a body that
757+
// cannot be parsed as JSON triggers the ShouldBindJSON error path → 400.
758+
func TestOrthrusHandler_Patch_MalformedJSON(t *testing.T) {
759+
h, _ := newOrthrusTestSetup(t)
760+
761+
w := httptest.NewRecorder()
762+
c, _ := gin.CreateTestContext(w)
763+
c.Request = httptest.NewRequest(http.MethodPatch, "/management/orthrus/agents/any-uuid",
764+
bytes.NewBufferString(`{not valid json`))
765+
c.Request.Header.Set("Content-Type", "application/json")
766+
c.Params = gin.Params{{Key: "uuid", Value: "any-uuid"}}
767+
768+
h.Patch(c)
769+
770+
assert.Equal(t, http.StatusBadRequest, w.Code)
771+
}
772+
773+
// TestOrthrusHandler_GetProxyStatus_AgentNotFound verifies that requesting
774+
// proxy status for a non-existent agent UUID returns 404.
775+
func TestOrthrusHandler_GetProxyStatus_AgentNotFound(t *testing.T) {
776+
h, _ := newOrthrusTestSetup(t)
777+
778+
w := httptest.NewRecorder()
779+
c, _ := gin.CreateTestContext(w)
780+
c.Request = httptest.NewRequest(http.MethodGet, "/management/orthrus/agents/does-not-exist/proxy-status", http.NoBody)
781+
c.Params = gin.Params{{Key: "uuid", Value: "does-not-exist"}}
782+
783+
h.GetProxyStatus(c)
784+
785+
assert.Equal(t, http.StatusNotFound, w.Code)
786+
}
787+
788+
// TestOrthrusHandler_GetProxyStatus_Connected_WithError verifies that when the
789+
// resolver returns a status with a non-empty Error field, the error is included
790+
// in the response — covering the if status.Error != "" branch.
791+
func TestOrthrusHandler_GetProxyStatus_Connected_WithError(t *testing.T) {
792+
h, _ := newOrthrusTestSetup(t)
793+
794+
wProv := httptest.NewRecorder()
795+
cProv, _ := gin.CreateTestContext(wProv)
796+
cProv.Request = httptest.NewRequest(http.MethodPost, "/management/orthrus/agents",
797+
bytes.NewBufferString(`{"name":"error-agent"}`))
798+
cProv.Request.Header.Set("Content-Type", "application/json")
799+
h.Provision(cProv)
800+
require.Equal(t, http.StatusCreated, wProv.Code)
801+
var provisioned map[string]any
802+
require.NoError(t, json.Unmarshal(wProv.Body.Bytes(), &provisioned))
803+
agentUUID := provisioned["agent"].(map[string]any)["uuid"].(string)
804+
805+
errStatus := orthrus.ExternalProxyStatus{
806+
ConfiguredPort: 2375,
807+
Active: false,
808+
Error: "bind failed: address already in use",
809+
}
810+
h.SetProxyResolver(&mockProxyResolver{status: errStatus, ok: true})
811+
812+
w := httptest.NewRecorder()
813+
c, _ := gin.CreateTestContext(w)
814+
c.Request = httptest.NewRequest(http.MethodGet, "/management/orthrus/agents/"+agentUUID+"/proxy-status", http.NoBody)
815+
c.Params = gin.Params{{Key: "uuid", Value: agentUUID}}
816+
h.GetProxyStatus(c)
817+
818+
assert.Equal(t, http.StatusOK, w.Code)
819+
var resp map[string]any
820+
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
821+
assert.Equal(t, true, resp["agent_online"])
822+
assert.Equal(t, false, resp["active"])
823+
assert.Equal(t, "bind failed: address already in use", resp["error"])
824+
}

frontend/src/api/__tests__/orthrus.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
getInstallSnippets,
1111
patchAgent,
1212
renameAgent,
13+
getAgentProxyStatus,
1314
} from '../orthrus'
1415

1516
vi.mock('../client', () => ({
@@ -175,4 +176,30 @@ describe('orthrus API', () => {
175176
await expect(getInstallSnippets('agent-uuid')).rejects.toThrow('snippets failed')
176177
})
177178
})
179+
180+
describe('getAgentProxyStatus', () => {
181+
it('calls the proxy-status endpoint and returns data', async () => {
182+
const status = {
183+
agent_uuid: 'agent-uuid',
184+
agent_online: true,
185+
configured_port: 2375,
186+
active: true,
187+
active_port: 2375,
188+
bind_address: '0.0.0.0:2375',
189+
connection_string: 'tcp://charon:2375',
190+
error: '',
191+
}
192+
vi.mocked(client.get).mockResolvedValue({ data: status })
193+
194+
const result = await getAgentProxyStatus('agent-uuid')
195+
196+
expect(client.get).toHaveBeenCalledWith('/orthrus/agents/agent-uuid/proxy-status')
197+
expect(result).toEqual(status)
198+
})
199+
200+
it('propagates errors', async () => {
201+
vi.mocked(client.get).mockRejectedValue(new Error('proxy status failed'))
202+
await expect(getAgentProxyStatus('agent-uuid')).rejects.toThrow('proxy status failed')
203+
})
204+
})
178205
})
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
2+
import { describe, it, expect, vi, beforeEach } from 'vitest';
3+
4+
import { type OrthrusAgent } from '../../../api/orthrus';
5+
import { AgentExternalProxyDialog } from '../AgentExternalProxyDialog';
6+
7+
const mockPatch = vi.fn();
8+
const mockRefetchStatus = vi.fn();
9+
let mockProxyStatus: Record<string, unknown> | undefined = undefined;
10+
11+
vi.mock('../../../hooks/useOrthrus', () => ({
12+
usePatchAgent: () => ({ mutate: mockPatch, isPending: false }),
13+
useAgentProxyStatus: () => ({
14+
data: mockProxyStatus,
15+
refetch: mockRefetchStatus,
16+
}),
17+
}));
18+
19+
vi.mock('react-i18next', () => ({
20+
useTranslation: () => ({
21+
t: (key: string, opts?: Record<string, string>) =>
22+
opts?.name ? `${key}:${opts.name}` : key,
23+
}),
24+
}));
25+
26+
const baseAgent: OrthrusAgent = {
27+
uuid: 'agent-1',
28+
name: 'Test Agent',
29+
status: 'online',
30+
capabilities: '["proxy"]',
31+
hecate_tunnel_uuid: undefined,
32+
resolved_address: undefined,
33+
device_id: undefined,
34+
created_at: '2025-01-01T00:00:00Z',
35+
updated_at: '2025-01-01T00:00:00Z',
36+
external_proxy_port: 2375,
37+
};
38+
39+
const renderDialog = (agent: OrthrusAgent = baseAgent, open = true, onClose = vi.fn()) =>
40+
render(<AgentExternalProxyDialog agent={agent} open={open} onClose={onClose} />);
41+
42+
beforeEach(() => {
43+
mockPatch.mockReset();
44+
mockRefetchStatus.mockReset();
45+
mockProxyStatus = undefined;
46+
});
47+
48+
describe('AgentExternalProxyDialog', () => {
49+
describe('port validation', () => {
50+
it('shows no error for port 0', () => {
51+
renderDialog({ ...baseAgent, external_proxy_port: 0 });
52+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
53+
});
54+
55+
it('shows no error for a valid port in 1024–65535', () => {
56+
renderDialog({ ...baseAgent, external_proxy_port: 2375 });
57+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
58+
});
59+
60+
it('shows an error when port is changed to a privileged value (1–1023)', () => {
61+
renderDialog({ ...baseAgent, external_proxy_port: 0 });
62+
const input = screen.getByRole('spinbutton');
63+
fireEvent.change(input, { target: { value: '80' } });
64+
expect(screen.getByRole('alert')).toBeInTheDocument();
65+
});
66+
67+
it('shows an error when port exceeds 65535', () => {
68+
renderDialog({ ...baseAgent, external_proxy_port: 0 });
69+
const input = screen.getByRole('spinbutton');
70+
fireEvent.change(input, { target: { value: '99999' } });
71+
expect(screen.getByRole('alert')).toBeInTheDocument();
72+
});
73+
74+
it('clears error when port is changed to 0', () => {
75+
renderDialog({ ...baseAgent, external_proxy_port: 0 });
76+
const input = screen.getByRole('spinbutton');
77+
fireEvent.change(input, { target: { value: '80' } });
78+
expect(screen.getByRole('alert')).toBeInTheDocument();
79+
fireEvent.change(input, { target: { value: '0' } });
80+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
81+
});
82+
});
83+
84+
describe('handleSave', () => {
85+
it('calls patch with the current port when valid', () => {
86+
renderDialog();
87+
fireEvent.click(screen.getByText('common.save'));
88+
expect(mockPatch).toHaveBeenCalledWith(
89+
{ uuid: 'agent-1', req: { external_proxy_port: 2375 } },
90+
expect.any(Object),
91+
);
92+
});
93+
94+
it('does not call patch when port is invalid', () => {
95+
renderDialog({ ...baseAgent, external_proxy_port: 0 });
96+
const input = screen.getByRole('spinbutton');
97+
fireEvent.change(input, { target: { value: '80' } });
98+
fireEvent.click(screen.getByText('common.save'));
99+
expect(mockPatch).not.toHaveBeenCalled();
100+
expect(screen.getByRole('alert')).toBeInTheDocument();
101+
});
102+
103+
it('calls refetchStatus and onClose on successful save', async () => {
104+
const onClose = vi.fn();
105+
mockPatch.mockImplementation((_args: unknown, { onSuccess }: { onSuccess: () => void }) => {
106+
onSuccess();
107+
});
108+
renderDialog(baseAgent, true, onClose);
109+
fireEvent.click(screen.getByText('common.save'));
110+
expect(mockRefetchStatus).toHaveBeenCalled();
111+
expect(onClose).toHaveBeenCalled();
112+
});
113+
});
114+
115+
describe('offline agent', () => {
116+
it('shows offline status when agent is not online', () => {
117+
renderDialog({ ...baseAgent, status: 'offline' });
118+
const statusNodes = screen.getAllByText('hecate.externalProxy.agentOffline');
119+
expect(statusNodes.length).toBeGreaterThan(0);
120+
});
121+
});
122+
123+
describe('live proxy status', () => {
124+
it('shows error state when proxyStatus.error is set', () => {
125+
mockProxyStatus = {
126+
agent_uuid: 'agent-1',
127+
agent_online: true,
128+
configured_port: 2375,
129+
active: false,
130+
active_port: 0,
131+
bind_address: '',
132+
connection_string: '',
133+
error: 'bind failed: address already in use',
134+
};
135+
renderDialog();
136+
expect(screen.getByRole('alert')).toBeInTheDocument();
137+
expect(screen.getByText('bind failed: address already in use')).toBeInTheDocument();
138+
});
139+
140+
it('shows offline message when proxyStatus.agent_online is false', () => {
141+
mockProxyStatus = {
142+
agent_uuid: 'agent-1',
143+
agent_online: false,
144+
configured_port: 2375,
145+
active: false,
146+
active_port: 0,
147+
bind_address: '',
148+
connection_string: '',
149+
error: '',
150+
};
151+
renderDialog();
152+
const offlineMessages = screen.getAllByText('hecate.externalProxy.agentOffline');
153+
expect(offlineMessages.length).toBeGreaterThan(0);
154+
});
155+
156+
it('shows active status with connection string when proxy is active', () => {
157+
mockProxyStatus = {
158+
agent_uuid: 'agent-1',
159+
agent_online: true,
160+
configured_port: 2375,
161+
active: true,
162+
active_port: 2375,
163+
bind_address: '0.0.0.0:2375',
164+
connection_string: 'tcp://charon:2375',
165+
error: '',
166+
};
167+
renderDialog();
168+
expect(screen.getByText('hecate.externalProxy.statusActive')).toBeInTheDocument();
169+
expect(screen.getByText('tcp://charon:2375')).toBeInTheDocument();
170+
});
171+
172+
it('shows inactive status when proxy is not active', () => {
173+
mockProxyStatus = {
174+
agent_uuid: 'agent-1',
175+
agent_online: true,
176+
configured_port: 2375,
177+
active: false,
178+
active_port: 0,
179+
bind_address: '',
180+
connection_string: '',
181+
error: '',
182+
};
183+
renderDialog();
184+
expect(screen.getByText('hecate.externalProxy.statusInactive')).toBeInTheDocument();
185+
});
186+
187+
it('shows reconnect notice when configured port differs from active port', () => {
188+
mockProxyStatus = {
189+
agent_uuid: 'agent-1',
190+
agent_online: true,
191+
configured_port: 2375,
192+
active: true,
193+
active_port: 9999,
194+
bind_address: '0.0.0.0:9999',
195+
connection_string: 'tcp://charon:9999',
196+
error: '',
197+
};
198+
renderDialog({ ...baseAgent, external_proxy_port: 2375 });
199+
expect(screen.getByText('hecate.externalProxy.reconnectNotice')).toBeInTheDocument();
200+
});
201+
});
202+
203+
describe('handleCopy', () => {
204+
it('copies connection string to clipboard and shows copied state', async () => {
205+
const writeText = vi.fn().mockResolvedValue(undefined);
206+
Object.assign(navigator, { clipboard: { writeText } });
207+
208+
mockProxyStatus = {
209+
agent_uuid: 'agent-1',
210+
agent_online: true,
211+
configured_port: 2375,
212+
active: true,
213+
active_port: 2375,
214+
bind_address: '0.0.0.0:2375',
215+
connection_string: 'tcp://charon:2375',
216+
error: '',
217+
};
218+
renderDialog();
219+
220+
const copyButton = screen.getByRole('button', {
221+
name: 'hecate.externalProxy.copyConnectionString',
222+
});
223+
await act(async () => {
224+
fireEvent.click(copyButton);
225+
});
226+
227+
await waitFor(() =>
228+
expect(writeText).toHaveBeenCalledWith('tcp://charon:2375'),
229+
);
230+
});
231+
});
232+
233+
describe('cancel button', () => {
234+
it('calls onClose when cancel is clicked', () => {
235+
const onClose = vi.fn();
236+
renderDialog(baseAgent, true, onClose);
237+
fireEvent.click(screen.getByText('common.cancel'));
238+
expect(onClose).toHaveBeenCalled();
239+
});
240+
});
241+
});

0 commit comments

Comments
 (0)