using System; using System.IO; using System.Linq; using Paca.Avalonia; using Paca.Mobile.Core; using Xunit; namespace Paca.Tests.Platform; /// /// Phase 5: 크로스플랫폼 패리티 및 모바일/데스크톱 견고성 헌법 게이트 (Gate 501 ~ 503). /// - Gate 501: Paca.Avalonia MainViewModel 일시정지/재개(Pause/Resume) 및 IDisposable 생명주기 /// - Gate 502: Paca.Mobile.Core LanDiscoveryProber 널 안전성, IPv6 및 P2P QR TTL 만료 검증 /// - Gate 503: Paca.Mobile.Core SmartCollectionEngine 널 타이틀 방어 및 M3U8 개행문자 살균 /// public class CrossPlatformParityHardeningPhase5Tests { [Fact] public void Gate501_Avalonia_MainViewModel_Pause_Resume_And_Disposable_Lifecycle_Audit() { using var vm = new MainViewModel(); // 1. IDisposable 구현 검증 Assert.IsAssignableFrom(vm); // 2. PauseCommand & ResumeCommand 배선 확인 Assert.NotNull(vm.PauseCommand); Assert.NotNull(vm.ResumeCommand); Assert.NotNull(vm.DownloadCommand); Assert.NotNull(vm.CancelCommand); // 3. 초기 상태: IsBusy=false, IsPaused=false, CanExecute 검증 Assert.False(vm.IsBusy); Assert.False(vm.IsPaused); Assert.False(vm.PauseCommand.CanExecute(null)); Assert.False(vm.ResumeCommand.CanExecute(null)); // 4. IsBusy 상태 시뮬레이션 vm.IsBusy = true; Assert.True(vm.PauseCommand.CanExecute(null)); Assert.False(vm.ResumeCommand.CanExecute(null)); // 5. 일시정지(Pause) 실행 vm.Pause(); Assert.True(vm.IsPaused); Assert.Equal("일시정지됨", vm.Status); Assert.False(vm.PauseCommand.CanExecute(null)); Assert.True(vm.ResumeCommand.CanExecute(null)); // 6. 재개(Resume) 실행 vm.Resume(); Assert.False(vm.IsPaused); Assert.Equal("다운로드 재개 중...", vm.Status); Assert.True(vm.PauseCommand.CanExecute(null)); Assert.False(vm.ResumeCommand.CanExecute(null)); // 7. 다운로드 완료(IsBusy=false) 시 IsPaused 자동 초기화 vm.Pause(); Assert.True(vm.IsPaused); vm.IsBusy = false; Assert.False(vm.IsPaused); Assert.False(vm.PauseCommand.CanExecute(null)); Assert.False(vm.ResumeCommand.CanExecute(null)); } [Fact] public void Gate502_LanDiscoveryProber_Robust_Address_And_NullSafe_Packet_Handling_Audit() { var prober = new LanDiscoveryProber(); // 1. Null/Whitespace 페이로드 안전성 (예외 미발생) prober.HandleReceivedPayload(null!, null!); prober.HandleReceivedPayload("", "192.168.1.50"); prober.HandleReceivedPayload(" ", ""); // 2. RemoteIp가 null/빈값인 경우 127.0.0.1 기본 폴백 var pong = LanDiscoveryMessage.CreatePong("PC-NullIp", ":5000", true, "FP-001"); prober.HandleReceivedPayload(pong.ToPayload(), null!); var devices = prober.GetActiveDevices(); var devNullIp = devices.FirstOrDefault(d => d.DeviceName == "PC-NullIp"); Assert.NotNull(devNullIp); Assert.Equal("127.0.0.1:5000", devNullIp.Endpoint); // 3. IPv6 remoteIp의 브래킷([]) 포맷팅 검증 var pongIpv6 = LanDiscoveryMessage.CreatePong("PC-IPv6", ":52100", true, "FP-IPV6"); prober.HandleReceivedPayload(pongIpv6.ToPayload(), "fe80::1"); devices = prober.GetActiveDevices(); var devIpv6 = devices.FirstOrDefault(d => d.DeviceName == "PC-IPv6"); Assert.NotNull(devIpv6); Assert.Equal("[fe80::1]:52100", devIpv6.Endpoint); Assert.Equal("http://[fe80::1]:52100", devIpv6.HttpBaseUrl); // 4. P2pQrPayloadCodec TTL 만료 검증 var now = DateTimeOffset.UtcNow; var freshPayload = new P2pQrPayload { Host = "192.168.1.10", Port = 5000, Token = "token-fresh", CreatedAtUnix = now.ToUnixTimeSeconds() }; var encodedFresh = P2pQrPayloadCodec.Encode(freshPayload); Assert.Contains("&ts=", encodedFresh); var decodedFresh = P2pQrPayloadCodec.Decode(encodedFresh); Assert.Equal(freshPayload.CreatedAtUnix, decodedFresh.CreatedAtUnix); Assert.False(P2pQrPayloadCodec.IsExpired(decodedFresh, TimeSpan.FromMinutes(5), now)); var expiredPayload = new P2pQrPayload { Host = "192.168.1.10", Port = 5000, Token = "token-expired", CreatedAtUnix = now.AddMinutes(-10).ToUnixTimeSeconds() }; var encodedExpired = P2pQrPayloadCodec.Encode(expiredPayload); var decodedExpired = P2pQrPayloadCodec.Decode(encodedExpired); Assert.True(P2pQrPayloadCodec.IsExpired(decodedExpired, TimeSpan.FromMinutes(5), now)); } [Fact] public void Gate503_SmartCollectionEngine_NullSafe_Title_And_M3u8_Sanitization_Audit() { // 1. Title이 null이거나 빈 문자열인 경우 NRE 없이 정상 분류 var itemNullTitle = new MediaItemMetadata( Id: "item-1", Title: null!, Url: "https://youtube.com/shorts/test1234", DurationSeconds: 45, SizeBytes: 1024 * 1024, Format: "mp4" ); var itemAudio = new MediaItemMetadata( Id: "item-2", Title: "Podcast Episode 1", Url: "https://example.com/audio.mp3", DurationSeconds: 1200, SizeBytes: 20 * 1024 * 1024, Format: "mp3" ); var itemWithNewlines = new MediaItemMetadata( Id: "item-3", Title: "Title With\r\nDangerous\nNewlines", Url: "https://example.com/video.mp4", DurationSeconds: 300, SizeBytes: 50 * 1024 * 1024, Format: "mp4" ); var collections = SmartCollectionEngine.GroupIntoCollections(new[] { itemNullTitle, itemAudio, itemWithNewlines }); Assert.Contains(itemNullTitle, collections[SmartCollectionEngine.CollectionShorts]); Assert.Contains(itemAudio, collections[SmartCollectionEngine.CollectionMusic]); Assert.Contains(itemWithNewlines, collections[SmartCollectionEngine.CollectionGeneral]); // 2. M3U8 플레이리스트 생성 시 개행 문자 살균 검증 var m3u8 = SmartCollectionEngine.GenerateM3u8Playlist("Playlist\r\nName", new[] { itemWithNewlines }); Assert.DoesNotContain("\r\nDangerous", m3u8); Assert.Contains("#PLAYLIST:Playlist Name", m3u8); Assert.Contains("#EXTINF:300,Title With Dangerous Newlines", m3u8); } }