Files
ExperionCrawler/ExperionCrawler.Tests/SqlValidatorTests.cs

189 lines
9.9 KiB
C#

using ExperionCrawler.Core.Application.DTOs;
using ExperionCrawler.Core.Application.Services;
namespace ExperionCrawler.Tests;
/// <summary>
/// SqlValidator 다단계 검증기 테스트
/// 검증 순서: ①구조 → ②위험키워드 → ③금지절 → ④함수화이트리스트 → ⑤테이블참조 → ⑥서브쿼리깊이 → ⑦의심패턴
/// </summary>
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);
}
}