'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const { buildCategoryFilterClause, buildIndicatorsQuery, buildRankingCompletoQuery, buildTicketContextQuery, buildBaselineDailyQuery, buildExtendedMetricsQuery, } = require('../src/v2/queries'); const { assertReadOnlySql, FORBIDDEN_KEYWORDS } = require('../src/v2/sqlGuard'); const { resolvePeriod } = require('../src/v2/period'); const { loadConfig } = require('../src/v2/config'); const config = loadConfig(); const period = resolvePeriod('2026-08-20', new Date(2026, 7, 24), config); test('assertReadOnlySql aceita SELECT e devolve a mesma string', () => { const sql = 'SELECT * FROM glpi_tickets'; assert.equal(assertReadOnlySql(sql), sql); }); test('assertReadOnlySql bloqueia qualquer verbo de escrita', () => { for (const verbo of FORBIDDEN_KEYWORDS) { assert.throws(() => assertReadOnlySql(`${verbo} INTO glpi_tickets ...`), /Query bloqueada/); } }); test('assertReadOnlySql nao gera falso positivo em nomes de coluna legitimos do GLPI (date_creation, updater...)', () => { assert.doesNotThrow(() => assertReadOnlySql('SELECT date_creation, users_id_lastupdater FROM glpi_tickets') ); }); test('buildCategoryFilterClause usa os IDs configurados (7,16,17,18) e nao verbo de escrita', () => { const clause = buildCategoryFilterClause([7, 16, 17, 18]); assert.match(clause, /id = 7/); assert.match(clause, /id = 16/); assert.match(clause, /id = 17/); assert.match(clause, /id = 18/); assert.doesNotThrow(() => assertReadOnlySql(clause)); }); test('buildCategoryFilterClause rejeita id invalido (defesa contra injecao via config)', () => { assert.throws(() => buildCategoryFilterClause([7, 'DROP TABLE x'])); }); test('buildIndicatorsQuery e somente leitura e preserva a logica da V1 (abertos por date, fechados por closedate)', () => { const sql = buildIndicatorsQuery(period, config); assert.doesNotThrow(() => assertReadOnlySql(sql)); assert.match(sql, /is_deleted = false/); assert.match(sql, /date >= '2026-08-20'/); assert.match(sql, /closedate >= '2026-08-20'/); assert.match(sql, /status NOT IN \(5, 6\)/); assert.match(sql, /priority >= 4/); }); test('buildIndicatorsQuery rejeita data em formato invalido (protecao contra interpolacao livre)', () => { const periodoRuim = { ...period, data_inicio: "2026-08-20'; DROP TABLE glpi_tickets; --", data_fim: period.data_fim }; assert.throws(() => buildIndicatorsQuery(periodoRuim, config), /Data invalida/); }); test('buildRankingCompletoQuery gera SQL somente leitura para categoria e cliente, e expoe o id (nao so o nome)', () => { const sqlCategoria = buildRankingCompletoQuery(period, config, 'categoria'); const sqlCliente = buildRankingCompletoQuery(period, config, 'cliente'); assert.doesNotThrow(() => assertReadOnlySql(sqlCategoria)); assert.doesNotThrow(() => assertReadOnlySql(sqlCliente)); assert.match(sqlCategoria, /glpi_itilcategories/); assert.match(sqlCliente, /glpi_entities/); assert.match(sqlCategoria, /SELECT t\.itilcategories_id AS id,/); assert.match(sqlCliente, /SELECT t\.entities_id AS id,/); }); test('buildTicketContextQuery limita linhas via LIMIT e nao seleciona tabelas de escrita', () => { const sql = buildTicketContextQuery(period, config); assert.doesNotThrow(() => assertReadOnlySql(sql)); assert.match(sql, new RegExp(`LIMIT ${config.TICKET_CONTEXT_SQL_LIMIT}`)); assert.match(sql, /LEFT\(t\.content, 600\) AS description/); }); test('buildBaselineDailyQuery agrupa por dia e por dimensao (categoria/cliente) sem verbo de escrita, e nao faz JOIN com tabela de dimensao (so glpi_tickets)', () => { const janela = period.baseline.rolling; const sql = buildBaselineDailyQuery(janela, config, 'categoria', period); assert.doesNotThrow(() => assertReadOnlySql(sql)); assert.match(sql, /GROUP BY dia,/); assert.doesNotMatch(sql, /LEFT JOIN glpi_itilcategories/); assert.doesNotMatch(sql, /LEFT JOIN glpi_entities/); }); test('buildBaselineDailyQuery restringe o agrupamento aos IDs ativos no periodo atual (evita agrupar a base inteira de entidades/categorias)', () => { const janela = period.baseline.rolling; const sqlCliente = buildBaselineDailyQuery(janela, config, 'cliente', period); assert.doesNotThrow(() => assertReadOnlySql(sqlCliente)); // a subquery de IDs ativos usa o periodo ATUAL (data_inicio do relatorio), nao a janela historica assert.match(sqlCliente, new RegExp(`t2\\.date >= '${period.data_inicio}'`)); assert.match(sqlCliente, /AND t\.entities_id IN \(/); }); test('buildExtendedMetricsQuery e somente leitura mesmo usando campos ainda nao validados no schema real', () => { const sql = buildExtendedMetricsQuery(period, config); assert.doesNotThrow(() => assertReadOnlySql(sql)); assert.match(sql, /time_to_resolve/); }); test('nenhuma query gerada para nenhum tipo de periodo conhecido contem verbo de escrita (varredura ampla)', () => { const referencias = [new Date(2026, 7, 18), new Date(2026, 7, 19), new Date(2026, 7, 20), new Date(2026, 7, 21), new Date(2026, 7, 24)]; for (const ref of referencias) { const p = resolvePeriod('', ref, config); assert.doesNotThrow(() => assertReadOnlySql(buildIndicatorsQuery(p, config))); assert.doesNotThrow(() => assertReadOnlySql(buildTicketContextQuery(p, config))); assert.doesNotThrow(() => assertReadOnlySql(buildRankingCompletoQuery(p, config, 'categoria'))); assert.doesNotThrow(() => assertReadOnlySql(buildRankingCompletoQuery(p, config, 'cliente'))); assert.doesNotThrow(() => assertReadOnlySql(buildExtendedMetricsQuery(p, config))); if (p.baseline.rolling) { assert.doesNotThrow(() => assertReadOnlySql(buildBaselineDailyQuery(p.baseline.rolling, config, 'categoria', p))); assert.doesNotThrow(() => assertReadOnlySql(buildBaselineDailyQuery(p.baseline.rolling, config, 'cliente', p))); } } });