189 lines
9.9 KiB
C#
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);
|
|
}
|
|
}
|