UnifiedLogs.queries.test.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. import { describe, expect, it } from 'vitest'
  2. import {
  3. getFacetCountQuery,
  4. getLogsChartQuery,
  5. getLogsCountQuery,
  6. getUnifiedLogsQuery,
  7. } from './UnifiedLogs.queries'
  8. import { getUnifiedLogsQuery as getUnifiedLogsQueryBQ } from './UnifiedLogs.queries.bq'
  9. const baseSearch = {
  10. date: [new Date('2026-05-08T09:00:00Z'), new Date('2026-05-08T10:00:00Z')],
  11. } as any
  12. describe('UnifiedLogs.queries (OTEL flat)', () => {
  13. describe('getUnifiedLogsQuery', () => {
  14. it('defaults to postgres + postgrest log types when none specified', () => {
  15. const sql = getUnifiedLogsQuery(baseSearch)
  16. expect(sql).toContain(`source = 'postgres_logs'`)
  17. // postgrest = edge_logs filtered by /rest/ path
  18. expect(sql).toContain(
  19. `source = 'edge_logs' AND log_attributes['request.path'] LIKE '%/rest/%'`
  20. )
  21. })
  22. it('routes the `edge` log type to edge_logs without /rest/ or /storage/ paths', () => {
  23. const sql = getUnifiedLogsQuery({ ...baseSearch, log_type: ['edge'] } as any)
  24. expect(sql).toContain(`NOT LIKE '%/rest/%'`)
  25. expect(sql).toContain(`NOT LIKE '%/storage/%'`)
  26. const where = sql.split(/\bWHERE\b/)[1] ?? ''
  27. expect(where).not.toContain(`source = 'postgres_logs'`)
  28. })
  29. it('routes the `storage` log type to edge_logs filtered by /storage/', () => {
  30. const sql = getUnifiedLogsQuery({ ...baseSearch, log_type: ['storage'] } as any)
  31. expect(sql).toContain(
  32. `source = 'edge_logs' AND log_attributes['request.path'] LIKE '%/storage/%'`
  33. )
  34. })
  35. it('escapes single quotes in filter values to prevent SQL injection', () => {
  36. const sql = getUnifiedLogsQuery({
  37. ...baseSearch,
  38. method: [`G'ET`],
  39. pathname: `/customers'; DROP TABLE logs --`,
  40. } as any)
  41. // Single quotes are doubled (SQL-standard escaping) by pg-meta's
  42. // literal(); a raw single quote from user input never closes its
  43. // string literal early.
  44. expect(sql).toContain(`'G''ET'`)
  45. expect(sql).toContain(`%/customers''; DROP TABLE logs --%`)
  46. })
  47. it('translates method/status/pathname filters to log_attributes predicates', () => {
  48. const sql = getUnifiedLogsQuery({
  49. ...baseSearch,
  50. method: ['GET'],
  51. status: ['401'],
  52. pathname: '/customers',
  53. } as any)
  54. expect(sql).toContain(`log_attributes['request.method'] IN ('GET')`)
  55. // Status filter wraps the CASE that picks HTTP code or Postgres SQLSTATE
  56. // so e.g. '00000' matches postgres success rows.
  57. expect(sql).toContain(`log_attributes['parsed.sql_state_code']`)
  58. expect(sql).toMatch(/END\) IN \('401'\)/)
  59. expect(sql).toContain(`log_attributes['request.path'] LIKE '%/customers%'`)
  60. })
  61. it('does not emit subqueries or CTEs (rejected by the OTEL endpoint)', () => {
  62. const sql = getUnifiedLogsQuery(baseSearch)
  63. expect(sql).not.toMatch(/WITH\s+\w+\s+AS\s*\(/i)
  64. expect(sql).not.toMatch(/FROM\s*\(\s*SELECT/i)
  65. // Single SELECT * FROM logs (not "SELECT *" wildcard usage either).
  66. expect(sql).not.toMatch(/SELECT\s+\*/)
  67. })
  68. })
  69. describe('getLogsCountQuery', () => {
  70. it('emits one UNION ALL branch per log_type bucket and per level', () => {
  71. const sql = getLogsCountQuery(baseSearch)
  72. // Per-log-type counts
  73. for (const lt of ['edge', 'postgrest', 'storage', 'postgres', 'edge function', 'auth']) {
  74. expect(sql).toContain(`'${lt}'`)
  75. }
  76. // Per-level counts
  77. for (const lvl of ['success', 'warning', 'error']) {
  78. expect(sql).toContain(`'${lvl}'`)
  79. }
  80. // Bundled via UNION ALL — multiple occurrences expected
  81. expect(sql.match(/UNION ALL/g)?.length ?? 0).toBeGreaterThan(5)
  82. })
  83. it('honours an active log_type filter in the total count branch', () => {
  84. const sql = getLogsCountQuery({ ...baseSearch, log_type: ['edge'] } as any)
  85. // The first branch is the total — its WHERE must include the edge
  86. // log_type predicate, otherwise the total badge would over-count
  87. // when a log_type filter is active.
  88. const totalBranch = sql.split(/\bUNION ALL\b/)[0]
  89. expect(totalBranch).toContain(`'total'`)
  90. expect(totalBranch).toContain(`source = 'edge_logs'`)
  91. expect(totalBranch).not.toContain(`source = 'postgres_logs'`)
  92. })
  93. })
  94. describe('getLogsChartQuery', () => {
  95. it('uses minute bucketing for short ranges', () => {
  96. const sql = getLogsChartQuery(baseSearch)
  97. expect(sql).toContain('toStartOfMinute(timestamp)')
  98. })
  99. it('uses hour bucketing for ranges spanning more than 12 hours', () => {
  100. const sql = getLogsChartQuery({
  101. ...baseSearch,
  102. date: [new Date('2026-05-08T00:00:00Z'), new Date('2026-05-08T18:00:00Z')],
  103. } as any)
  104. expect(sql).toContain('toStartOfHour(timestamp)')
  105. })
  106. it('uses day bucketing for ranges spanning more than 2 days', () => {
  107. const sql = getLogsChartQuery({
  108. ...baseSearch,
  109. date: [new Date('2026-05-01T00:00:00Z'), new Date('2026-05-08T00:00:00Z')],
  110. } as any)
  111. expect(sql).toContain('toStartOfDay(timestamp)')
  112. })
  113. })
  114. describe('getFacetCountQuery', () => {
  115. it('groups by the requested facet and excludes that facet from the WHERE filters', () => {
  116. const sql = getFacetCountQuery({
  117. search: { ...baseSearch, method: ['GET'], status: ['200'] } as any,
  118. facet: 'method',
  119. })
  120. // Filtered facet is excluded from WHERE; other filters still applied.
  121. // For facet='method' the SELECT projection doesn't include STATUS_EXPR,
  122. // so SQLSTATE/IN ('200') must come from the WHERE clause.
  123. expect(sql).not.toContain(`log_attributes['request.method'] IN ('GET')`)
  124. expect(sql).toContain(`log_attributes['parsed.sql_state_code']`)
  125. expect(sql).toMatch(/END\) IN \('200'\)/)
  126. expect(sql).toContain('GROUP BY value')
  127. expect(sql).toContain('LIMIT 20')
  128. })
  129. })
  130. describe('analyticsLiteral escaping', () => {
  131. it('emits ClickHouse / BigQuery escape syntax (doubled `\\\\`, no `E` prefix)', () => {
  132. // pg-meta's literal() would emit `E'a\\b'` for `a\b` — the `E` prefix is
  133. // Postgres-only and rejected by both analytics engines. analyticsLiteral
  134. // doubles the backslash inside plain `'…'` delimiters instead.
  135. const sql = getUnifiedLogsQuery({ ...baseSearch, method: 'a\\b' } as any)
  136. expect(sql).toContain(`log_attributes['request.method'] = 'a\\\\b'`)
  137. expect(sql).not.toContain(`E'a`)
  138. })
  139. it("escapes single quotes by doubling them ('' rather than \\')", () => {
  140. const sql = getUnifiedLogsQuery({ ...baseSearch, method: "GET' OR '1'='1" } as any)
  141. expect(sql).toContain(`log_attributes['request.method'] = 'GET'' OR ''1''=''1'`)
  142. })
  143. })
  144. })
  145. describe('UnifiedLogs.queries.bq', () => {
  146. it('backtick-quotes column identifiers (BigQuery syntax)', () => {
  147. const sql = getUnifiedLogsQueryBQ({ ...baseSearch, method: 'GET' } as any)
  148. expect(sql).toContain("`method` = 'GET'")
  149. })
  150. it('rejects keys with non-identifier characters', () => {
  151. // A key like "foo; DROP TABLE" fails the bqIdent regex (the `;` is not
  152. // in `[A-Za-z_][A-Za-z0-9_]*`), so the predicate is dropped entirely.
  153. const sql = getUnifiedLogsQueryBQ({ ...baseSearch, 'foo; DROP TABLE x': 'y' } as any)
  154. expect(sql).not.toContain('DROP TABLE')
  155. expect(sql).not.toContain('foo;')
  156. })
  157. it('rejects keys containing spaces', () => {
  158. const sql = getUnifiedLogsQueryBQ({
  159. ...baseSearch,
  160. 'level OR id IS NOT NULL': 'anything',
  161. } as any)
  162. expect(sql).not.toContain('IS NOT NULL')
  163. expect(sql).not.toContain('level OR')
  164. })
  165. it('escapes injection attempts in filter values via analyticsLiteral', () => {
  166. const sql = getUnifiedLogsQueryBQ({ ...baseSearch, method: "GET' OR '1'='1" } as any)
  167. // The value is single-quote-escaped, so the synthetic OR can't break out
  168. // of the string literal.
  169. expect(sql).toContain("`method` = 'GET'' OR ''1''=''1'")
  170. expect(sql).not.toMatch(/`method` = 'GET' OR '1'='1'/)
  171. })
  172. })