LogDrains.utils.test.ts337 lines · main
1import { describe, expect, it } from 'vitest'
2
3import {
4 getDefaultHeadersByType,
5 getHeadersSectionDescription,
6 HEADER_VALIDATION_ERRORS,
7 headerRecordToRows,
8 headerRowsToRecord,
9 logDrainHeaderEntriesSchema,
10 otlpConfigSchema,
11} from './LogDrains.utils'
12
13describe('getHeadersSectionDescription', () => {
14 it('returns webhook description for webhook type', () => {
15 const result = getHeadersSectionDescription('webhook')
16 expect(result).toBe('Set custom headers when draining logs to the Endpoint URL')
17 })
18
19 it('returns loki description for loki type', () => {
20 const result = getHeadersSectionDescription('loki')
21 expect(result).toBe('Set custom headers when draining logs to the Loki HTTP(S) endpoint')
22 })
23
24 it('returns otlp description for otlp type', () => {
25 const result = getHeadersSectionDescription('otlp')
26 expect(result).toBe(
27 'Set custom headers for OTLP authentication (e.g., Authorization, X-API-Key)'
28 )
29 })
30
31 it('returns empty string for unsupported types', () => {
32 expect(getHeadersSectionDescription('datadog')).toBe('')
33 expect(getHeadersSectionDescription('s3')).toBe('')
34 expect(getHeadersSectionDescription('sentry')).toBe('')
35 })
36})
37
38describe('getDefaultHeadersByType', () => {
39 it('returns the JSON content type header for webhook destinations', () => {
40 expect(getDefaultHeadersByType('webhook')).toEqual({
41 'Content-Type': 'application/json',
42 })
43 })
44
45 it('returns the protobuf content type header for OTLP destinations', () => {
46 expect(getDefaultHeadersByType('otlp')).toEqual({
47 'Content-Type': 'application/x-protobuf',
48 })
49 })
50
51 it('returns an empty object for destinations without default headers', () => {
52 expect(getDefaultHeadersByType('loki')).toEqual({})
53 expect(getDefaultHeadersByType('datadog')).toEqual({})
54 })
55})
56
57describe('headerRecordToRows', () => {
58 it('converts a header record into key/value rows', () => {
59 expect(
60 headerRecordToRows({
61 Authorization: 'Bearer token',
62 'Content-Type': 'application/json',
63 })
64 ).toEqual([
65 { key: 'Authorization', value: 'Bearer token' },
66 { key: 'Content-Type', value: 'application/json' },
67 ])
68 })
69
70 it('returns an empty array for empty header records', () => {
71 expect(headerRecordToRows({})).toEqual([])
72 })
73})
74
75describe('headerRowsToRecord', () => {
76 it('converts key/value rows back into a header record', () => {
77 expect(
78 headerRowsToRecord([
79 { key: 'Authorization', value: 'Bearer token' },
80 { key: 'Content-Type', value: 'application/json' },
81 ])
82 ).toEqual({
83 Authorization: 'Bearer token',
84 'Content-Type': 'application/json',
85 })
86 })
87
88 it('trims row values and skips incomplete rows', () => {
89 expect(
90 headerRowsToRecord([
91 { key: ' Authorization ', value: ' Bearer token ' },
92 { key: '', value: 'missing-key' },
93 { key: 'X-Skip', value: '' },
94 ])
95 ).toEqual({
96 Authorization: 'Bearer token',
97 })
98 })
99})
100
101describe('logDrainHeaderEntriesSchema', () => {
102 it('accepts fully empty draft rows', () => {
103 const result = logDrainHeaderEntriesSchema.safeParse([
104 { key: 'Content-Type', value: 'application/json' },
105 { key: '', value: '' },
106 ])
107
108 expect(result.success).toBe(true)
109 })
110
111 it('rejects rows with a key but no value', () => {
112 const result = logDrainHeaderEntriesSchema.safeParse([{ key: 'X-Draft-Only', value: '' }])
113
114 expect(result.success).toBe(false)
115 if (!result.success) {
116 expect(result.error.issues).toEqual(
117 expect.arrayContaining([
118 expect.objectContaining({
119 message: HEADER_VALIDATION_ERRORS.VALUE_REQUIRED,
120 path: [0, 'value'],
121 }),
122 ])
123 )
124 }
125 })
126
127 it('rejects rows with a value but no key', () => {
128 const result = logDrainHeaderEntriesSchema.safeParse([{ key: '', value: 'draft-value' }])
129
130 expect(result.success).toBe(false)
131 if (!result.success) {
132 expect(result.error.issues).toEqual(
133 expect.arrayContaining([
134 expect.objectContaining({
135 message: HEADER_VALIDATION_ERRORS.KEY_REQUIRED,
136 path: [0, 'key'],
137 }),
138 ])
139 )
140 }
141 })
142
143 it('still rejects duplicate completed header names', () => {
144 const result = logDrainHeaderEntriesSchema.safeParse([
145 { key: 'Content-Type', value: 'application/json' },
146 { key: 'Content-Type', value: 'application/custom' },
147 ])
148
149 expect(result.success).toBe(false)
150 if (!result.success) {
151 expect(result.error.issues).toEqual(
152 expect.arrayContaining([
153 expect.objectContaining({
154 message: 'Header name already exists',
155 path: [0, 'key'],
156 }),
157 expect.objectContaining({
158 message: 'Header name already exists',
159 path: [1, 'key'],
160 }),
161 ])
162 )
163 }
164 })
165})
166
167describe('otlpConfigSchema', () => {
168 describe('valid OTLP configurations', () => {
169 it('accepts valid HTTPS endpoint with all fields', () => {
170 const config = {
171 type: 'otlp' as const,
172 endpoint: 'https://otlp.example.com:4318/v1/logs',
173 protocol: 'http/protobuf',
174 gzip: true,
175 headers: { Authorization: 'Bearer token' },
176 }
177 const result = otlpConfigSchema.safeParse(config)
178 expect(result.success).toBe(true)
179 if (result.success) {
180 expect(result.data).toEqual(config)
181 }
182 })
183
184 it('accepts HTTP endpoint (for testing environments)', () => {
185 const config = {
186 type: 'otlp' as const,
187 endpoint: 'http://localhost:4318/v1/logs',
188 }
189 const result = otlpConfigSchema.safeParse(config)
190 expect(result.success).toBe(true)
191 })
192
193 it('applies default values for optional fields', () => {
194 const config = {
195 type: 'otlp' as const,
196 endpoint: 'https://otlp.example.com/v1/logs',
197 }
198 const result = otlpConfigSchema.safeParse(config)
199 expect(result.success).toBe(true)
200 if (result.success) {
201 expect(result.data.protocol).toBe('http/protobuf')
202 expect(result.data.gzip).toBe(true)
203 }
204 })
205
206 it('accepts empty headers object', () => {
207 const config = {
208 type: 'otlp' as const,
209 endpoint: 'https://otlp.example.com/v1/logs',
210 headers: {},
211 }
212 const result = otlpConfigSchema.safeParse(config)
213 expect(result.success).toBe(true)
214 })
215
216 it('accepts multiple custom headers', () => {
217 const config = {
218 type: 'otlp' as const,
219 endpoint: 'https://otlp.example.com/v1/logs',
220 headers: {
221 Authorization: 'Bearer token',
222 'X-API-Key': 'secret-key',
223 'X-Custom-Header': 'custom-value',
224 },
225 }
226 const result = otlpConfigSchema.safeParse(config)
227 expect(result.success).toBe(true)
228 })
229 })
230
231 describe('invalid OTLP configurations', () => {
232 it('rejects empty endpoint', () => {
233 const config = {
234 type: 'otlp' as const,
235 endpoint: '',
236 }
237 const result = otlpConfigSchema.safeParse(config)
238 expect(result.success).toBe(false)
239 if (!result.success) {
240 expect(result.error.issues[0].message).toContain('OTLP endpoint is required')
241 }
242 })
243
244 it('rejects endpoint without protocol', () => {
245 const config = {
246 type: 'otlp' as const,
247 endpoint: 'otlp.example.com/v1/logs',
248 }
249 const result = otlpConfigSchema.safeParse(config)
250 expect(result.success).toBe(false)
251 if (!result.success) {
252 expect(result.error.issues[0].message).toContain('must start with http:// or https://')
253 }
254 })
255
256 it('rejects endpoint with invalid protocol', () => {
257 const config = {
258 type: 'otlp' as const,
259 endpoint: 'ftp://otlp.example.com/v1/logs',
260 }
261 const result = otlpConfigSchema.safeParse(config)
262 expect(result.success).toBe(false)
263 })
264
265 it('rejects endpoint with ws:// protocol', () => {
266 const config = {
267 type: 'otlp' as const,
268 endpoint: 'ws://otlp.example.com/v1/logs',
269 }
270 const result = otlpConfigSchema.safeParse(config)
271 expect(result.success).toBe(false)
272 })
273
274 it('rejects endpoint with an incomplete hostname', () => {
275 const config = {
276 type: 'otlp' as const,
277 endpoint: 'https://webhook',
278 }
279 const result = otlpConfigSchema.safeParse(config)
280 expect(result.success).toBe(false)
281 if (!result.success) {
282 expect(result.error.issues[0].message).toContain('must be a valid URL')
283 }
284 })
285
286 it('rejects wrong type field', () => {
287 const config = {
288 type: 'webhook' as const,
289 endpoint: 'https://otlp.example.com/v1/logs',
290 }
291 const result = otlpConfigSchema.safeParse(config)
292 expect(result.success).toBe(false)
293 })
294 })
295
296 describe('edge cases', () => {
297 it('accepts endpoint with port number', () => {
298 const config = {
299 type: 'otlp' as const,
300 endpoint: 'https://otlp.example.com:4318/v1/logs',
301 }
302 const result = otlpConfigSchema.safeParse(config)
303 expect(result.success).toBe(true)
304 })
305
306 it('accepts endpoint with query parameters', () => {
307 const config = {
308 type: 'otlp' as const,
309 endpoint: 'https://otlp.example.com/v1/logs?tenant=123',
310 }
311 const result = otlpConfigSchema.safeParse(config)
312 expect(result.success).toBe(true)
313 })
314
315 it('accepts endpoint with authentication in URL', () => {
316 const config = {
317 type: 'otlp' as const,
318 endpoint: 'https://user:pass@otlp.example.com/v1/logs',
319 }
320 const result = otlpConfigSchema.safeParse(config)
321 expect(result.success).toBe(true)
322 })
323
324 it('allows gzip to be explicitly false', () => {
325 const config = {
326 type: 'otlp' as const,
327 endpoint: 'https://otlp.example.com/v1/logs',
328 gzip: false,
329 }
330 const result = otlpConfigSchema.safeParse(config)
331 expect(result.success).toBe(true)
332 if (result.success) {
333 expect(result.data.gzip).toBe(false)
334 }
335 })
336 })
337})