using ExperionCrawler.Core.Application.DTOs; using ExperionCrawler.Core.Application.Services; namespace ExperionCrawler.Tests; /// /// SqlValidator 다단계 검증기 테스트 /// 검증 순서: ①구조 → ②위험키워드 → ③금지절 → ④함수화이트리스트 → ⑤테이블참조 → ⑥서브쿼리깊이 → ⑦의심패턴 /// public class SqlValidatorTests { private readonly SqlValidator _v; public SqlValidatorTests() { _v = new SqlValidator(new SqlValidatorOptions { RequiredTables = ["measurements"], AllowedTables = ["measurements", "node_map_master"], MaxSubqueryDepth = 4 }); } // ── ① 정상 케이스 (SELECT 전용, 허용 함수/테이블) ──────────────────────── [Theory] [InlineData("SELECT AVG(value) FROM measurements WHERE tagname = 'PV_101.PV'")] [InlineData("SELECT date_trunc('minute', time), AVG(value) FROM measurements GROUP BY 1")] [InlineData("SELECT tagname, REGR_SLOPE(value, EXTRACT(EPOCH FROM time)) FROM measurements GROUP BY tagname")] [InlineData("SELECT date_trunc('minute', time), first(value, time) FROM measurements WHERE tagname = 'test' GROUP BY 1 ORDER BY 1")] [InlineData("SELECT * FROM measurements WHERE tagname = 'test' AND time > now() - interval '1 hour'")] public void ValidSql_ShouldPass(string sql) { var result = _v.Validate(sql); Assert.True(result.IsValid, $"Expected valid but failed: {result.Message}"); } // ── ① SELECT 전용 검사 ───────────────────────────────────────────────── [Theory] [InlineData("DROP TABLE measurements", ValidationFailReason.NotSelectStatement)] [InlineData("CREATE TABLE test (id INT)", ValidationFailReason.NotSelectStatement)] [InlineData("ALTER TABLE measurements ADD COLUMN x INT", ValidationFailReason.NotSelectStatement)] [InlineData("TRUNCATE measurements", ValidationFailReason.NotSelectStatement)] [InlineData("MERGE INTO measurements USING...", ValidationFailReason.NotSelectStatement)] [InlineData("INSERT INTO measurements VALUES (1, 2, 3)", ValidationFailReason.NotSelectStatement)] [InlineData("UPDATE measurements SET value = 1", ValidationFailReason.NotSelectStatement)] [InlineData("DELETE FROM measurements", ValidationFailReason.NotSelectStatement)] public void NonSelectStatement_ShouldFail(string sql, ValidationFailReason expected) { var result = _v.Validate(sql); Assert.False(result.IsValid); Assert.Equal(expected, result.Reason); } // ── ② 위험 키워드 검사 (SELECT로 시작하지만 위험 키워드 포함) ───────────── [Theory] [InlineData("SELECT * FROM measurements; DROP TABLE measurements", ValidationFailReason.DangerousKeyword)] [InlineData("SELECT * FROM measurements; INSERT INTO other VALUES (1)", ValidationFailReason.DangerousKeyword)] [InlineData("SELECT * FROM measurements; COMMIT", ValidationFailReason.DangerousKeyword)] public void DangerousKeywordAfterSelect_ShouldFail(string sql, ValidationFailReason expected) { var result = _v.Validate(sql); Assert.False(result.IsValid); Assert.Equal(expected, result.Reason); } // ── ③ 금지 절 검사 (SELECT로 시작하지만 금지 절 포함) ───────────────────── [Theory] [InlineData("SELECT pg_sleep(5)", ValidationFailReason.ForbiddenClause)] [InlineData("SELECT pg_cancel_backend(1)", ValidationFailReason.ForbiddenClause)] [InlineData("SELECT pg_terminate_backend(1)", ValidationFailReason.ForbiddenClause)] // CALL/EXECUTE는 SELECT로 시작하지 않으므로 NotSelectStatement로 먼저 실패 public void ForbiddenClause_ShouldFail(string sql, ValidationFailReason expected) { var result = _v.Validate(sql); Assert.False(result.IsValid); Assert.Equal(expected, result.Reason); } // ── ④ 허용되지 않는 함수 ──────────────────────────────────────────────── [Theory] [InlineData("SELECT SYSTEM('ls') FROM measurements", ValidationFailReason.DisallowedFunction)] [InlineData("SELECT COPY_FILE('/tmp') FROM measurements", ValidationFailReason.DisallowedFunction)] [InlineData("SELECT NON_EXISTING_FUNC(1) FROM measurements", ValidationFailReason.DisallowedFunction)] public void DisallowedFunction_ShouldFail(string sql, ValidationFailReason expected) { var result = _v.Validate(sql); Assert.False(result.IsValid); Assert.Equal(expected, result.Reason); } // ── ⑤ 필수 테이블 누락 ───────────────────────────────────────────────── [Fact] public void MissingRequiredTable_ShouldFail() { var sql = "SELECT 1 FROM node_map_master"; var result = _v.Validate(sql); Assert.False(result.IsValid); Assert.Equal(ValidationFailReason.MissingRequiredTable, result.Reason); } // ── ⑦ 의심 패턴 검사 (시스템 뷰 접근) ─────────────────────────────────── [Fact] public void SystemViewAccess_ShouldFail() { // measurements가 없어서 MissingRequiredTable로 먼저 실패 var sql = "SELECT * FROM information_schema.tables"; var result = _v.Validate(sql); Assert.False(result.IsValid); // 필수 테이블 measurements가 없어서 MissingRequiredTable로 실패 Assert.Equal(ValidationFailReason.MissingRequiredTable, result.Reason); } // ── SQL Injection 패턴 ────────────────────────────────────────────────── [Theory] [InlineData("SELECT * FROM measurements WHERE tagname = '' OR '1'='1'", ValidationFailReason.SuspiciousPattern)] [InlineData("SELECT * FROM measurements UNION SELECT * FROM measurements", ValidationFailReason.SuspiciousPattern)] [InlineData("SELECT * FROM measurements WHERE tagname = 'x'; DROP TABLE measurements", ValidationFailReason.DangerousKeyword)] public void InjectionPattern_ShouldFail(string sql, ValidationFailReason expected) { var result = _v.Validate(sql); Assert.False(result.IsValid); Assert.Equal(expected, result.Reason); } // ── ⑥ 서브쿼리 깊이 ──────────────────────────────────────────────────── [Fact] public void SubqueryDepthExceeded_ShouldFail() { // 5단계 중첩: ( -> ( -> ( -> ( -> ( -> (SELECT f FROM measurements) // MaxSubqueryDepth = 4이므로 5단계에서 실패 // 괄호 개수: 5개여야 함 var sql = "SELECT a FROM (SELECT b FROM (SELECT c FROM (SELECT d FROM (SELECT e FROM (SELECT f FROM measurements) AS x0) AS x1) AS x2) AS x3) AS x4"; var result = _v.Validate(sql); Assert.False(result.IsValid, $"Expected invalid but got valid. Message: {result.Message}"); Assert.Equal(ValidationFailReason.SubqueryDepthExceeded, result.Reason); } // ── 빈 입력 ──────────────────────────────────────────────────────────── [Theory] [InlineData("")] [InlineData(" ")] [InlineData(null)] public void EmptyInput_ShouldFail(string? sql) { var result = _v.Validate(sql!); Assert.False(result.IsValid); Assert.Equal(ValidationFailReason.EmptyInput, result.Reason); } // ── 허용 함수 화이트리스트 (정규 케이스) ───────────────────────────────── [Theory] [InlineData("SELECT COUNT(*) FROM measurements")] [InlineData("SELECT SUM(value), AVG(value), MIN(value), MAX(value) FROM measurements")] [InlineData("SELECT STDDEV(value), VARIANCE(value) FROM measurements")] [InlineData("SELECT ROW_NUMBER() OVER (ORDER BY time) FROM measurements")] [InlineData("SELECT RANK() OVER (PARTITION BY tagname ORDER BY value DESC) FROM measurements")] [InlineData("SELECT LAG(value, 1) OVER (ORDER BY time) FROM measurements")] [InlineData("SELECT NOW(), CURRENT_TIMESTAMP, CURRENT_DATE FROM measurements")] [InlineData("SELECT DATE_TRUNC('hour', time), EXTRACT(EPOCH FROM time) FROM measurements")] [InlineData("SELECT TIME_BUCKET('1 hour', time) FROM measurements")] [InlineData("SELECT UPPER(tagname), LOWER(tagname), TRIM(tagname) FROM measurements")] [InlineData("SELECT COALESCE(value, 0), NULLIF(value, -1) FROM measurements")] [InlineData("SELECT ROUND(value), CEIL(value), FLOOR(value) FROM measurements")] public void AllowedFunctions_ShouldPass(string sql) { var result = _v.Validate(sql); Assert.True(result.IsValid, $"Expected valid but failed: {result.Message}"); } // ── Deconstruct 테스트 ───────────────────────────────────────────────── [Fact] public void ValidationResult_Deconstruct_ShouldWork() { var result = _v.Validate("SELECT 1 FROM measurements"); var (ok, error) = result; Assert.True(ok); Assert.Null(error); } [Fact] public void ValidationResult_Deconstruct_Fail_ShouldWork() { var result = _v.Validate(""); var (ok, error) = result; Assert.False(ok); Assert.NotNull(error); } }