LogDrains.utils.test.ts 10.0 KB

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