Skip to content

Commit 7de8916

Browse files
authored
fix: Strip trailing slash for connections (#752)
1 parent 9a9581b commit 7de8916

File tree

15 files changed

+659
-430
lines changed

15 files changed

+659
-430
lines changed

.changeset/clean-dingos-divide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/app": patch
3+
---
4+
5+
Removes trailing slash for connection urls

packages/app/src/__tests__/utils.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
formatDate,
99
formatNumber,
1010
getMetricTableName,
11+
stripTrailingSlash,
1112
useQueryHistory,
1213
} from '../utils';
1314

@@ -473,6 +474,48 @@ describe('useLocalStorage', () => {
473474
});
474475
});
475476

477+
describe('stripTrailingSlash', () => {
478+
it('should throw an error for nullish values', () => {
479+
expect(() => stripTrailingSlash(null)).toThrow(
480+
'URL must be a non-empty string',
481+
);
482+
expect(() => stripTrailingSlash(undefined)).toThrow(
483+
'URL must be a non-empty string',
484+
);
485+
});
486+
487+
it('should throw an error for non-string values', () => {
488+
expect(() => stripTrailingSlash(123 as any)).toThrow(
489+
'URL must be a non-empty string',
490+
);
491+
expect(() => stripTrailingSlash({} as any)).toThrow(
492+
'URL must be a non-empty string',
493+
);
494+
});
495+
496+
it('should remove trailing slash from URLs', () => {
497+
expect(stripTrailingSlash('http://example.com/')).toBe(
498+
'http://example.com',
499+
);
500+
expect(stripTrailingSlash('http://example.com/api/')).toBe(
501+
'http://example.com/api',
502+
);
503+
});
504+
505+
it('should not modify URLs without trailing slash', () => {
506+
expect(stripTrailingSlash('http://example.com')).toBe('http://example.com');
507+
expect(stripTrailingSlash('http://example.com/api')).toBe(
508+
'http://example.com/api',
509+
);
510+
});
511+
512+
it('should handle URLs with multiple trailing slashes', () => {
513+
expect(stripTrailingSlash('http://example.com///')).toBe(
514+
'http://example.com//',
515+
);
516+
});
517+
});
518+
476519
describe('useQueryHistory', () => {
477520
const mockGetItem = jest.fn();
478521
const mockSetItem = jest.fn();

packages/app/src/components/ConnectionForm.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
useDeleteConnection,
1414
useUpdateConnection,
1515
} from '@/connection';
16+
import { stripTrailingSlash } from '@/utils';
1617

1718
import ConfirmDeleteMenu from './ConfirmDeleteMenu';
1819

@@ -32,9 +33,10 @@ function useTestConnection({
3233
useState<TestConnectionState | null>(null);
3334

3435
const handleTestConnection = useCallback(async () => {
35-
const host = getValues('host');
36+
const hostValue = getValues('host');
3637
const username = getValues('username');
3738
const password = getValues('password');
39+
const host = stripTrailingSlash(hostValue);
3840

3941
if (testConnectionState) {
4042
return;
@@ -134,9 +136,15 @@ export function ConnectionForm({
134136
const deleteConnection = useDeleteConnection();
135137

136138
const onSubmit = (data: Connection) => {
139+
// Make sure we don't save a trailing slash in the host
140+
const normalizedData = {
141+
...data,
142+
host: stripTrailingSlash(data.host),
143+
};
144+
137145
if (isNew) {
138146
createConnection.mutate(
139-
{ connection: data },
147+
{ connection: normalizedData },
140148
{
141149
onSuccess: () => {
142150
notifications.show({
@@ -157,7 +165,7 @@ export function ConnectionForm({
157165
);
158166
} else {
159167
updateConnection.mutate(
160-
{ connection: data, id: connection.id },
168+
{ connection: normalizedData, id: connection.id },
161169
{
162170
onSuccess: () => {
163171
notifications.show({
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import React from 'react';
2+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3+
4+
import { Connection } from '../../connection';
5+
import { ConnectionForm } from '../ConnectionForm';
6+
7+
import '@testing-library/jest-dom';
8+
9+
// --- Mocks ---
10+
const mockCreateMutate = jest.fn();
11+
const mockUpdateMutate = jest.fn();
12+
jest.mock('@/connection', () => ({
13+
...jest.requireActual('@/connection'),
14+
useCreateConnection: () => ({
15+
mutate: mockCreateMutate,
16+
isPending: false,
17+
}),
18+
useUpdateConnection: () => ({
19+
mutate: mockUpdateMutate,
20+
isPending: false,
21+
}),
22+
23+
useDeleteConnection: () => ({
24+
mutate: jest.fn(),
25+
isPending: false,
26+
}),
27+
}));
28+
29+
jest.mock('@mantine/notifications', () => ({
30+
notifications: {
31+
show: jest.fn(),
32+
},
33+
}));
34+
35+
const mockTestConnectionMutateAsync = jest.fn();
36+
jest.mock('@/api', () => ({
37+
...(jest.requireActual('@/api') ?? {}),
38+
useTestConnection: () => ({
39+
mutateAsync: mockTestConnectionMutateAsync.mockResolvedValue({
40+
success: true,
41+
}),
42+
}),
43+
}));
44+
45+
// --- Test Suite ---
46+
47+
describe('ConnectionForm', () => {
48+
const baseConnection: Connection = {
49+
id: '',
50+
name: 'Test Connection',
51+
host: 'http://localhost:8123',
52+
username: 'default',
53+
password: '',
54+
};
55+
56+
beforeEach(() => {
57+
mockCreateMutate.mockClear();
58+
mockUpdateMutate.mockClear();
59+
mockTestConnectionMutateAsync.mockClear();
60+
(
61+
jest.requireMock('@mantine/notifications') as any
62+
).notifications.show.mockClear();
63+
});
64+
65+
it('should save connection with trailing slash removed from host when creating', async () => {
66+
renderWithMantine(
67+
<ConnectionForm connection={baseConnection} isNew={true} />,
68+
);
69+
70+
const hostInput = screen.getByPlaceholderText('http://localhost:8123');
71+
const nameInput = screen.getByPlaceholderText('My Clickhouse Server');
72+
const submitButton = screen.getByRole('button', { name: 'Create' });
73+
74+
await fireEvent.change(nameInput, { target: { value: 'Test Name' } });
75+
await fireEvent.change(hostInput, {
76+
target: { value: 'http://example.com:8123/' },
77+
}); // Host with trailing slash
78+
79+
fireEvent.click(submitButton);
80+
81+
await waitFor(() => {
82+
expect(mockCreateMutate).toHaveBeenCalledTimes(1);
83+
});
84+
85+
// Check the arguments passed to the mutate function
86+
expect(mockCreateMutate).toHaveBeenCalledWith(
87+
expect.objectContaining({
88+
connection: expect.objectContaining({
89+
host: 'http://example.com:8123',
90+
name: 'Test Name',
91+
}),
92+
}),
93+
expect.anything(),
94+
);
95+
});
96+
97+
it('should save connection with trailing slash removed from host when updating', async () => {
98+
const existingConnection = {
99+
...baseConnection,
100+
id: 'existing-id',
101+
host: 'http://old.com/',
102+
};
103+
renderWithMantine(
104+
<ConnectionForm connection={existingConnection} isNew={false} />,
105+
);
106+
107+
const hostInput = screen.getByPlaceholderText('http://localhost:8123');
108+
const submitButton = screen.getByRole('button', { name: 'Save' });
109+
110+
// Update host
111+
await fireEvent.change(hostInput, {
112+
target: { value: 'http://updated.com:8123/' },
113+
});
114+
115+
fireEvent.click(submitButton);
116+
117+
// Wait for mutate to be called and assert
118+
await waitFor(() => {
119+
expect(mockUpdateMutate).toHaveBeenCalledTimes(1);
120+
});
121+
122+
// Check the arguments passed to the mutate function
123+
expect(mockUpdateMutate).toHaveBeenCalledWith(
124+
expect.objectContaining({
125+
id: 'existing-id',
126+
connection: expect.objectContaining({
127+
host: 'http://updated.com:8123',
128+
}),
129+
}),
130+
expect.anything(),
131+
);
132+
});
133+
134+
it('should use stripped host for test connection', async () => {
135+
renderWithMantine(
136+
<ConnectionForm connection={baseConnection} isNew={true} />,
137+
);
138+
const hostInput = screen.getByPlaceholderText('http://localhost:8123');
139+
140+
const nameInput = screen.getByPlaceholderText('My Clickhouse Server');
141+
const testButton = screen.getByRole('button', { name: 'Test Connection' });
142+
143+
await fireEvent.change(nameInput, { target: { value: 'Test Name' } });
144+
await fireEvent.change(hostInput, {
145+
target: { value: 'http://test.com:8123/' },
146+
});
147+
148+
// Ensure form state is valid before clicking test
149+
await waitFor(() => expect(testButton).not.toBeDisabled());
150+
151+
fireEvent.click(testButton);
152+
153+
await waitFor(() =>
154+
expect(mockTestConnectionMutateAsync).toHaveBeenCalled(),
155+
);
156+
157+
// Assert that the mock API call received the stripped host
158+
expect(mockTestConnectionMutateAsync).toHaveBeenCalledTimes(1);
159+
expect(mockTestConnectionMutateAsync).toHaveBeenCalledWith(
160+
expect.objectContaining({
161+
host: 'http://test.com:8123',
162+
}),
163+
);
164+
});
165+
});

packages/app/src/utils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -747,3 +747,11 @@ export function getMetricTableName(
747747
export function toArray<T>(obj?: T | T[]): T[] {
748748
return !obj ? [] : Array.isArray(obj) ? obj : [obj];
749749
}
750+
751+
// Helper function to remove trailing slash
752+
export const stripTrailingSlash = (url: string | undefined | null): string => {
753+
if (!url || typeof url !== 'string') {
754+
throw new Error('URL must be a non-empty string');
755+
}
756+
return url.endsWith('/') ? url.slice(0, -1) : url;
757+
};
Lines changed: 49 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,53 @@
11
{
2-
"resourceLogs": [
2+
"resourceLogs": [
3+
{
4+
"resource": {
5+
"attributes": [
6+
{
7+
"key": "suite-id",
8+
"value": {
9+
"stringValue": "auto-parse"
10+
}
11+
},
12+
{
13+
"key": "test-id",
14+
"value": {
15+
"stringValue": "default"
16+
}
17+
}
18+
]
19+
},
20+
"scopeLogs": [
321
{
4-
"resource": {
5-
"attributes": [
6-
{
7-
"key": "suite-id",
8-
"value": {
9-
"stringValue": "auto-parse"
10-
}
11-
},
12-
{
13-
"key": "test-id",
14-
"value": {
15-
"stringValue": "default"
16-
}
17-
}
18-
]
22+
"scope": {},
23+
"logRecords": [
24+
{
25+
"timeUnixNano": "1901999580000000000",
26+
"body": {
27+
"stringValue": "[note] this is very much not JSON even though it starts with an array char"
28+
}
1929
},
20-
"scopeLogs": [
21-
{
22-
"scope": {},
23-
"logRecords": [
24-
{
25-
"timeUnixNano": "1901999580000000000",
26-
"body": {
27-
"stringValue": "[note] this is very much not JSON even though it starts with an array char"
28-
}
29-
},
30-
{
31-
"timeUnixNano": "1901999580000000001",
32-
"body": {
33-
"stringValue": "{note} this is very much not JSON even though it starts with an object char"
34-
}
35-
},
36-
{
37-
"timeUnixNano": "1901999580000000002",
38-
"body": {
39-
"stringValue": "NOTE: this is very much not JSON"
40-
}
41-
},
42-
{
43-
"timeUnixNano": "1901999580000000003",
44-
"body": {
45-
"stringValue": "this has some {Key {Value { '{' } } invalid JSON in it"
46-
}
47-
}
48-
]
49-
}
50-
]
30+
{
31+
"timeUnixNano": "1901999580000000001",
32+
"body": {
33+
"stringValue": "{note} this is very much not JSON even though it starts with an object char"
34+
}
35+
},
36+
{
37+
"timeUnixNano": "1901999580000000002",
38+
"body": {
39+
"stringValue": "NOTE: this is very much not JSON"
40+
}
41+
},
42+
{
43+
"timeUnixNano": "1901999580000000003",
44+
"body": {
45+
"stringValue": "this has some {Key {Value { '{' } } invalid JSON in it"
46+
}
47+
}
48+
]
5149
}
52-
]
53-
}
50+
]
51+
}
52+
]
53+
}

0 commit comments

Comments
 (0)