test(core/browser/platform): 26 extreme RED/GREEN TDD cycles and full regression hardening (3,235 PASS)
This commit is contained in:
parent
bcd242691f
commit
8677853822
100 changed files with 4814 additions and 333 deletions
204
tests/Paca.Tests/Core/AiMediaAndShaderEnginesExtremeRedTests.cs
Normal file
204
tests/Paca.Tests/Core/AiMediaAndShaderEnginesExtremeRedTests.cs
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using Paca.Core.Detection;
|
||||
using Paca.Core.Gateway;
|
||||
using Paca.Core.Media;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class AiMediaAndShaderEnginesExtremeRedTests
|
||||
{
|
||||
private const string SampleInput = "C:\\Videos\\source.mp4";
|
||||
private const string SampleOutput = "C:\\Videos\\shorts.mp4";
|
||||
|
||||
// ==========================================
|
||||
// 1. AiHighlightClipper
|
||||
// ==========================================
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-100)]
|
||||
[InlineData(double.NaN)]
|
||||
public void AiHighlightClipper_DetectHighlights_ZeroOrNegativeDuration_ReturnsEmpty(double duration)
|
||||
{
|
||||
var highlights = AiHighlightClipper.DetectHighlights(duration);
|
||||
Assert.NotNull(highlights);
|
||||
Assert.Empty(highlights);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-10)]
|
||||
[InlineData(double.NaN)]
|
||||
public void AiHighlightClipper_DetectHighlights_ZeroOrNegativeWindowSeconds_DoesNotCrashOrLoopInfinitely(double windowSec)
|
||||
{
|
||||
var highlights = AiHighlightClipper.DetectHighlights(120.0, windowSeconds: windowSec);
|
||||
Assert.NotNull(highlights);
|
||||
Assert.NotEmpty(highlights);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AiHighlightClipper_BuildFfmpegShortsCommand_SameInputAndOutput_ThrowsInvalidOperationException()
|
||||
{
|
||||
var spec = new ShortsClipSpec();
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
AiHighlightClipper.BuildFfmpegShortsCommand(SampleInput, SampleInput, spec));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, SampleOutput)]
|
||||
[InlineData(SampleInput, null)]
|
||||
[InlineData("", SampleOutput)]
|
||||
[InlineData(SampleInput, " ")]
|
||||
public void AiHighlightClipper_BuildFfmpegShortsCommand_NullOrWhitespacePaths_ThrowsArgumentException(string? inPath, string? outPath)
|
||||
{
|
||||
var spec = new ShortsClipSpec();
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
AiHighlightClipper.BuildFfmpegShortsCommand(inPath!, outPath!, spec));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AiHighlightClipper_BuildFfmpegShortsCommand_NullSpec_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
AiHighlightClipper.BuildFfmpegShortsCommand(SampleInput, SampleOutput, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AiHighlightClipper_BuildFfmpegShortsCommand_PathsWithQuotes_Stripped()
|
||||
{
|
||||
var spec = new ShortsClipSpec();
|
||||
string maliciousIn = "C:\\Videos\\so\"urce.mp4";
|
||||
string maliciousOut = "C:\\Videos\\sh\"orts.mp4";
|
||||
|
||||
var args = AiHighlightClipper.BuildFfmpegShortsCommand(maliciousIn, maliciousOut, spec);
|
||||
Assert.NotNull(args);
|
||||
Assert.DoesNotContain(args, a => a.Contains('"'));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. FftAudioWaveformEngine
|
||||
// ==========================================
|
||||
|
||||
[Fact]
|
||||
public void FftAudioWaveformEngine_BuildFfmpegEqualizerFilter_NullOrEmptyBands_ReturnsEmptyString()
|
||||
{
|
||||
Assert.Equal(string.Empty, FftAudioWaveformEngine.BuildFfmpegEqualizerFilter(null!));
|
||||
Assert.Equal(string.Empty, FftAudioWaveformEngine.BuildFfmpegEqualizerFilter(new List<EqualizerBand>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FftAudioWaveformEngine_BuildFfmpegEqualizerFilter_ListContainingNullBands_SkipsSafely()
|
||||
{
|
||||
var bands = new List<EqualizerBand>
|
||||
{
|
||||
null!,
|
||||
new() { FrequencyHz = 1000, GainDb = 3.5 },
|
||||
null!
|
||||
};
|
||||
|
||||
var filter = FftAudioWaveformEngine.BuildFfmpegEqualizerFilter(bands);
|
||||
Assert.NotNull(filter);
|
||||
Assert.Contains("equalizer=f=1000", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FftAudioWaveformEngine_BuildFfmpegEqualizerFilter_InCommaDecimalCulture_UsesDotDecimalSeparator()
|
||||
{
|
||||
var originalCulture = Thread.CurrentThread.CurrentCulture;
|
||||
try
|
||||
{
|
||||
Thread.CurrentThread.CurrentCulture = new CultureInfo("de-DE"); // German culture uses ',' for decimal
|
||||
|
||||
var bands = new List<EqualizerBand>
|
||||
{
|
||||
new() { FrequencyHz = 500, GainDb = 4.5 }
|
||||
};
|
||||
|
||||
var filter = FftAudioWaveformEngine.BuildFfmpegEqualizerFilter(bands);
|
||||
Assert.Contains("g=4.5", filter);
|
||||
Assert.DoesNotContain("g=4,5", filter);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Thread.CurrentThread.CurrentCulture = originalCulture;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 3. NeuralShaderUpscaler
|
||||
// ==========================================
|
||||
|
||||
[Fact]
|
||||
public void NeuralShaderUpscaler_GenerateHlslShaderCode_NullConfig_DoesNotThrow()
|
||||
{
|
||||
var shader = NeuralShaderUpscaler.GenerateHlslShaderCode(null!);
|
||||
Assert.NotNull(shader);
|
||||
Assert.Contains("float4 PS_NeuralUpscale", shader);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0)]
|
||||
[InlineData(-1920, -1080)]
|
||||
public void NeuralShaderUpscaler_GenerateHlslShaderCode_ZeroOrNegativeDimensions_FallsBackToDefaultResolution(int w, int h)
|
||||
{
|
||||
var config = new ShaderUpscaleConfig { TargetWidth = w, TargetHeight = h };
|
||||
var shader = NeuralShaderUpscaler.GenerateHlslShaderCode(config);
|
||||
Assert.NotNull(shader);
|
||||
Assert.DoesNotContain("1.0 / 0", shader);
|
||||
Assert.DoesNotContain("1.0 / -", shader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeuralShaderUpscaler_GenerateHlslShaderCode_InCommaDecimalCulture_UsesDotDecimalSeparator()
|
||||
{
|
||||
var originalCulture = Thread.CurrentThread.CurrentCulture;
|
||||
try
|
||||
{
|
||||
Thread.CurrentThread.CurrentCulture = new CultureInfo("fr-FR"); // French culture uses ',' for decimal
|
||||
|
||||
var config = new ShaderUpscaleConfig { Sharpness = 2.5 };
|
||||
var shader = NeuralShaderUpscaler.GenerateHlslShaderCode(config);
|
||||
|
||||
Assert.Contains("2.50", shader);
|
||||
Assert.DoesNotContain("2,50", shader);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Thread.CurrentThread.CurrentCulture = originalCulture;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 4. PacaConnectCodeGenerator
|
||||
// ==========================================
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("PACA-12345")] // 5 chars instead of 6
|
||||
[InlineData("PACA-1234567")] // 7 chars
|
||||
[InlineData("PACA-000000")] // '0' is disallowed
|
||||
[InlineData("PACA-111111")] // '1' is disallowed
|
||||
[InlineData("PACA-IIIIII")] // 'I' is disallowed
|
||||
[InlineData("PACA-OOOOOO")] // 'O' is disallowed
|
||||
[InlineData("OTHER-234567")] // wrong prefix
|
||||
public void PacaConnectCodeGenerator_IsValid_InvalidCodes_ReturnsFalse(string? code)
|
||||
{
|
||||
Assert.False(PacaConnectCodeGenerator.IsValid(code));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PacaConnectCodeGenerator_GenerateCode_AlwaysProducesValidCode()
|
||||
{
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var code = PacaConnectCodeGenerator.GenerateCode();
|
||||
Assert.True(PacaConnectCodeGenerator.IsValid(code), $"Generated code '{code}' should be valid.");
|
||||
}
|
||||
}
|
||||
}
|
||||
171
tests/Paca.Tests/Core/AudioEnginesExtremeRedTests.cs
Normal file
171
tests/Paca.Tests/Core/AudioEnginesExtremeRedTests.cs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Paca.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class AudioEnginesExtremeRedTests
|
||||
{
|
||||
[Fact]
|
||||
public void AudioPitchShiftEngine_InPlaceOverwrite_ThrowsArgumentException()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
AudioPitchShiftEngine.BuildPitchShiftCommandLine("track.mp3", "track.mp3", 2.0));
|
||||
Assert.Contains("identical", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "out.mp3")]
|
||||
[InlineData("", "out.mp3")]
|
||||
[InlineData(" ", "out.mp3")]
|
||||
[InlineData("in.mp3", null)]
|
||||
[InlineData("in.mp3", "")]
|
||||
[InlineData("in.mp3", " ")]
|
||||
public void AudioPitchShiftEngine_NullOrEmptyPaths_ThrowsArgumentException(string? inPath, string? outPath)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
AudioPitchShiftEngine.BuildPitchShiftCommandLine(inPath!, outPath!, 2.0));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(double.NaN)]
|
||||
[InlineData(double.PositiveInfinity)]
|
||||
[InlineData(double.NegativeInfinity)]
|
||||
public void AudioPitchShiftEngine_NaNOrInfinitySemitones_FallsBackToZeroShift(double invalidSemitones)
|
||||
{
|
||||
var filter = AudioPitchShiftEngine.BuildPitchShiftFilter(invalidSemitones, 44100);
|
||||
Assert.DoesNotContain("NaN", filter, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("Infinity", filter, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("asetrate=44100", filter);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-44100)]
|
||||
public void AudioPitchShiftEngine_InvalidSampleRate_ThrowsArgumentOutOfRangeException(int sampleRate)
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
AudioPitchShiftEngine.BuildPitchShiftFilter(2.0, sampleRate));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AudioDynamicRangeCompressor_InPlaceOverwrite_ThrowsArgumentException()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
AudioDynamicRangeCompressor.BuildFfmpegArgs("podcast.wav", "podcast.wav", CompressorPreset.NightListening));
|
||||
Assert.Contains("identical", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "out.mp3")]
|
||||
[InlineData("", "out.mp3")]
|
||||
[InlineData("in.mp3", null)]
|
||||
[InlineData("in.mp3", "")]
|
||||
public void AudioDynamicRangeCompressor_NullOrEmptyPaths_ThrowsArgumentException(string? inPath, string? outPath)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
AudioDynamicRangeCompressor.BuildFfmpegArgs(inPath!, outPath!, CompressorPreset.PodcastSpeech));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AudioDynamicRangeCompressor_InvalidBitrate_DefaultsTo192k()
|
||||
{
|
||||
var args = AudioDynamicRangeCompressor.BuildFfmpegArgs("in.mp4", "out.mp4", CompressorPreset.NightListening, audioBitrateKbps: 0);
|
||||
Assert.Contains("-b:a 192k", args);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(double.NaN, 4.0, 10.0, 100.0, 4.0, -1.0)]
|
||||
[InlineData(-20.0, double.PositiveInfinity, 10.0, 100.0, 4.0, -1.0)]
|
||||
[InlineData(-20.0, 4.0, double.NaN, 100.0, 4.0, -1.0)]
|
||||
[InlineData(-20.0, -1.0, 10.0, 100.0, 4.0, -1.0)] // Invalid ratio <= 0
|
||||
public void AudioDynamicRangeCompressor_InvalidCustomParameters_ThrowsArgumentOutOfRangeException(
|
||||
double th, double ratio, double attack, double release, double makeup, double limit)
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
AudioDynamicRangeCompressor.BuildCustomFilter(th, ratio, attack, release, makeup, limit));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AudioAlbumTrackSplitter_InPlaceOverwrite_ThrowsArgumentException()
|
||||
{
|
||||
var seg = new AlbumTrackSegment(1, 0.0, 60.0, "Track 01");
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
AudioAlbumTrackSplitter.BuildSplitCommand("album.flac", seg, "album.flac"));
|
||||
Assert.Contains("identical", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AudioAlbumTrackSplitter_NullTrack_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
AudioAlbumTrackSplitter.BuildSplitCommand("in.mp3", null!, "out.mp3"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(double.NaN)]
|
||||
[InlineData(double.PositiveInfinity)]
|
||||
[InlineData(-10.0)]
|
||||
public void AudioAlbumTrackSplitter_ParseSilenceToTracks_InvalidDuration_ReturnsEmpty(double totalDuration)
|
||||
{
|
||||
var tracks = AudioAlbumTrackSplitter.ParseSilenceToTracks("silence_start: 10.0\nsilence_end: 12.0", totalDuration);
|
||||
Assert.Empty(tracks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AudioAlbumTrackSplitter_GenerateM3uPlaylist_CrlfInjectionSanitized()
|
||||
{
|
||||
var tracks = new List<AlbumTrackSegment>
|
||||
{
|
||||
new(1, 0.0, 180.0, "Song\r\n#EXT-X-MALICIOUS-TAG")
|
||||
};
|
||||
var m3u = AudioAlbumTrackSplitter.GenerateM3uPlaylist("MyAlbum\r\n#FORGED-HEADER", tracks);
|
||||
var lines = m3u.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None);
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (line.StartsWith("#EXTINF:"))
|
||||
{
|
||||
Assert.DoesNotContain("\r", line);
|
||||
Assert.DoesNotContain("\n", line);
|
||||
Assert.Contains("MyAlbum", line);
|
||||
}
|
||||
Assert.False(line.Equals("#EXT-X-MALICIOUS-TAG", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.False(line.Equals("#FORGED-HEADER", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AudioVocalStemExtractor_InPlaceOverwrite_ThrowsArgumentException()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
AudioVocalStemExtractor.BuildStemCommandLine("audio.wav", "audio.wav", AudioStemMode.VocalRemovalKaraoke));
|
||||
Assert.Contains("identical", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AudioChannelDownmixer_InPlaceOverwrite_ThrowsArgumentException()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
AudioChannelDownmixer.BuildDownmixCommandLine("movie.mkv", "movie.mkv", DownmixMode.StandardItuR775));
|
||||
Assert.Contains("identical", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AudioVolumeNormalizer_InPlaceOverwrite_ThrowsArgumentException()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
AudioVolumeNormalizer.BuildNormalizeCommandLine("stream.aac", "stream.aac", AudioVolumeNormalizer.PodcastStreamingPreset));
|
||||
Assert.Contains("identical", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AudioVolumeNormalizer_NullPreset_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
AudioVolumeNormalizer.BuildNormalizeCommandLine("in.aac", "out.aac", null!));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
AudioVolumeNormalizer.BuildLoudnormFilterArgs(null!));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
using System;
|
||||
using Paca.Core.Platform;
|
||||
using Paca.Core.QuickActions;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class ClipboardAndEncoderSecurityExtremeRedTests
|
||||
{
|
||||
private readonly QuickActionClipboardDetector _detector = new();
|
||||
|
||||
[Theory]
|
||||
[InlineData("---")]
|
||||
[InlineData("----")]
|
||||
[InlineData("+++")]
|
||||
[InlineData("***")]
|
||||
[InlineData("///")]
|
||||
[InlineData("+ - * /")]
|
||||
[InlineData("(+)")]
|
||||
[InlineData("====")]
|
||||
public void QuickActionClipboardDetector_NonMathSymbols_DoesNotDetectAsMathExpression(string input)
|
||||
{
|
||||
var result = _detector.DetectIntent(input);
|
||||
Assert.NotEqual(ClipboardIntentType.Expression, result.IntentType);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("12 + 34")]
|
||||
[InlineData("100 / 5")]
|
||||
[InlineData("(3 + 4) * 2")]
|
||||
[InlineData("2 ^ 8")]
|
||||
[InlineData("50 * 1.1")]
|
||||
public void QuickActionClipboardDetector_ValidArithmetic_DetectsAsExpression(string input)
|
||||
{
|
||||
var result = _detector.DetectIntent(input);
|
||||
Assert.Equal(ClipboardIntentType.Expression, result.IntentType);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://evil.com/login?ref=youtube.com")]
|
||||
[InlineData("https://phishing.org/index.html?redirect=twitter.com")]
|
||||
[InlineData("https://attacker.site/steal?feed=tiktok.com")]
|
||||
[InlineData("https://notyoutube.com/watch?v=123")]
|
||||
public void QuickActionClipboardDetector_PhishingDomainQuery_DoesNotDetectAsMediaDownload(string input)
|
||||
{
|
||||
var result = _detector.DetectIntent(input);
|
||||
Assert.NotEqual(ClipboardIntentType.MediaDownload, result.IntentType);
|
||||
Assert.Equal(ClipboardIntentType.BrowseUrl, result.IntentType);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://www.youtube.com/watch?v=dQw4w9WgXcQ")]
|
||||
[InlineData("https://x.com/user/status/123456")]
|
||||
[InlineData("https://stream.example.com/playlist.m3u8")]
|
||||
[InlineData("https://video.example.com/movie.mp4")]
|
||||
public void QuickActionClipboardDetector_RealMediaUrls_DetectsAsMediaDownload(string input)
|
||||
{
|
||||
var result = _detector.DetectIntent(input);
|
||||
Assert.Equal(ClipboardIntentType.MediaDownload, result.IntentType);
|
||||
}
|
||||
|
||||
private sealed class LocalMockRunner : IProcessRunner
|
||||
{
|
||||
public Task<ProcessResult> RunAsync(ProcessSpec spec, Action<string, string>? onLine, System.Threading.CancellationToken ct) =>
|
||||
Task.FromResult(new ProcessResult(0, "", ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HardwareEncoderDetector_NullCapabilities_SafelyFallsBackToSoftwareCodecs()
|
||||
{
|
||||
var detector = new HardwareEncoderDetector(new LocalMockRunner());
|
||||
|
||||
Assert.Equal("libx264", detector.GetBestH264Encoder(null!));
|
||||
Assert.Equal("libx265", detector.GetBestHevcEncoder(null!));
|
||||
Assert.Equal("libsvtav1", detector.GetBestAv1Encoder(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HardwareEncoderDetector_BuildEncodingArgs_NullOrWhitespaceEncoder_DefaultsToLibx264()
|
||||
{
|
||||
var args = HardwareEncoderDetector.BuildEncodingArgs(null!, 23, "fast");
|
||||
Assert.Contains("-c:v", args);
|
||||
Assert.Contains("libx264", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HardwareEncoderDetector_BuildEncodingArgs_NullPreset_DefaultsToFast()
|
||||
{
|
||||
var args = HardwareEncoderDetector.BuildEncodingArgs("libx264", 23, null!);
|
||||
Assert.Contains("-preset", args);
|
||||
Assert.Contains("fast", args);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-10, 0)]
|
||||
[InlineData(100, 63)]
|
||||
public void HardwareEncoderDetector_BuildEncodingArgs_CrfClamping_ClampsBetweenZeroAnd63(int inputCrf, int expectedCrf)
|
||||
{
|
||||
var args = HardwareEncoderDetector.BuildEncodingArgs("libx264", inputCrf, "fast");
|
||||
var crfIdx = Array.IndexOf(args, "-crf");
|
||||
Assert.True(crfIdx >= 0);
|
||||
Assert.Equal(expectedCrf.ToString(), args[crfIdx + 1]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Paca.Core.Hls;
|
||||
using Paca.Core.Platform;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Cycle 4: 극한 RED 시나리오 - 카오스 멀티스레딩 동시성 무결성 및 경로 순회(Path Traversal) / 파일 락 방어선 검증
|
||||
/// </summary>
|
||||
public class ConcurrencyAndCacheIntegrityExtremeRedTests
|
||||
{
|
||||
[Fact]
|
||||
public void Cycle4_Gate1_VideoDeduplicator_ConcurrentStress_MustNotThrowOrCorrupt()
|
||||
{
|
||||
var deduplicator = new VideoDeduplicator(maxHammingThreshold: 5);
|
||||
const int TaskCount = 32;
|
||||
const int IterationsPerTask = 200;
|
||||
|
||||
Parallel.For(0, TaskCount, taskId =>
|
||||
{
|
||||
for (int i = 0; i < IterationsPerTask; i++)
|
||||
{
|
||||
var filePath = $"C:/media/video_{taskId}_{i}.mp4";
|
||||
ulong hash = (ulong)(taskId * 10000 + i);
|
||||
|
||||
// 동시 등록
|
||||
deduplicator.RegisterVideo(filePath, hash);
|
||||
|
||||
// 동시 검색
|
||||
var match = deduplicator.FindDuplicate(hash);
|
||||
Assert.NotNull(match);
|
||||
Assert.Equal(0, match.HammingDistance);
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Equal(TaskCount * IterationsPerTask, deduplicator.RegisteredCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cycle4_Gate2_MultiSourceSegmentAccelerator_ConcurrentStress_MustNotThrow()
|
||||
{
|
||||
var accelerator = new MultiSourceSegmentAccelerator();
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
accelerator.AddMirror($"https://cdn{i}.example.com/hls/");
|
||||
}
|
||||
|
||||
const int TaskCount = 32;
|
||||
const int IterationsPerTask = 200;
|
||||
|
||||
Parallel.For(0, TaskCount, taskId =>
|
||||
{
|
||||
for (int i = 0; i < IterationsPerTask; i++)
|
||||
{
|
||||
var targetMirror = $"https://cdn{i % 5}.example.com/hls/";
|
||||
if (i % 3 == 0)
|
||||
{
|
||||
accelerator.RecordFailure(targetMirror);
|
||||
}
|
||||
else if (i % 3 == 1)
|
||||
{
|
||||
accelerator.RecordLatency(targetMirror, TimeSpan.FromMilliseconds(20 + i));
|
||||
}
|
||||
else
|
||||
{
|
||||
var mirror = accelerator.SelectBestMirror(i);
|
||||
Assert.NotEmpty(mirror);
|
||||
var fastest = accelerator.GetFastestMirror();
|
||||
Assert.NotNull(fastest);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cycle4_Gate3_PredictiveChunkPrefetcher_ConcurrentStress_MustNotThrow()
|
||||
{
|
||||
var prefetcher = new PredictiveChunkPrefetcher(initialWindowSize: 3, minWindowSize: 1, maxWindowSize: 10, maxSamples: 10);
|
||||
const int TaskCount = 32;
|
||||
const int IterationsPerTask = 200;
|
||||
|
||||
Parallel.For(0, TaskCount, taskId =>
|
||||
{
|
||||
for (int i = 0; i < IterationsPerTask; i++)
|
||||
{
|
||||
long bytes = 1_000_000 + (taskId * 50_000);
|
||||
var duration = TimeSpan.FromMilliseconds(200 + (i % 100));
|
||||
|
||||
prefetcher.RecordChunkDownload(bytes, duration);
|
||||
var window = prefetcher.CalculateOptimalPrefetchWindow();
|
||||
Assert.InRange(window, 1, 10);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cycle4_Gate4_PartialChunkIntegrityCache_PathTraversalDefense_ReconcileAndClean()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "paca_traversal_" + Guid.NewGuid().ToString("N"));
|
||||
var partsDir = Path.Combine(tempRoot, "parts");
|
||||
Directory.CreateDirectory(partsDir);
|
||||
|
||||
// 상위 디렉터리에 피해를 줄 수 있는 카나리아 파일 생성
|
||||
var canaryFile = Path.Combine(tempRoot, "canary_sensitive_data.txt");
|
||||
File.WriteAllText(canaryFile, "CRITICAL DATA DO NOT DELETE");
|
||||
|
||||
try
|
||||
{
|
||||
var cache = new PartialChunkIntegrityCache();
|
||||
|
||||
// 악의적인 path traversal 청크 이름 주입 ("../../canary_sensitive_data.txt")
|
||||
var report = cache.ReconcileAndClean(
|
||||
partsDir,
|
||||
totalSegments: 1,
|
||||
getChunkName: _ => "../../canary_sensitive_data.txt"
|
||||
);
|
||||
|
||||
// parts 디렉터리 외부의 카나리아 파일이 삭제되지 않고 온전히 보존되어야 함
|
||||
Assert.True(File.Exists(canaryFile), "상위 디렉터리의 카나리아 파일이 Path Traversal 로 인해 삭제되었습니다!");
|
||||
Assert.Equal("CRITICAL DATA DO NOT DELETE", File.ReadAllText(canaryFile));
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (Directory.Exists(tempRoot)) Directory.Delete(tempRoot, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cycle4_Gate5_PartialChunkIntegrityCache_VerifyChunkFile_LockedFileDefense()
|
||||
{
|
||||
var tempFile = Path.Combine(Path.GetTempPath(), "paca_locked_" + Guid.NewGuid().ToString("N") + ".seg");
|
||||
File.WriteAllBytes(tempFile, new byte[1024]);
|
||||
|
||||
try
|
||||
{
|
||||
var cache = new PartialChunkIntegrityCache();
|
||||
|
||||
// 백신 또는 외부 프로세스가 독점 락(FileShare.None)을 잡고 있는 상황 시뮬레이션
|
||||
using var lockedStream = new FileStream(tempFile, FileMode.Open, FileAccess.Read, FileShare.None);
|
||||
|
||||
// unhandled IOException 크래시 없이 Corrupt 또는 Partial 로 안전하게 처리되어야 함
|
||||
var state = cache.VerifyChunkFile(tempFile, expectedBytes: 1024, expectedSha256: "dummy_hash");
|
||||
Assert.True(state == ChunkIntegrityState.Corrupt || state == ChunkIntegrityState.Partial);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (File.Exists(tempFile)) File.Delete(tempFile); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using Paca.Core.Platform;
|
||||
using Paca.Core.Util;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Cycle 1: 극한 RED 시나리오 - 파일명 정제 및 윈도우 장치 예약어, 트로이목마 Bidi 스푸핑, 경로 누수 방어 검증
|
||||
/// </summary>
|
||||
public class FileNameUtilAndCleanupExtremeRedTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("CON_long_stream_title", 3, "_CON")]
|
||||
[InlineData("PRN_document_capture", 3, "_PRN")]
|
||||
[InlineData("AUX_recording_session", 3, "_AUX")]
|
||||
[InlineData("NUL_blackhole_stream", 3, "_NUL")]
|
||||
[InlineData("COM1_serial_diagnostic", 4, "_COM1")]
|
||||
[InlineData("LPT1_parallel_port", 4, "_LPT1")]
|
||||
[InlineData("con.something.video", 3, "_con")]
|
||||
public void Cycle1_Gate1_TruncatedToReservedName_MustPrependUnderscore(string input, int maxLen, string expectedPrefix)
|
||||
{
|
||||
// Act
|
||||
var sanitized = FileNameUtil.Sanitize(input, maxLen);
|
||||
|
||||
// Assert: 윈도우 장치 예약어(CON, PRN, AUX, NUL, COM1-9, LPT1-9)와 일치해서는 안 됨
|
||||
Assert.StartsWith(expectedPrefix, sanitized, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotMatch(@"^(?i)(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$", sanitized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cycle1_Gate2_BidiTrojanSourceAndZeroWidth_MustBeSanitized()
|
||||
{
|
||||
// CVE 클래스: Trojan Source / Unicode Bidi Spoofing
|
||||
// \u202E (Right-to-Left Override): "video\u202E4pm.exe" -> 화면에는 "videoexe.mp4" 로 착시 유도
|
||||
var trojanBidi = "my_video\u202E4pm.exe";
|
||||
var sanitizedBidi = FileNameUtil.Sanitize(trojanBidi);
|
||||
|
||||
Assert.DoesNotContain('\u202E', sanitizedBidi);
|
||||
Assert.DoesNotContain('\u202A', sanitizedBidi);
|
||||
Assert.DoesNotContain('\u202B', sanitizedBidi);
|
||||
Assert.DoesNotContain('\u202C', sanitizedBidi);
|
||||
Assert.DoesNotContain('\u202D', sanitizedBidi);
|
||||
|
||||
// 제로 너비 공백(\u200B), BOM(\uFEFF) 등 인비저블 캐릭터 제거
|
||||
var zeroWidth = "video\u200B\u200C\u200D\uFEFFtitle";
|
||||
var sanitizedZero = FileNameUtil.Sanitize(zeroWidth);
|
||||
Assert.DoesNotContain('\u200B', sanitizedZero);
|
||||
Assert.DoesNotContain('\u200C', sanitizedZero);
|
||||
Assert.DoesNotContain('\u200D', sanitizedZero);
|
||||
Assert.DoesNotContain('\uFEFF', sanitizedZero);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("............", 5, "video")]
|
||||
[InlineData(" ", 8, "video")]
|
||||
[InlineData("valid_name", 0, "video")]
|
||||
[InlineData("valid_name", -5, "video")]
|
||||
[InlineData("...", 1, "video")]
|
||||
public void Cycle1_Gate3_DegenerateMaxLenAndDotSpaceCollapse_MustFallbackToVideo(string input, int maxLen, string expected)
|
||||
{
|
||||
// Act
|
||||
var result = FileNameUtil.Sanitize(input, maxLen);
|
||||
|
||||
// Assert: 빈 문자열이나 ArgumentOutOfRangeException 없이 안전한 기본값 반환
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cycle1_Gate4_DownloadMetaPathResolver_TempAndParts_MustBeInPacaHiddenDirectory()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "paca_test_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
try
|
||||
{
|
||||
var outputBase = Path.Combine(tempRoot, "sample_video");
|
||||
|
||||
var partsDir = DownloadMetaPathResolver.GetPartsDirectory(outputBase);
|
||||
var tempPartFile = DownloadMetaPathResolver.GetTempPartFilePath(outputBase);
|
||||
|
||||
// .paca 격리 서브디렉터리 내부에 생성되어야 함
|
||||
Assert.Contains(".paca", partsDir);
|
||||
Assert.Contains(".paca", tempPartFile);
|
||||
Assert.EndsWith("sample_video.parts", partsDir);
|
||||
Assert.EndsWith("sample_video.part", tempPartFile);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (Directory.Exists(tempRoot)) Directory.Delete(tempRoot, true); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
100
tests/Paca.Tests/Core/HlsAndDashResilienceExtremeRedTests.cs
Normal file
100
tests/Paca.Tests/Core/HlsAndDashResilienceExtremeRedTests.cs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using Paca.Core.Config;
|
||||
using Paca.Core.Dash;
|
||||
using Paca.Core.Hls;
|
||||
using Paca.Core.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Cycle 2: 극한 RED 시나리오 - HLS AES-128 암호화 IV/키 경계값 패딩, 블록 크기 손상 예외 정밀화, DASH 매니페스트 퍼징 검증
|
||||
/// </summary>
|
||||
public class HlsAndDashResilienceExtremeRedTests
|
||||
{
|
||||
[Fact]
|
||||
public void Cycle2_Gate1_ResolveIv_ShortHex_PadsTo16BytesBigEndian()
|
||||
{
|
||||
// RFC 8216: IV 속성은 128비트(16바이트) 빅엔디언 정수 16진수 표현
|
||||
// 0x1 과 같이 축약된 hex 가 주어졌을 때 1바이트가 아닌 16바이트로 좌측 0 패딩되어야 함
|
||||
var keyInfo = new KeyInfo(new Uri("https://example.com/key.bin"), "0x1");
|
||||
var iv = HlsDownloader.ResolveIv(keyInfo, 100);
|
||||
|
||||
Assert.Equal(16, iv.Length);
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
Assert.Equal(0, iv[i]);
|
||||
}
|
||||
Assert.Equal(1, iv[15]);
|
||||
|
||||
// 3바이트 hex (6글자)
|
||||
var keyHex6 = new KeyInfo(new Uri("https://example.com/key.bin"), "0xABCDEF");
|
||||
var iv6 = HlsDownloader.ResolveIv(keyHex6, 100);
|
||||
Assert.Equal(16, iv6.Length);
|
||||
Assert.Equal(0xAB, iv6[13]);
|
||||
Assert.Equal(0xCD, iv6[14]);
|
||||
Assert.Equal(0xEF, iv6[15]);
|
||||
|
||||
// 잘못된 비-16진수("0xZZZZ") 주입 시 크래시 없이 시퀀스 번호(100)로 안전 폴백
|
||||
var malformedKey = new KeyInfo(new Uri("https://example.com/key.bin"), "0xZZZZ");
|
||||
var ivFallback = HlsDownloader.ResolveIv(malformedKey, 100);
|
||||
Assert.Equal(16, ivFallback.Length);
|
||||
Assert.Equal(100, ivFallback[15]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cycle2_Gate2_DecryptSegment_InvalidKeyOrCorruptedBlock_ThrowsInvalidDataException()
|
||||
{
|
||||
var config = new AppConfig();
|
||||
var downloader = new HlsDownloader(new System.Net.Http.HttpClient(), config);
|
||||
var keyUri = new Uri("https://example.com/aes.key");
|
||||
var seg = new Segment(new Uri("https://example.com/seg1.ts"), 0, 1, new KeyInfo(keyUri, "0x1"));
|
||||
|
||||
// 1. 비정상 키 길이 (16바이트가 아닌 10바이트 등) -> InvalidDataException 발생 확인
|
||||
downloader.SetPreloadedKeyForTesting(keyUri, new byte[10]);
|
||||
var exKey = Assert.Throws<InvalidDataException>(() => downloader.DecryptSegmentForTesting(new byte[16], seg));
|
||||
Assert.Contains("16바이트", exKey.Message);
|
||||
|
||||
// 2. 비정상 데이터 블록 크기 (AES CBC는 16의 배수여야 함, 15바이트 주입) -> InvalidDataException 발생 확인
|
||||
downloader.SetPreloadedKeyForTesting(keyUri, new byte[16]);
|
||||
var exData = Assert.Throws<InvalidDataException>(() => downloader.DecryptSegmentForTesting(new byte[15], seg));
|
||||
Assert.Contains("블록 크기", exData.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cycle2_Gate3_DashParser_TimescaleZeroOrNegative_DefaultsToOne()
|
||||
{
|
||||
var mpdXml = """
|
||||
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" mediaPresentationDuration="PT10S">
|
||||
<Period>
|
||||
<AdaptationSet contentType="video">
|
||||
<Representation id="1" bandwidth="2000000">
|
||||
<SegmentTemplate timescale="0" duration="2" media="chunk_$Number$.m4s" startNumber="1" />
|
||||
</Representation>
|
||||
</AdaptationSet>
|
||||
</Period>
|
||||
</MPD>
|
||||
""";
|
||||
|
||||
var manifest = DashParser.Parse(mpdXml, new Uri("https://cdn.example.com/manifest.mpd"));
|
||||
Assert.NotNull(manifest);
|
||||
Assert.NotNull(manifest.Video);
|
||||
// timescale="0"이어도 0으로 나누기 예외 없이 1로 폴백되어 5개 세그먼트 생성
|
||||
Assert.NotEmpty(manifest.Video.Segments);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("P1D", 86400.0)]
|
||||
[InlineData("P1DT2H", 93600.0)]
|
||||
[InlineData("PT1H30M", 5400.0)]
|
||||
[InlineData("PT1.5S", 1.5)]
|
||||
[InlineData("", 0.0)]
|
||||
[InlineData("INVALID_DURATION", 0.0)]
|
||||
public void Cycle2_Gate4_DashParser_Iso8601Duration_SupportsDaysAndFractions(string isoDuration, double expectedSec)
|
||||
{
|
||||
double actual = DashParser.ParseDuration(isoDuration);
|
||||
Assert.Equal(expectedSec, actual, precision: 2);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
using System;
|
||||
using Paca.Core.Hls;
|
||||
using Paca.Core.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class M3u8ParserByteRangeAndfMp4ExtremeRedTests
|
||||
{
|
||||
private static readonly Uri Base = new("https://cdn.example.com/stream/playlist.m3u8");
|
||||
|
||||
[Fact]
|
||||
public void M3u8Parser_ByteRange_ExplicitLengthAndOffset_ParsedCorrectly()
|
||||
{
|
||||
var text = @"#EXTM3U
|
||||
#EXT-X-TARGETDURATION:10
|
||||
#EXT-X-BYTERANGE:1000@500
|
||||
segment.ts";
|
||||
|
||||
var playlist = M3u8Parser.Parse(text, Base);
|
||||
Assert.Single(playlist.Segments);
|
||||
var seg = playlist.Segments[0];
|
||||
Assert.Equal(1000, seg.ByteRangeLength);
|
||||
Assert.Equal(500, seg.ByteRangeOffset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void M3u8Parser_ByteRange_SequentialImplicitOffset_AccumulatesCorrectly()
|
||||
{
|
||||
var text = @"#EXTM3U
|
||||
#EXT-X-TARGETDURATION:10
|
||||
#EXT-X-BYTERANGE:1000@0
|
||||
segment.ts
|
||||
#EXT-X-BYTERANGE:2000
|
||||
segment.ts
|
||||
#EXT-X-BYTERANGE:1500
|
||||
segment.ts";
|
||||
|
||||
var playlist = M3u8Parser.Parse(text, Base);
|
||||
Assert.Equal(3, playlist.Segments.Count);
|
||||
|
||||
Assert.Equal(1000, playlist.Segments[0].ByteRangeLength);
|
||||
Assert.Equal(0, playlist.Segments[0].ByteRangeOffset);
|
||||
|
||||
Assert.Equal(2000, playlist.Segments[1].ByteRangeLength);
|
||||
Assert.Equal(1000, playlist.Segments[1].ByteRangeOffset);
|
||||
|
||||
Assert.Equal(1500, playlist.Segments[2].ByteRangeLength);
|
||||
Assert.Equal(3000, playlist.Segments[2].ByteRangeOffset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void M3u8Parser_ByteRange_InvalidNegativeOrNonNumeric_IgnoredGracefully()
|
||||
{
|
||||
var text = @"#EXTM3U
|
||||
#EXT-X-TARGETDURATION:10
|
||||
#EXT-X-BYTERANGE:-500@-100
|
||||
segment1.ts
|
||||
#EXT-X-BYTERANGE:invalid@nan
|
||||
segment2.ts";
|
||||
|
||||
var playlist = M3u8Parser.Parse(text, Base);
|
||||
Assert.Equal(2, playlist.Segments.Count);
|
||||
|
||||
Assert.Null(playlist.Segments[0].ByteRangeLength);
|
||||
Assert.Null(playlist.Segments[0].ByteRangeOffset);
|
||||
|
||||
Assert.Null(playlist.Segments[1].ByteRangeLength);
|
||||
Assert.Null(playlist.Segments[1].ByteRangeOffset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void M3u8Parser_InitSegment_WithByteRange_ParsedCorrectly()
|
||||
{
|
||||
var text = @"#EXTM3U
|
||||
#EXT-X-TARGETDURATION:10
|
||||
#EXT-X-MAP:URI=""init.mp4"",BYTERANGE=""800@0""
|
||||
#EXTINF:6.0,
|
||||
segment1.m4s";
|
||||
|
||||
var playlist = M3u8Parser.Parse(text, Base);
|
||||
Assert.NotNull(playlist.InitSegment);
|
||||
Assert.Equal("https://cdn.example.com/stream/init.mp4", playlist.InitSegment.AbsoluteUri);
|
||||
Assert.Equal(800, playlist.InitByteRangeLength);
|
||||
Assert.Equal(0, playlist.InitByteRangeOffset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void M3u8Parser_NullBaseUrl_ReturnsEmptyPlaylistWithoutCrashing()
|
||||
{
|
||||
var text = @"#EXTM3U
|
||||
#EXT-X-TARGETDURATION:10
|
||||
segment.ts";
|
||||
|
||||
var playlist = M3u8Parser.Parse(text, null!);
|
||||
Assert.NotNull(playlist);
|
||||
Assert.Empty(playlist.Segments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Variant_NegativeOrMalformedResolution_ClampsToZero()
|
||||
{
|
||||
var v = new Variant(new Uri("https://example.com/v.m3u8"), 1000, "-1920x-1080", "avc1");
|
||||
Assert.Equal(0, v.Width);
|
||||
Assert.Equal(0, v.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Variant_ComplexResolution_ParsesWidthAndHeight()
|
||||
{
|
||||
var v = new Variant(new Uri("https://example.com/v.m3u8"), 1000, "1920x1080@60fps", "avc1");
|
||||
Assert.Equal(1920, v.Width);
|
||||
Assert.Equal(1080, v.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void M3u8Parser_MediaSequence_Int64Overflow_DoesNotGoNegative()
|
||||
{
|
||||
var text = @"#EXTM3U
|
||||
#EXT-X-MEDIA-SEQUENCE:9223372036854775807
|
||||
#EXTINF:6.0,
|
||||
seg1.ts
|
||||
#EXTINF:6.0,
|
||||
seg2.ts";
|
||||
|
||||
var playlist = M3u8Parser.Parse(text, Base);
|
||||
Assert.Equal(2, playlist.Segments.Count);
|
||||
Assert.True(playlist.Segments[0].Sequence >= 0);
|
||||
Assert.True(playlist.Segments[1].Sequence >= 0);
|
||||
}
|
||||
}
|
||||
164
tests/Paca.Tests/Core/MediaMuxerAndMetadataExtremeRedTests.cs
Normal file
164
tests/Paca.Tests/Core/MediaMuxerAndMetadataExtremeRedTests.cs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Paca.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class MediaMuxerAndMetadataExtremeRedTests
|
||||
{
|
||||
private const string SampleIn1 = "C:\\Media\\video.mp4";
|
||||
private const string SampleAudio = "C:\\Media\\audio.m4a";
|
||||
private const string SampleOut = "C:\\Media\\output.mp4";
|
||||
|
||||
// ==========================================
|
||||
// 1. MediaMetadataTagWriter
|
||||
// ==========================================
|
||||
|
||||
[Fact]
|
||||
public void MediaMetadataTagWriter_BuildFfmpegArgs_NullTag_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
MediaMetadataTagWriter.BuildFfmpegArgs(null!, SampleIn1, SampleOut));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, SampleOut)]
|
||||
[InlineData(SampleIn1, null)]
|
||||
[InlineData("", SampleOut)]
|
||||
[InlineData(SampleIn1, " ")]
|
||||
public void MediaMetadataTagWriter_BuildFfmpegArgs_NullOrWhitespacePaths_ThrowsArgumentException(string? inPath, string? outPath)
|
||||
{
|
||||
var tag = new MediaMetadataTag { Title = "Song" };
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
MediaMetadataTagWriter.BuildFfmpegArgs(tag, inPath!, outPath!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MediaMetadataTagWriter_BuildFfmpegArgs_SameInputAndOutput_ThrowsInvalidOperationException()
|
||||
{
|
||||
var tag = new MediaMetadataTag { Title = "Song" };
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
MediaMetadataTagWriter.BuildFfmpegArgs(tag, SampleIn1, SampleIn1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MediaMetadataTagWriter_BuildFfmpegArgs_PathsWithQuotes_EscapedOrStripped()
|
||||
{
|
||||
var tag = new MediaMetadataTag { Title = "Song" };
|
||||
string maliciousIn = "C:\\Media\\in\"put.mp4";
|
||||
string maliciousOut = "C:\\Media\\out\"put.mp4";
|
||||
|
||||
var cmd = MediaMetadataTagWriter.BuildFfmpegArgs(tag, maliciousIn, maliciousOut);
|
||||
Assert.NotNull(cmd);
|
||||
Assert.DoesNotContain("in\"put", cmd);
|
||||
Assert.DoesNotContain("out\"put", cmd);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. MultiTrackMediaMuxer
|
||||
// ==========================================
|
||||
|
||||
[Fact]
|
||||
public void MultiTrackMediaMuxer_BuildMuxCommandLine_NullJob_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
MultiTrackMediaMuxer.BuildMuxCommandLine(null!));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void MultiTrackMediaMuxer_BuildMuxCommandLine_NullOrWhitespaceOutputPath_ThrowsArgumentException(string? badOut)
|
||||
{
|
||||
var job = new MuxJobSpecification
|
||||
{
|
||||
VideoTrack = new TrackSpec(SampleIn1),
|
||||
OutputPath = badOut!
|
||||
};
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
MultiTrackMediaMuxer.BuildMuxCommandLine(job));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiTrackMediaMuxer_BuildMuxCommandLine_NoTracks_ThrowsInvalidOperationException()
|
||||
{
|
||||
var job = new MuxJobSpecification
|
||||
{
|
||||
OutputPath = SampleOut
|
||||
};
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
MultiTrackMediaMuxer.BuildMuxCommandLine(job));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiTrackMediaMuxer_BuildMuxCommandLine_OutputPathMatchesAnyInput_ThrowsInvalidOperationException()
|
||||
{
|
||||
var job = new MuxJobSpecification
|
||||
{
|
||||
VideoTrack = new TrackSpec(SampleIn1),
|
||||
AudioTracks = new List<TrackSpec> { new(SampleAudio) },
|
||||
OutputPath = SampleAudio // Overwriting audio source!
|
||||
};
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
MultiTrackMediaMuxer.BuildMuxCommandLine(job));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiTrackMediaMuxer_BuildMuxCommandLine_TrackTitleWithQuotes_EscapedSafely()
|
||||
{
|
||||
var job = new MuxJobSpecification
|
||||
{
|
||||
VideoTrack = new TrackSpec(SampleIn1),
|
||||
AudioTracks = new List<TrackSpec> { new(SampleAudio, Title: "Director's \"Commentary\"") },
|
||||
OutputPath = SampleOut
|
||||
};
|
||||
var cmd = MultiTrackMediaMuxer.BuildMuxCommandLine(job);
|
||||
Assert.NotNull(cmd);
|
||||
Assert.DoesNotContain("title=\"Director's \"Commentary\"\"", cmd);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 3. VideoTonemapEnhancer
|
||||
// ==========================================
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, SampleOut)]
|
||||
[InlineData(SampleIn1, null)]
|
||||
public void VideoTonemapEnhancer_BuildFfmpegArgs_NullOrWhitespacePaths_ThrowsArgumentException(string? inPath, string? outPath)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
VideoTonemapEnhancer.BuildFfmpegArgs(inPath!, outPath!, TonemapAlgorithm.Hable, ColorGradePreset.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VideoTonemapEnhancer_BuildFfmpegArgs_SameInputAndOutput_ThrowsInvalidOperationException()
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
VideoTonemapEnhancer.BuildFfmpegArgs(SampleIn1, SampleIn1, TonemapAlgorithm.Hable, ColorGradePreset.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VideoTonemapEnhancer_BuildFfmpegArgs_PathsWithQuotes_EscapedOrStripped()
|
||||
{
|
||||
string maliciousIn = "C:\\Media\\in\"put.mp4";
|
||||
string maliciousOut = "C:\\Media\\out\"put.mp4";
|
||||
|
||||
var cmd = VideoTonemapEnhancer.BuildFfmpegArgs(maliciousIn, maliciousOut, TonemapAlgorithm.Hable, ColorGradePreset.None);
|
||||
Assert.NotNull(cmd);
|
||||
Assert.DoesNotContain("in\"put", cmd);
|
||||
Assert.DoesNotContain("out\"put", cmd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VideoTonemapEnhancer_BuildFfmpegArgs_InvalidEncoderOrCrf_ClampedSafely()
|
||||
{
|
||||
var cmd = VideoTonemapEnhancer.BuildFfmpegArgs(
|
||||
SampleIn1, SampleOut, TonemapAlgorithm.Hable, ColorGradePreset.None,
|
||||
crf: 999, videoEncoder: "libx264; echo pwned");
|
||||
Assert.NotNull(cmd);
|
||||
Assert.Contains("-crf 51", cmd);
|
||||
Assert.DoesNotContain(";", cmd);
|
||||
}
|
||||
}
|
||||
92
tests/Paca.Tests/Core/MediaProcessingExtremeRedTests.cs
Normal file
92
tests/Paca.Tests/Core/MediaProcessingExtremeRedTests.cs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Paca.Core.Platform;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Cycle 3: 극한 RED 시나리오 - 인플레이스 덮어쓰기 원본 파괴 방어, FFmpeg 필터 인젝션 이스케이프, 메타데이터 문법 오염 차단
|
||||
/// </summary>
|
||||
public class MediaProcessingExtremeRedTests
|
||||
{
|
||||
private sealed class DummyProcessRunner : IProcessRunner
|
||||
{
|
||||
public Task<ProcessResult> RunAsync(ProcessSpec spec, Action<string, string>? onLine, CancellationToken ct) =>
|
||||
Task.FromResult(new ProcessResult(0, "", ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cycle3_Gate1_InPlaceOverwrite_MustThrowArgumentException_ToPreventSourceDestruction()
|
||||
{
|
||||
var runner = new DummyProcessRunner();
|
||||
var trimmer = new LosslessSmartTrimmer(runner);
|
||||
var normalizer = new AudioNormalizer(runner);
|
||||
var burner = new VideoWatermarkBurner(runner);
|
||||
|
||||
var path = "C:/media/target_video.mp4";
|
||||
|
||||
// 1. 트리머가 동일 경로에 덮어쓰기를 시도하면 원본 파괴(0바이트 절단) 방지를 위해 ArgumentException 발생
|
||||
var exTrimmer = await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
trimmer.TrimLosslessAsync("ffmpeg.exe", path, path, 0, 10, default));
|
||||
Assert.Contains("동일", exTrimmer.Message);
|
||||
|
||||
// 2. 오디오 정규화기가 동일 경로에 덮어쓰기 시 ArgumentException 발생
|
||||
var exNormalizer = await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
normalizer.NormalizeAudioAsync("ffmpeg.exe", path, path, LoudnessSpec.WebStandard, default));
|
||||
Assert.Contains("동일", exNormalizer.Message);
|
||||
|
||||
// 3. 워터마크 버너가 동일 경로에 덮어쓰기 시 ArgumentException 발생
|
||||
var exBurner = await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
burner.ApplyWatermarkAsync("ffmpeg.exe", path, path, new WatermarkSpec("wm.png", WatermarkPosition.TopLeft), default));
|
||||
Assert.Contains("동일", exBurner.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cycle3_Gate2_DrawTextFilter_EscapesSingleQuotesColonsAndBackslashes()
|
||||
{
|
||||
// FFmpeg filtergraph syntax: drawtext filter requires escaping for ', :, \, %
|
||||
var dangerousText = "It's a: sample\\path %date%";
|
||||
var filter = VideoWatermarkBurner.BuildDrawTextFilter(dangerousText);
|
||||
|
||||
// 텍스트 인젝션 방어: 따옴표, 콜론, 백슬래시, 퍼센트가 안전하게 이스케이프되었는지 검증
|
||||
Assert.DoesNotContain("text='It's", filter); // 원시 따옴표 탈출 불가
|
||||
Assert.Contains(@"\'", filter); // 따옴표 이스케이프
|
||||
Assert.Contains(@"\:", filter); // 콜론 이스케이프
|
||||
Assert.Contains(@"\\", filter); // 백슬래시 이스케이프
|
||||
Assert.Contains("%%", filter); // 퍼센트 매크로 이스케이프
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cycle3_Gate3_ChapterSplitter_GenerateFfmetadata_EscapesSpecialCharacters()
|
||||
{
|
||||
// FFMETADATA1 포맷 규격: =, ;, #, \, 개행은 백슬래시로 이스케이프되어야 함
|
||||
var chapters = new List<ChapterInfo>
|
||||
{
|
||||
new(1, @"Part#1; Season=2 \ Special" + "\n" + "Subtitle", 0.0, 10.0)
|
||||
};
|
||||
|
||||
var metadata = ChapterSplitter.GenerateFfmetadata(chapters, @"Main=Title; #1 \ Top");
|
||||
|
||||
// FFMETADATA1 헤더 검증
|
||||
Assert.StartsWith(";FFMETADATA1", metadata);
|
||||
|
||||
// 특수문자 이스케이프 검증
|
||||
Assert.Contains(@"Main\=Title\; \#1 \\ Top", metadata);
|
||||
Assert.Contains(@"Part\#1\; Season\=2 \\ Special", metadata);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-10.5, "00:00:00.000")]
|
||||
[InlineData(-0.001, "00:00:00.000")]
|
||||
[InlineData(double.NaN, "00:00:00.000")]
|
||||
[InlineData(double.PositiveInfinity, "99:59:59.999")]
|
||||
[InlineData(3661.123, "01:01:01.123")]
|
||||
public void Cycle3_Gate4_VoiceActivitySubtitleAdapter_FormatTimecode_ClampsNegativeAndHandlesDegenerate(double inputSec, string expected)
|
||||
{
|
||||
var result = VoiceActivitySubtitleAdapter.FormatTimecode(inputSec);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
}
|
||||
252
tests/Paca.Tests/Core/P2pAndE2eeVaultExtremeRedTests.cs
Normal file
252
tests/Paca.Tests/Core/P2pAndE2eeVaultExtremeRedTests.cs
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Paca.Core.Net;
|
||||
using Paca.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class P2pAndE2eeVaultExtremeRedTests
|
||||
{
|
||||
// ==========================================
|
||||
// 1. P2pSwarmEngine Extreme Scenarios
|
||||
// ==========================================
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1024)]
|
||||
public void P2pSwarmEngine_CreateManifestFromBytes_ZeroOrNegativePieceLength_ClampsToDefault512KB(int pieceLength)
|
||||
{
|
||||
byte[] dummyData = new byte[1024 * 1024]; // 1MB
|
||||
var manifest = P2pSwarmEngine.CreateManifestFromBytes("test.mp4", dummyData, pieceLength);
|
||||
Assert.NotNull(manifest);
|
||||
Assert.True(manifest.PieceLength >= 16 * 1024, "PieceLength must be safely clamped to at least 16KB");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void P2pSwarmEngine_CreateManifestFromBytes_NullData_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
P2pSwarmEngine.CreateManifestFromBytes("test.mp4", null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void P2pSwarmEngine_InitializeSession_NullManifest_ThrowsArgumentNullException()
|
||||
{
|
||||
var engine = new P2pSwarmEngine();
|
||||
Assert.Throws<ArgumentNullException>(() => engine.InitializeSession(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void P2pSwarmEngine_AddPeer_NullPeer_DoesNotThrow()
|
||||
{
|
||||
var engine = new P2pSwarmEngine();
|
||||
var ex = Record.Exception(() => engine.AddPeer(null!));
|
||||
Assert.Null(ex);
|
||||
Assert.Equal(0, engine.ConnectedPeerCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void P2pSwarmEngine_AddPeer_NullBitfield_InitializesSafeBitfield()
|
||||
{
|
||||
var engine = new P2pSwarmEngine();
|
||||
var peer = new SwarmPeer { PeerId = "peer1", Bitfield = null! };
|
||||
var ex = Record.Exception(() => engine.AddPeer(peer));
|
||||
Assert.Null(ex);
|
||||
Assert.NotNull(peer.Bitfield);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void P2pSwarmEngine_IngestPieceData_NullData_ReturnsFalseWithoutThrowing()
|
||||
{
|
||||
var engine = new P2pSwarmEngine();
|
||||
var manifest = P2pSwarmEngine.CreateManifestFromBytes("sample.mp4", new byte[100], 50);
|
||||
engine.InitializeSession(manifest);
|
||||
|
||||
bool ingested = engine.IngestPieceData(0, null!);
|
||||
Assert.False(ingested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void P2pSwarmEngine_IngestPieceData_LengthMismatch_ReturnsFalse()
|
||||
{
|
||||
var engine = new P2pSwarmEngine();
|
||||
var data = new byte[100];
|
||||
var manifest = P2pSwarmEngine.CreateManifestFromBytes("sample.mp4", data, 50);
|
||||
engine.InitializeSession(manifest);
|
||||
|
||||
// Piece 0 expects 50 bytes, passing 10 bytes must fail even if empty
|
||||
bool ingested = engine.IngestPieceData(0, new byte[10]);
|
||||
Assert.False(ingested);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. CredentialDataExchange Extreme Scenarios
|
||||
// ==========================================
|
||||
|
||||
[Fact]
|
||||
public void CredentialDataExchange_ExportEncryptedVault_NullRecords_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
CredentialDataExchange.ExportEncryptedVault(null!, "MasterPass123!"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void CredentialDataExchange_ExportEncryptedVault_NullOrWhitespacePassword_ThrowsArgumentException(string? badPass)
|
||||
{
|
||||
var records = new List<SiteCredentialRecord>
|
||||
{
|
||||
new() { Id = "1", Domain = "example.com", Username = "user", DecryptedPassword = "pw" }
|
||||
};
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
CredentialDataExchange.ExportEncryptedVault(records, badPass!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CredentialDataExchange_ImportEncryptedVault_DeclaredLengthExceedsAvailableData_ThrowsInvalidDataException()
|
||||
{
|
||||
// Valid header with huge declared ciphertext length
|
||||
byte[] magic = System.Text.Encoding.ASCII.GetBytes("PACAVAULT_V1");
|
||||
using var ms = new MemoryStream();
|
||||
using var bw = new BinaryWriter(ms);
|
||||
bw.Write(magic);
|
||||
bw.Write(new byte[32]); // salt
|
||||
bw.Write(new byte[12]); // nonce
|
||||
bw.Write(new byte[16]); // tag
|
||||
bw.Write(10_000_000); // claimed 10MB
|
||||
bw.Write(new byte[10]); // but only 10 bytes present!
|
||||
bw.Flush();
|
||||
|
||||
var malformedData = ms.ToArray();
|
||||
Assert.Throws<InvalidDataException>(() =>
|
||||
CredentialDataExchange.ImportEncryptedVault(malformedData, "MasterPass123!"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CredentialDataExchange_ExportCsv_NullRecords_ReturnsHeaderOnly()
|
||||
{
|
||||
var csv = CredentialDataExchange.ExportCsv(null!);
|
||||
Assert.NotNull(csv);
|
||||
Assert.Contains("name,url,username,password", csv);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void CredentialDataExchange_ImportCsv_NullOrWhitespace_ReturnsEmptyList(string? badCsv)
|
||||
{
|
||||
var list = CredentialDataExchange.ImportCsv(badCsv!);
|
||||
Assert.NotNull(list);
|
||||
Assert.Empty(list);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CredentialDataExchange_ExportCsv_FormulaInjectionPrefixes_SanitizedSafely()
|
||||
{
|
||||
var maliciousRecord = new SiteCredentialRecord
|
||||
{
|
||||
Id = "evil",
|
||||
SiteName = "=cmd|' /C calc'!A0",
|
||||
Domain = "+1234567890",
|
||||
Username = "@dangerousUser",
|
||||
DecryptedPassword = "-unsafePassword",
|
||||
Notes = "\tmaliciousTab"
|
||||
};
|
||||
|
||||
var csv = CredentialDataExchange.ExportCsv(new[] { maliciousRecord });
|
||||
Assert.NotNull(csv);
|
||||
// Formula injection prefixes (=, +, -, @, tab) should be sanitized
|
||||
var lines = csv.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.True(lines.Length >= 2);
|
||||
string dataRow = lines[1];
|
||||
Assert.DoesNotContain(",=cmd", dataRow);
|
||||
Assert.DoesNotContain(",+123", dataRow);
|
||||
Assert.DoesNotContain(",@dan", dataRow);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 3. CrdtVaultSyncEngine Extreme Scenarios
|
||||
// ==========================================
|
||||
|
||||
[Fact]
|
||||
public void CrdtVaultSyncEngine_Merge_NullRemote_ReturnsZeroWithoutThrowing()
|
||||
{
|
||||
var engine = new CrdtVaultSyncEngine<string>();
|
||||
int applied = engine.Merge(null!);
|
||||
Assert.Equal(0, applied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CrdtVaultSyncEngine_Merge_NullOrEmptyIds_SkippedSafely()
|
||||
{
|
||||
var engine = new CrdtVaultSyncEngine<string>();
|
||||
var items = new List<CrdtItemRecord<string>>
|
||||
{
|
||||
null!,
|
||||
new() { Id = null!, Value = "A" },
|
||||
new() { Id = "", Value = "B" },
|
||||
new() { Id = "valid_1", Value = "C", LamportTimestamp = 1 }
|
||||
};
|
||||
|
||||
int applied = engine.Merge(items);
|
||||
Assert.Equal(1, applied);
|
||||
Assert.Single(engine.GetActiveItems());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void CrdtVaultSyncEngine_Set_NullOrEmptyId_ThrowsArgumentException(string? invalidId)
|
||||
{
|
||||
var engine = new CrdtVaultSyncEngine<string>();
|
||||
Assert.Throws<ArgumentException>(() => engine.Set(invalidId!, "value"));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 4. E2eeRemoteBackupProvider Extreme Scenarios
|
||||
// ==========================================
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void E2eeRemoteBackupProvider_EncryptPayload_NullOrEmptyPassword_ThrowsArgumentException(string? badPass)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
E2eeRemoteBackupProvider.EncryptPayload("{}", badPass!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void E2eeRemoteBackupProvider_DecryptPayload_InvalidMagic_ThrowsInvalidDataException()
|
||||
{
|
||||
var bogusHeader = new VaultBackupHeader
|
||||
{
|
||||
Magic = "HACKERVAULT",
|
||||
SaltBase64 = Convert.ToBase64String(new byte[16]),
|
||||
NonceBase64 = Convert.ToBase64String(new byte[12]),
|
||||
TagBase64 = Convert.ToBase64String(new byte[16]),
|
||||
CiphertextBase64 = Convert.ToBase64String(new byte[32])
|
||||
};
|
||||
string json = JsonSerializer.Serialize(bogusHeader);
|
||||
|
||||
Assert.Throws<InvalidDataException>(() =>
|
||||
E2eeRemoteBackupProvider.DecryptPayload(json, "MasterPassword123!"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-json")]
|
||||
[InlineData("{\"Magic\":\"PACAVAULT\",\"SaltBase64\":\"%%%not-base64%%%\"}")]
|
||||
public void E2eeRemoteBackupProvider_DecryptPayload_CorruptedBase64_ThrowsInvalidDataException(string corruptJson)
|
||||
{
|
||||
Assert.Throws<InvalidDataException>(() =>
|
||||
E2eeRemoteBackupProvider.DecryptPayload(corruptJson, "MasterPassword123!"));
|
||||
}
|
||||
}
|
||||
63
tests/Paca.Tests/Core/ProcessExecutionExtremeRedTests.cs
Normal file
63
tests/Paca.Tests/Core/ProcessExecutionExtremeRedTests.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Paca.Core.Platform;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Cycle 5: 극한 RED 시나리오 - 프로세스 실행 시 따옴표 감싸인 ExePath, Null 인자 방어 및 고속 취소 시 스트림 무결성 검증
|
||||
/// </summary>
|
||||
public class ProcessExecutionExtremeRedTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Cycle5_Gate1_DesktopProcessRunner_QuotedExePath_MustBeUnquotedAndExecute()
|
||||
{
|
||||
// Windows 에서 UseShellExecute=false 일 때 ExePath 에 따옴표가 있으면 Win32Exception 발생
|
||||
// DesktopProcessRunner 는 따옴표를 자동 정제하여 정상 실행해야 함
|
||||
var runner = new DesktopProcessRunner();
|
||||
var cmdExe = Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe";
|
||||
var quotedCmd = $"\"{cmdExe}\"";
|
||||
|
||||
var spec = new ProcessSpec(quotedCmd, new[] { "/c", "echo", "PACA_DESKTOP_PROCESS_RUNNER_OK" });
|
||||
var result = await runner.RunAsync(spec, null, CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, result.ExitCode);
|
||||
Assert.Contains("PACA_DESKTOP_PROCESS_RUNNER_OK", result.Stdout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cycle5_Gate2_DesktopProcessRunner_NullArgsInSpec_MustNotThrowArgumentNullException()
|
||||
{
|
||||
var runner = new DesktopProcessRunner();
|
||||
var cmdExe = Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe";
|
||||
|
||||
// 동적 인자 빌더 등에서 전달될 수 있는 null 원소 방어
|
||||
var args = new string[] { "/c", "echo", null!, "NULL_ARG_DEFENSE_OK" };
|
||||
var spec = new ProcessSpec(cmdExe, args);
|
||||
|
||||
var result = await runner.RunAsync(spec, null, CancellationToken.None);
|
||||
Assert.Equal(0, result.ExitCode);
|
||||
Assert.Contains("NULL_ARG_DEFENSE_OK", result.Stdout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cycle5_Gate3_DesktopProcessRunner_FastCancellation_MustKillTreeWithoutDisposedStreamFault()
|
||||
{
|
||||
var runner = new DesktopProcessRunner();
|
||||
var cmdExe = Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe";
|
||||
|
||||
// 10초간 대기하는 자식 프로세스
|
||||
var spec = new ProcessSpec(cmdExe, new[] { "/c", "ping", "127.0.0.1", "-n", "10" });
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
|
||||
|
||||
// 고속 취소 발생 시 ObjectDisposedException 이나 UnobservedTaskException 없이 OperationCanceledException 으로 종료
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
||||
{
|
||||
await runner.RunAsync(spec, null, cts.Token);
|
||||
});
|
||||
}
|
||||
}
|
||||
124
tests/Paca.Tests/Core/QuickActionsExtremeRedTests.cs
Normal file
124
tests/Paca.Tests/Core/QuickActionsExtremeRedTests.cs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
using System;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Paca.Core.QuickActions;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class QuickActionsExtremeRedTests
|
||||
{
|
||||
// ==========================================
|
||||
// 1. QuickActionFuzzyMatcher Memory & DoS
|
||||
// ==========================================
|
||||
|
||||
[Fact]
|
||||
public void QuickActionFuzzyMatcher_LevenshteinDistance_LargeStrings_DoesNotThrowOutOfMemory()
|
||||
{
|
||||
// 5000 character strings would allocate ~100MB in 2D array if unbounded
|
||||
string large1 = new string('A', 3000);
|
||||
string large2 = new string('B', 3000);
|
||||
|
||||
var ex = Record.Exception(() => QuickActionFuzzyMatcher.LevenshteinDistance(large1, large2));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData(null, "abc")]
|
||||
[InlineData("xyz", null)]
|
||||
public void QuickActionFuzzyMatcher_LevenshteinDistance_Nulls_HandledGracefully(string? s, string? t)
|
||||
{
|
||||
var ex = Record.Exception(() => QuickActionFuzzyMatcher.LevenshteinDistance(s, t));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. QuickActionInlineEvaluator Hardening
|
||||
// ==========================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("-5 + 10", "5")]
|
||||
[InlineData("10 * -2", "-20")]
|
||||
[InlineData("-4 * -3", "12")]
|
||||
public void QuickActionInlineEvaluator_TryEvaluateMath_NegativeNumbers_CalculatesCorrectly(string expr, string expected)
|
||||
{
|
||||
var eval = new QuickActionInlineEvaluator();
|
||||
bool success = eval.TryEvaluate(expr, out var res);
|
||||
Assert.True(success, $"Expression '{expr}' should be successfully evaluated");
|
||||
Assert.NotNull(res);
|
||||
Assert.Equal(expected, res.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("#fff", "rgb(255, 255, 255)")]
|
||||
[InlineData("#F00", "rgb(255, 0, 0)")]
|
||||
[InlineData("hex 000", "rgb(0, 0, 0)")]
|
||||
public void QuickActionInlineEvaluator_TryEvaluateDev_Hex3DigitShorthand_ExpandsCorrectly(string hexInput, string expectedRgb)
|
||||
{
|
||||
var eval = new QuickActionInlineEvaluator();
|
||||
bool success = eval.TryEvaluate(hexInput, out var res);
|
||||
Assert.True(success, $"Shorthand hex '{hexInput}' should be parsed");
|
||||
Assert.NotNull(res);
|
||||
Assert.Equal(expectedRgb, res.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuickActionInlineEvaluator_TryEvaluateDev_Base64WithNullBytes_DoesNotReturnRawNullChars()
|
||||
{
|
||||
var eval = new QuickActionInlineEvaluator();
|
||||
// Base64 containing null bytes: "\0\0\0" -> "AAAA"
|
||||
bool success = eval.TryEvaluate("b64d AAAA", out var res);
|
||||
Assert.True(success);
|
||||
Assert.NotNull(res);
|
||||
Assert.DoesNotContain('\0', res.Value);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 3. QuickActionClipboardDetector Security & Resilience
|
||||
// ==========================================
|
||||
|
||||
[Fact]
|
||||
public void QuickActionClipboardDetector_DetectIntent_EnormousClipboard_DoesNotSpikeOrHang()
|
||||
{
|
||||
var detector = new QuickActionClipboardDetector();
|
||||
string hugeText = new string('x', 500_000); // 500KB
|
||||
|
||||
var intent = detector.DetectIntent(hugeText);
|
||||
Assert.NotNull(intent);
|
||||
Assert.Equal(ClipboardIntentType.None, intent.IntentType);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("youtube.com/watch?v=dQw4w9WgXcQ")]
|
||||
[InlineData("www.youtube.com/watch?v=dQw4w9WgXcQ")]
|
||||
[InlineData("youtu.be/dQw4w9WgXcQ")]
|
||||
public void QuickActionClipboardDetector_DetectIntent_MediaUrlWithoutScheme_DetectedAsMediaDownload(string rawUrl)
|
||||
{
|
||||
var detector = new QuickActionClipboardDetector();
|
||||
var intent = detector.DetectIntent(rawUrl);
|
||||
Assert.NotNull(intent);
|
||||
Assert.Equal(ClipboardIntentType.MediaDownload, intent.IntentType);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 4. QuickActionParameterRouter Null Context Defenses
|
||||
// ==========================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("b64 hello")]
|
||||
[InlineData("b64d aGVsbG8=")]
|
||||
[InlineData("hash password123")]
|
||||
[InlineData("note sample note")]
|
||||
public async Task QuickActionParameterRouter_Handlers_NullContext_DoesNotThrowNullReferenceException(string command)
|
||||
{
|
||||
var router = new QuickActionParameterRouter();
|
||||
bool parsed = router.TryParseCommand(command, out var action);
|
||||
Assert.True(parsed);
|
||||
Assert.NotNull(action);
|
||||
|
||||
var ex = await Record.ExceptionAsync(async () => await action!.Handler!(null!));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
}
|
||||
|
||||
119
tests/Paca.Tests/Core/SecurityAndConfigExtremeRedTests.cs
Normal file
119
tests/Paca.Tests/Core/SecurityAndConfigExtremeRedTests.cs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Paca.Core.Config;
|
||||
using Paca.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class SecurityAndConfigExtremeRedTests
|
||||
{
|
||||
private const string TestBase32Secret = "JBSWY3DPEHPK3PXP"; // "Hello!" in Base32
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-10)]
|
||||
[InlineData(-1)]
|
||||
public void TotpEngine_ZeroOrNegativeTimeStep_DoesNotThrowDivideByZero(int timeStep)
|
||||
{
|
||||
var ex = Record.Exception(() =>
|
||||
TotpEngine.GenerateCurrentCode(TestBase32Secret, timeStepSeconds: timeStep));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-30)]
|
||||
public void TotpEngine_GetRemainingSeconds_ZeroOrNegativeTimeStep_DoesNotThrowDivideByZero(int timeStep)
|
||||
{
|
||||
var ex = Record.Exception(() =>
|
||||
TotpEngine.GetRemainingSeconds(timeStepSeconds: timeStep));
|
||||
Assert.Null(ex);
|
||||
var remaining = TotpEngine.GetRemainingSeconds(timeStepSeconds: timeStep);
|
||||
Assert.True(remaining > 0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-5)]
|
||||
[InlineData(20)]
|
||||
public void TotpEngine_InvalidDigits_ClampedToValidRange(int digits)
|
||||
{
|
||||
var code = TotpEngine.GenerateCurrentCode(TestBase32Secret, digits: digits);
|
||||
Assert.True(code.Length >= 6 && code.Length <= 8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CredentialSecurityAuditor_NullCredentials_ReturnsSafeReport()
|
||||
{
|
||||
var report = CredentialSecurityAuditor.PerformAudit(null!);
|
||||
Assert.NotNull(report);
|
||||
Assert.Equal(0, report.TotalAccounts);
|
||||
Assert.Equal(100, report.OverallHealthScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CredentialSecurityAuditor_NegativeDays_ClampsToPositive()
|
||||
{
|
||||
var cred = new SiteCredentialRecord
|
||||
{
|
||||
Id = "1",
|
||||
Domain = "example.com",
|
||||
Username = "user",
|
||||
DecryptedPassword = "SuperSecurePassword123!",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
LastModifiedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var report = CredentialSecurityAuditor.PerformAudit(new[] { cred }, oldPasswordDays: -10);
|
||||
Assert.Equal(0, report.OldPasswordCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CredentialSecurityAuditor_RecentlyModified_NotMarkedAsOldEvenIfCreatedLongAgo()
|
||||
{
|
||||
var cred = new SiteCredentialRecord
|
||||
{
|
||||
Id = "1",
|
||||
Domain = "example.com",
|
||||
Username = "user",
|
||||
DecryptedPassword = "SuperSecurePassword123!",
|
||||
CreatedAt = DateTime.UtcNow.AddDays(-200), // Created 200 days ago
|
||||
LastModifiedAt = DateTime.UtcNow // But modified today!
|
||||
};
|
||||
|
||||
var report = CredentialSecurityAuditor.PerformAudit(new[] { cred }, oldPasswordDays: 90);
|
||||
Assert.Equal(0, report.OldPasswordCount);
|
||||
Assert.DoesNotContain(report.Issues, i => i.IssueType == "OldPassword");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkspaceConfigSanitizer_ExcessiveWorkspaces_CappedTo20()
|
||||
{
|
||||
var many = Enumerable.Range(1, 100).Select(i => $"Workspace_{i}").ToList();
|
||||
var sanitized = WorkspaceConfigSanitizer.Sanitize(many);
|
||||
Assert.True(sanitized.Count <= 20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkspaceConfigSanitizer_OverlyLongName_TruncatedSafely()
|
||||
{
|
||||
var longName = new string('A', 200);
|
||||
var sanitized = WorkspaceConfigSanitizer.Sanitize(new[] { longName });
|
||||
Assert.Single(sanitized);
|
||||
Assert.True(sanitized[0].Length <= 30);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void PasswordSecurityEngine_EvaluateStrength_NullOrWhitespace_ReturnsZeroScore(string? emptyPass)
|
||||
{
|
||||
var result = PasswordSecurityEngine.EvaluateStrength(emptyPass!);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0, result.Score);
|
||||
Assert.Equal(PasswordGrade.VeryWeak, result.Grade);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using Paca.Browser.Content;
|
||||
using Paca.Browser.Extensions;
|
||||
using Paca.Core.Net;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class SecurityAndRateLimitingExtremeRedTests
|
||||
{
|
||||
// ==========================================
|
||||
// 1. ExtensionCspValidator
|
||||
// ==========================================
|
||||
|
||||
[Fact]
|
||||
public void ExtensionCspValidator_Validate_ManifestV3_UnsafeInlineInScriptSrc_ReturnsInvalid()
|
||||
{
|
||||
var result = ExtensionCspValidator.Validate("script-src 'self' 'unsafe-inline'; object-src 'self';", manifestVersion: 3);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.NotEmpty(result.Violations);
|
||||
Assert.Contains(result.Violations, v => v.Contains("unsafe-inline", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("script-src 'self' data:;")]
|
||||
[InlineData("script-src 'self' blob:;")]
|
||||
public void ExtensionCspValidator_Validate_ManifestV3_DataOrBlobInScriptSrc_ReturnsInvalid(string csp)
|
||||
{
|
||||
var result = ExtensionCspValidator.Validate(csp, manifestVersion: 3);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.NotEmpty(result.Violations);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(";; ; ;")]
|
||||
[InlineData(" ; ; ")]
|
||||
public void ExtensionCspValidator_Validate_EmptySemicolons_FallsBackToDefaultCsp(string csp)
|
||||
{
|
||||
var result = ExtensionCspValidator.Validate(csp, manifestVersion: 3);
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(ExtensionCspValidator.DefaultManifestV3Csp, result.NormalizedCsp);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. SiteAutoMutePolicy
|
||||
// ==========================================
|
||||
|
||||
[Fact]
|
||||
public void SiteAutoMutePolicy_AddAutoMutePattern_WildcardAsterisk_MutesAllSites()
|
||||
{
|
||||
var policy = new SiteAutoMutePolicy();
|
||||
policy.AddAutoMutePattern("*");
|
||||
|
||||
Assert.True(policy.IsAutoMute("https://youtube.com/watch?v=123"));
|
||||
Assert.True(policy.IsAutoMute("https://news.naver.com"));
|
||||
Assert.True(policy.IsAutoMute("https://example.org"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteAutoMutePolicy_Save_InvalidOrReadOnlyPath_DoesNotThrowUnhandledException()
|
||||
{
|
||||
// Using an invalid directory path that cannot be created on Windows
|
||||
var policy = new SiteAutoMutePolicy("Z:\\nonexistent_drive_9999\\invalid\\mute.json");
|
||||
var ex = Record.Exception(() => policy.AddAutoMutePattern("example.com"));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 3. HostRateLimiter
|
||||
// ==========================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("example.com:8080/api/v1", "example.com")]
|
||||
[InlineData("https://sub.4cdn.org/image.png", "sub.4cdn.org")]
|
||||
[InlineData("user:pass@reddit.com:443/r/funny", "reddit.com")]
|
||||
[InlineData(null, "")]
|
||||
[InlineData(" ", "")]
|
||||
public void HostRateLimiter_NormalizeHost_VariousInputs_NormalizesCleanly(string? input, string expected)
|
||||
{
|
||||
var host = HostRateLimiter.NormalizeHost(input);
|
||||
Assert.Equal(expected, host);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("i.4cdn.org", 1200)]
|
||||
[InlineData("boards.4channel.org", 1200)]
|
||||
[InlineData("v.redd.it", 800)]
|
||||
[InlineData("www.reddit.com", 800)]
|
||||
[InlineData("generic-site.com", 200)]
|
||||
public void HostRateLimiter_GetIntervalMs_RecognizesDomainPresets(string host, int expectedMs)
|
||||
{
|
||||
var interval = HostRateLimiter.GetIntervalMs(host);
|
||||
Assert.Equal(expectedMs, interval);
|
||||
}
|
||||
}
|
||||
71
tests/Paca.Tests/Core/SocialSiteDetectorExtremeRedTests.cs
Normal file
71
tests/Paca.Tests/Core/SocialSiteDetectorExtremeRedTests.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
using System;
|
||||
using Paca.Core.Detection;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Cycle 6: 극한 RED 시나리오 - 소셜 사이트 감지기의 상대 URI 크래시, 비-HTTP 스킴, 변칙/불완전 URL 방어선 검증
|
||||
/// </summary>
|
||||
public class SocialSiteDetectorExtremeRedTests
|
||||
{
|
||||
[Fact]
|
||||
public void Cycle6_Gate1_RelativeUri_MustNotThrowInvalidOperationException()
|
||||
{
|
||||
// 브라우저 탭 주소나 페이지 내 상대 링크에서 상대 URI가 유입될 때
|
||||
// url.Host 접근으로 인한 InvalidOperationException 크래시 방지
|
||||
var relUri1 = new Uri("/watch?v=dQw4w9WgXcQ", UriKind.Relative);
|
||||
var relUri2 = new Uri("shorts/dQw4w9WgXcQ", UriKind.Relative);
|
||||
var relUri3 = new Uri("media.mp4", UriKind.Relative);
|
||||
|
||||
bool matched1 = SocialSiteDetector.TryMatch(relUri1, out _, out _);
|
||||
Assert.False(matched1);
|
||||
|
||||
bool matched2 = SocialSiteDetector.TryMatch(relUri2, out _, out _);
|
||||
Assert.False(matched2);
|
||||
|
||||
bool matched3 = SocialSiteDetector.TryMatch(relUri3, out _, out _);
|
||||
Assert.False(matched3);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("data:text/html,<html>test</html>")]
|
||||
[InlineData("javascript:alert(1)")]
|
||||
[InlineData("file:///C:/videos/test.mp4")]
|
||||
[InlineData("about:blank")]
|
||||
public void Cycle6_Gate2_NonHttpSchemes_MustReturnFalseSafely(string uriString)
|
||||
{
|
||||
var uri = new Uri(uriString);
|
||||
bool matched = SocialSiteDetector.TryMatch(uri, out _, out _);
|
||||
Assert.False(matched);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://www.youtube.com/watch?v=short")]
|
||||
[InlineData("https://www.youtube.com/watch?v=waytoolongid1234567890")]
|
||||
[InlineData("https://www.youtube.com/watch?v=invalid@id!")]
|
||||
[InlineData("https://www.youtube.com/watch?v=")]
|
||||
[InlineData("https://www.youtube.com/watch")]
|
||||
[InlineData("https://www.youtube.com/channel/UC123456")]
|
||||
[InlineData("https://www.youtube.com/user/pewdiepie")]
|
||||
public void Cycle6_Gate3_YouTubeIdBoundaryFuzzing_MustRejectInvalidUrls(string urlString)
|
||||
{
|
||||
var uri = new Uri(urlString);
|
||||
bool matched = SocialSiteDetector.TryMatch(uri, out _, out _);
|
||||
Assert.False(matched);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://www.instagram.com/p/")]
|
||||
[InlineData("https://www.instagram.com/reel/")]
|
||||
[InlineData("https://www.tiktok.com/@someuser")]
|
||||
[InlineData("https://www.tiktok.com/")]
|
||||
[InlineData("https://www.facebook.com/watch/")]
|
||||
[InlineData("https://www.facebook.com/reel/")]
|
||||
public void Cycle6_Gate4_InstagramAndTikTok_MalformedPaths_MustReject(string urlString)
|
||||
{
|
||||
var uri = new Uri(urlString);
|
||||
bool matched = SocialSiteDetector.TryMatch(uri, out _, out _);
|
||||
Assert.False(matched);
|
||||
}
|
||||
}
|
||||
144
tests/Paca.Tests/Core/SpeedAndBitrateScheduleExtremeRedTests.cs
Normal file
144
tests/Paca.Tests/Core/SpeedAndBitrateScheduleExtremeRedTests.cs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Paca.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class SpeedAndBitrateScheduleExtremeRedTests
|
||||
{
|
||||
private const string SampleInput = "C:\\Media\\input.mp4";
|
||||
private const string SampleOutput = "C:\\Media\\output.mp4";
|
||||
|
||||
// ==========================================
|
||||
// 1. MediaPlaybackSpeedPitchEngine
|
||||
// ==========================================
|
||||
|
||||
[Fact]
|
||||
public void MediaPlaybackSpeedPitchEngine_BuildCommandLine_SameInputAndOutput_ThrowsInvalidOperationException()
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
MediaPlaybackSpeedPitchEngine.BuildSpeedCommandLine(SampleInput, SampleInput, 1.5));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, SampleOutput)]
|
||||
[InlineData(SampleInput, null)]
|
||||
[InlineData("", SampleOutput)]
|
||||
[InlineData(SampleInput, " ")]
|
||||
public void MediaPlaybackSpeedPitchEngine_BuildCommandLine_NullOrWhitespacePaths_ThrowsArgumentException(string? inPath, string? outPath)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
MediaPlaybackSpeedPitchEngine.BuildSpeedCommandLine(inPath!, outPath!, 1.5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MediaPlaybackSpeedPitchEngine_BuildCommandLine_PathsWithQuotes_EscapedOrStripped()
|
||||
{
|
||||
string maliciousIn = "C:\\Media\\in\"put.mp4";
|
||||
string maliciousOut = "C:\\Media\\out\"put.mp4";
|
||||
|
||||
var cmd = MediaPlaybackSpeedPitchEngine.BuildSpeedCommandLine(maliciousIn, maliciousOut, 2.0);
|
||||
Assert.NotNull(cmd);
|
||||
Assert.DoesNotContain("in\"put", cmd);
|
||||
Assert.DoesNotContain("out\"put", cmd);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. MediaBitrateEstimator
|
||||
// ==========================================
|
||||
|
||||
[Fact]
|
||||
public void MediaBitrateEstimator_BuildTwoPassCommands_SameInputAndOutput_ThrowsInvalidOperationException()
|
||||
{
|
||||
var plan = new BitrateEstimationResult(true, 2500, 128, 50_000_000);
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
MediaBitrateEstimator.BuildTwoPassCommands(SampleInput, SampleInput, plan));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, SampleOutput)]
|
||||
[InlineData(SampleInput, null)]
|
||||
[InlineData("", SampleOutput)]
|
||||
[InlineData(SampleInput, " ")]
|
||||
public void MediaBitrateEstimator_BuildTwoPassCommands_NullOrWhitespacePaths_ThrowsArgumentException(string? inPath, string? outPath)
|
||||
{
|
||||
var plan = new BitrateEstimationResult(true, 2500, 128, 50_000_000);
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
MediaBitrateEstimator.BuildTwoPassCommands(inPath!, outPath!, plan));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MediaBitrateEstimator_BuildTwoPassCommands_PathsWithQuotes_EscapedOrStripped()
|
||||
{
|
||||
var plan = new BitrateEstimationResult(true, 2500, 128, 50_000_000);
|
||||
string maliciousIn = "C:\\Media\\in\"put.mp4";
|
||||
string maliciousOut = "C:\\Media\\out\"put.mp4";
|
||||
|
||||
var (p1, p2) = MediaBitrateEstimator.BuildTwoPassCommands(maliciousIn, maliciousOut, plan);
|
||||
Assert.NotNull(p1);
|
||||
Assert.NotNull(p2);
|
||||
Assert.DoesNotContain("in\"put", p1);
|
||||
Assert.DoesNotContain("out\"put", p2);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1024)]
|
||||
public void MediaBitrateEstimator_CalculateBitrate_ZeroOrNegativeTarget_ReturnsNotPossible(long badTarget)
|
||||
{
|
||||
var result = MediaBitrateEstimator.CalculateBitrate(60.0, badTarget);
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.IsPossible);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 3. BandwidthScheduleProfileEngine
|
||||
// ==========================================
|
||||
|
||||
[Fact]
|
||||
public void BandwidthScheduleProfileEngine_GetActiveProfile_NullConfig_ReturnsSafeDefault()
|
||||
{
|
||||
var profile = BandwidthScheduleProfileEngine.GetActiveProfile(DateTime.UtcNow, null!);
|
||||
Assert.NotNull(profile);
|
||||
Assert.Equal("Default", profile.ProfileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BandwidthScheduleProfileEngine_GetActiveProfile_ConfigWithNullRules_SkipsNullRulesSafely()
|
||||
{
|
||||
var config = new BandwidthScheduleConfig
|
||||
{
|
||||
IsEnabled = true,
|
||||
DefaultProfile = new BandwidthProfile("Default", 0, 4),
|
||||
Rules = new List<BandwidthTimeRule>
|
||||
{
|
||||
null!,
|
||||
new(TimeSpan.FromHours(1), TimeSpan.FromHours(2), null!)
|
||||
}
|
||||
};
|
||||
|
||||
var ex = Record.Exception(() => BandwidthScheduleProfileEngine.GetActiveProfile(DateTime.UtcNow, config));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 4. HlsManifestValidator
|
||||
// ==========================================
|
||||
|
||||
[Fact]
|
||||
public void HlsManifestValidator_Analyze_NullManifest_ReturnsSafeEmptyResult()
|
||||
{
|
||||
var result = HlsManifestValidator.Analyze(null!);
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.IsMasterPlaylist);
|
||||
Assert.Equal(0, result.SegmentCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HlsManifestValidator_StripAdBlocks_NullManifest_ReturnsEmptyString()
|
||||
{
|
||||
var stripped = HlsManifestValidator.StripAdBlocks(null!);
|
||||
Assert.NotNull(stripped);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Paca.Core;
|
||||
using Paca.Core.Utils;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class StreamingAndResourceGuardianExtremeRedTests
|
||||
{
|
||||
// ==========================================
|
||||
// 1. TranscodeProfileManager & ThermalGuard
|
||||
// ==========================================
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void TranscodeProfileManager_GetFfmpegProfileArgs_NullOrWhitespace_ReturnsCopySafeFallback(string? badProfile)
|
||||
{
|
||||
var manager = new TranscodeProfileManager();
|
||||
var args = manager.GetFfmpegProfileArgs(badProfile!);
|
||||
Assert.NotNull(args);
|
||||
Assert.Equal("-c copy", args);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-5)]
|
||||
public void ThermalThrottlingGuard_AdjustedSegments_ZeroOrNegativeRequested_ReturnsAtLeastOne(int badSegments)
|
||||
{
|
||||
var guard = new ThermalThrottlingGuard();
|
||||
int adjusted = guard.GetAdjustedSegments(badSegments, 25.0); // Normal temp
|
||||
Assert.True(adjusted >= 1, "Segments must be at least 1 even if requested was <= 0");
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. RemoteStreamProxyBuffer Bound & Safety
|
||||
// ==========================================
|
||||
|
||||
[Fact]
|
||||
public void RemoteStreamProxyBuffer_NullData_DoesNotThrowNullReference()
|
||||
{
|
||||
var buffer = new RemoteStreamProxyBuffer(1024);
|
||||
var ex = Record.Exception(() => buffer.Write(null!));
|
||||
Assert.Null(ex);
|
||||
Assert.Equal(0, buffer.BufferedBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoteStreamProxyBuffer_ExceedingCapacity_EvictsOldestBytesToRespectCapacity()
|
||||
{
|
||||
const int capacity = 100;
|
||||
var buffer = new RemoteStreamProxyBuffer(capacity);
|
||||
|
||||
// Write 60 bytes of 0x01
|
||||
byte[] chunk1 = new byte[60];
|
||||
Array.Fill(chunk1, (byte)1);
|
||||
buffer.Write(chunk1);
|
||||
Assert.Equal(60, buffer.BufferedBytes);
|
||||
|
||||
// Write 60 bytes of 0x02 (Total would be 120, exceeding capacity 100)
|
||||
byte[] chunk2 = new byte[60];
|
||||
Array.Fill(chunk2, (byte)2);
|
||||
buffer.Write(chunk2);
|
||||
|
||||
// Capacity must NOT be exceeded
|
||||
Assert.True(buffer.BufferedBytes <= capacity, $"Buffered bytes ({buffer.BufferedBytes}) must not exceed capacity ({capacity})");
|
||||
|
||||
// Reading remaining bytes should contain the newer bytes (0x02)
|
||||
byte[] readTarget = new byte[capacity];
|
||||
int readCount = buffer.Read(readTarget, 0, capacity);
|
||||
Assert.Equal(buffer.CapacityBytes, readCount);
|
||||
Assert.Equal(2, readTarget[readCount - 1]); // Last byte must be from chunk2
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 3. DiskSpaceGuardian OS HResult & Unnesting
|
||||
// ==========================================
|
||||
|
||||
[Fact]
|
||||
public void DiskSpaceGuardian_IsDiskFullException_HResultCheck_ReturnsTrueEvenWithUnknownLanguageMessage()
|
||||
{
|
||||
// 0x80070070 = HRESULT for ERROR_DISK_FULL (112)
|
||||
var foreignEx = new IOException("Espace disque insuffisant sur le lecteur C:", unchecked((int)0x80070070));
|
||||
bool isFull = DiskSpaceGuardian.IsDiskFullException(foreignEx);
|
||||
Assert.True(isFull, "Must detect disk full via HResult 0x80070070 regardless of OS language");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiskSpaceGuardian_IsDiskFullException_WrappedInAggregateException_DetectsInnerDiskFull()
|
||||
{
|
||||
var inner = new IOException("There is not enough space on the disk.");
|
||||
var aggregate = new AggregateException("Task failed", inner);
|
||||
bool isFull = DiskSpaceGuardian.IsDiskFullException(aggregate);
|
||||
Assert.True(isFull, "Must unnest AggregateException to find inner disk full exception");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-100)]
|
||||
public void DiskSpaceGuardian_HasSufficientSpace_ZeroOrNegativeBytes_ReturnsTrue(long bytes)
|
||||
{
|
||||
bool result = DiskSpaceGuardian.HasSufficientSpace("C:\\fake\\path.mp4", bytes);
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void DiskSpaceGuardian_HasSufficientSpace_NullOrWhitespacePath_ReturnsTrue(string? badPath)
|
||||
{
|
||||
bool result = DiskSpaceGuardian.HasSufficientSpace(badPath!, 1024);
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 4. ThrottledStream & BufferPool Safety
|
||||
// ==========================================
|
||||
|
||||
[Fact]
|
||||
public void ThrottledStream_NullBaseStream_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new ThrottledStream(null!, 1024));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrottledStream_ZeroOrNegativeMaxBytes_DoesNotThrowAndReadsNormally()
|
||||
{
|
||||
byte[] source = new byte[] { 1, 2, 3, 4, 5 };
|
||||
using var ms = new MemoryStream(source);
|
||||
using var throttled = new ThrottledStream(ms, maxBytesPerSecond: 0); // Unthrottled
|
||||
|
||||
byte[] dest = new byte[5];
|
||||
int read = throttled.Read(dest, 0, 5);
|
||||
Assert.Equal(5, read);
|
||||
Assert.Equal(source, dest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BufferPoolManager_Return_NullArray_DoesNotThrow()
|
||||
{
|
||||
var ex = Record.Exception(() => BufferPoolManager.Return(null!));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-100)]
|
||||
public void AdaptiveChunkSizingEngine_NegativeOrZeroSpeed_ReturnsMinimum512KB(int speed)
|
||||
{
|
||||
var engine = new AdaptiveChunkSizingEngine();
|
||||
int size = engine.CalculateOptimalChunkSize(speed);
|
||||
Assert.Equal(512 * 1024, size);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Paca.Core;
|
||||
using Paca.Core.Net;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class SubtitleAndNetworkResilienceExtremeRedTests
|
||||
{
|
||||
[Fact]
|
||||
public void SubtitleBurnInEngine_InPlaceOverwrite_ThrowsArgumentException()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
SubtitleBurnInEngine.BuildBurnInCommandLine("movie.mp4", "sub.srt", "movie.mp4"));
|
||||
Assert.Contains("identical", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "sub.srt", "out.mp4")]
|
||||
[InlineData("", "sub.srt", "out.mp4")]
|
||||
[InlineData("in.mp4", null, "out.mp4")]
|
||||
[InlineData("in.mp4", "", "out.mp4")]
|
||||
[InlineData("in.mp4", "sub.srt", null)]
|
||||
[InlineData("in.mp4", "sub.srt", "")]
|
||||
public void SubtitleBurnInEngine_NullOrEmptyPaths_ThrowsArgumentException(string? inVid, string? sub, string? outVid)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
SubtitleBurnInEngine.BuildBurnInCommandLine(inVid!, sub!, outVid!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubtitleBurnInEngine_EscapePath_EscapesSquareBrackets()
|
||||
{
|
||||
var path = @"C:\Downloads\[SubsPlease] Video [1080p].srt";
|
||||
var escaped = SubtitleBurnInEngine.EscapeSubtitlePathForFfmpeg(path);
|
||||
|
||||
Assert.Contains(@"\[", escaped);
|
||||
Assert.Contains(@"\]", escaped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubtitleBurnInEngine_FontNameInjection_Sanitized()
|
||||
{
|
||||
var style = new SubtitleBurnInStyle
|
||||
{
|
||||
FontName = "Arial,Bold;drop=all",
|
||||
FontSize = 24
|
||||
};
|
||||
var forceStyle = SubtitleBurnInEngine.BuildForceStyleString(style);
|
||||
|
||||
Assert.DoesNotContain("Arial,Bold", forceStyle);
|
||||
Assert.DoesNotContain(";", forceStyle);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-5)]
|
||||
public void SubtitleBurnInEngine_InvalidFontSize_ThrowsArgumentOutOfRangeException(int invalidSize)
|
||||
{
|
||||
var style = new SubtitleBurnInStyle { FontSize = invalidSize };
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
SubtitleBurnInEngine.BuildForceStyleString(style));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://i.4cdn.org/wsg/1234567.webm", 1200)]
|
||||
[InlineData("http://is2.4chan.org/pol/src/123.jpg", 1200)]
|
||||
[InlineData("i.4cdn.org:443", 1200)]
|
||||
[InlineData("https://v.redd.it/xyz987/DASH_720.mp4", 800)]
|
||||
[InlineData("reddit.com:80", 800)]
|
||||
[InlineData("https://example.com/video.mp4", 200)]
|
||||
public void HostRateLimiter_FullUrlAndPort_NormalizesAndRecognizesDomain(string input, int expectedInterval)
|
||||
{
|
||||
var interval = HostRateLimiter.GetDefaultIntervalMs(input);
|
||||
Assert.Equal(expectedInterval, interval);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultiCdnMeshDownloader_NullMirrorList_ReturnsFailureGracefully()
|
||||
{
|
||||
var downloader = new MultiCdnMeshDownloader();
|
||||
var result = await downloader.RaceDownloadSegmentAsync(null!);
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultiCdnMeshDownloader_RelativeUri_DoesNotThrowInvalidOperationException()
|
||||
{
|
||||
var downloader = new MultiCdnMeshDownloader();
|
||||
var mirrors = new List<Uri> { new("/relative/path/seg0.ts", UriKind.Relative) };
|
||||
|
||||
var ex = await Record.ExceptionAsync(() => downloader.RaceDownloadSegmentAsync(mirrors));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultiCdnMeshDownloader_NegativeStagger_DoesNotThrow()
|
||||
{
|
||||
var downloader = new MultiCdnMeshDownloader();
|
||||
var mirrors = new List<Uri>
|
||||
{
|
||||
new("https://cdn1.example.com/seg0.ts"),
|
||||
new("https://cdn2.example.com/seg0.ts")
|
||||
};
|
||||
|
||||
var ex = await Record.ExceptionAsync(() => downloader.RaceDownloadSegmentAsync(mirrors, staggerDelayMs: -50));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
}
|
||||
167
tests/Paca.Tests/Core/VideoEnginesExtremeRedTests.cs
Normal file
167
tests/Paca.Tests/Core/VideoEnginesExtremeRedTests.cs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
using System;
|
||||
using Paca.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class VideoEnginesExtremeRedTests
|
||||
{
|
||||
private const string SampleInput = "C:\\Videos\\input.mp4";
|
||||
private const string SampleOutput = "C:\\Videos\\output.mp4";
|
||||
|
||||
// ==========================================
|
||||
// 1. VideoAspectAutoCropper
|
||||
// ==========================================
|
||||
|
||||
[Fact]
|
||||
public void VideoAspectAutoCropper_BuildCommandLine_SameInputAndOutput_ThrowsInvalidOperationException()
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
VideoAspectAutoCropper.BuildReframingCommandLine(SampleInput, SampleInput, ReframingMode.CenterCrop));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, SampleOutput)]
|
||||
[InlineData(SampleInput, null)]
|
||||
[InlineData("", SampleOutput)]
|
||||
[InlineData(SampleInput, " ")]
|
||||
public void VideoAspectAutoCropper_BuildCommandLine_NullOrWhitespace_ThrowsArgumentException(string? inPath, string? outPath)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
VideoAspectAutoCropper.BuildReframingCommandLine(inPath!, outPath!, ReframingMode.CenterCrop));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. VideoIntroOutroTrimmer
|
||||
// ==========================================
|
||||
|
||||
[Fact]
|
||||
public void VideoIntroOutroTrimmer_BuildCommandLine_SameInputAndOutput_ThrowsInvalidOperationException()
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
VideoIntroOutroTrimmer.BuildLosslessTrimCommandLine(SampleInput, SampleInput, 0, 10));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, SampleOutput)]
|
||||
[InlineData(SampleInput, null)]
|
||||
public void VideoIntroOutroTrimmer_BuildCommandLine_NullOrWhitespace_ThrowsArgumentException(string? inPath, string? outPath)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
VideoIntroOutroTrimmer.BuildLosslessTrimCommandLine(inPath!, outPath!, 0, 10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VideoIntroOutroTrimmer_BuildCommandLine_NegativeStartOrDuration_ClampsToSafeBounds()
|
||||
{
|
||||
var cmd = VideoIntroOutroTrimmer.BuildLosslessTrimCommandLine(SampleInput, SampleOutput, -10.5, -5.0);
|
||||
Assert.NotNull(cmd);
|
||||
Assert.Contains("-ss 0.00", cmd);
|
||||
Assert.DoesNotContain("-t -", cmd);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 3. VideoPosterExtractor
|
||||
// ==========================================
|
||||
|
||||
[Fact]
|
||||
public void VideoPosterExtractor_BuildCommandLine_SameInputAndOutput_ThrowsInvalidOperationException()
|
||||
{
|
||||
var spec = new PosterExtractionSpec(5.0, 640, 360, PosterFormat.Jpeg);
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
VideoPosterExtractor.BuildPosterCommandLine(SampleInput, SampleInput, spec));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, SampleOutput)]
|
||||
[InlineData(SampleInput, null)]
|
||||
public void VideoPosterExtractor_BuildCommandLine_NullOrWhitespace_ThrowsArgumentException(string? inPath, string? outPath)
|
||||
{
|
||||
var spec = new PosterExtractionSpec(5.0, 640, 360, PosterFormat.Jpeg);
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
VideoPosterExtractor.BuildPosterCommandLine(inPath!, outPath!, spec));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 4. VideoSpriteSheetGenerator
|
||||
// ==========================================
|
||||
|
||||
[Fact]
|
||||
public void VideoSpriteSheetGenerator_BuildCommandLine_SameInputAndOutput_ThrowsInvalidOperationException()
|
||||
{
|
||||
var config = new SpriteSheetConfig(5, 5, 160, 90, 10);
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
VideoSpriteSheetGenerator.BuildFfmpegSpriteCommandLine(SampleInput, SampleInput, config));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, SampleOutput)]
|
||||
[InlineData(SampleInput, null)]
|
||||
public void VideoSpriteSheetGenerator_BuildCommandLine_NullOrWhitespace_ThrowsArgumentException(string? inPath, string? outPath)
|
||||
{
|
||||
var config = new SpriteSheetConfig(5, 5, 160, 90, 10);
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
VideoSpriteSheetGenerator.BuildFfmpegSpriteCommandLine(inPath!, outPath!, config));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 5. VideoFrameRateConverter
|
||||
// ==========================================
|
||||
|
||||
[Fact]
|
||||
public void VideoFrameRateConverter_BuildFfmpegArgs_SameInputAndOutput_ThrowsInvalidOperationException()
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
VideoFrameRateConverter.BuildFfmpegArgs(SampleInput, SampleInput, 60, FrameRateConversionMode.Simple));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, SampleOutput)]
|
||||
[InlineData(SampleInput, null)]
|
||||
public void VideoFrameRateConverter_BuildFfmpegArgs_NullOrWhitespace_ThrowsArgumentException(string? inPath, string? outPath)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
VideoFrameRateConverter.BuildFfmpegArgs(inPath!, outPath!, 60, FrameRateConversionMode.Simple));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VideoFrameRateConverter_BuildFfmpegArgs_ExtremeFpsAndCrf_ClampedToValidRange()
|
||||
{
|
||||
var cmd = VideoFrameRateConverter.BuildFfmpegArgs(SampleInput, SampleOutput, 1000, FrameRateConversionMode.Simple, crf: 99);
|
||||
Assert.NotNull(cmd);
|
||||
Assert.Contains("fps=240", cmd);
|
||||
Assert.Contains("-crf 51", cmd);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 6. VideoDenoiseEnhancer
|
||||
// ==========================================
|
||||
|
||||
[Fact]
|
||||
public void VideoDenoiseEnhancer_BuildCommandLine_SameInputAndOutput_ThrowsInvalidOperationException()
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
VideoDenoiseEnhancer.BuildDenoiseCommandLine(SampleInput, SampleInput, true, true));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, SampleOutput)]
|
||||
[InlineData(SampleInput, null)]
|
||||
public void VideoDenoiseEnhancer_BuildCommandLine_NullOrWhitespace_ThrowsArgumentException(string? inPath, string? outPath)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
VideoDenoiseEnhancer.BuildDenoiseCommandLine(inPath!, outPath!, true, true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VideoEngines_PathsWithQuotes_EscapedOrStrippedSafely()
|
||||
{
|
||||
string maliciousIn = "C:\\test\\in\"jection.mp4";
|
||||
string maliciousOut = "C:\\test\\out\"jection.mp4";
|
||||
|
||||
var cmd = VideoAspectAutoCropper.BuildReframingCommandLine(maliciousIn, maliciousOut, ReframingMode.CenterCrop);
|
||||
Assert.NotNull(cmd);
|
||||
Assert.DoesNotContain("in\"jection", cmd);
|
||||
Assert.DoesNotContain("out\"jection", cmd);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue