From b3fdec1eb24ee865198478a223e6c3f4aea4ac70 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 24 Sep 2026 16:37:59 +0700 Subject: [PATCH] test(terminal): add comprehensive unit test suite covering scanner mock, deduplication, sync, and security --- .gitignore | 4 + .../AdminPinServiceTests.cs | 124 ++++++++++ .../AttendanceSyncTests.cs | 186 +++++++++++++++ .../AttendanceTrackerTests.cs | 223 ++++++++++++++++++ .../EmployeeFingerprintTests.cs | 150 ++++++++++++ .../KronaStaffTerminal.Tests.csproj | 44 ++++ .../Mocks/MockFingerprintScanner.cs | 142 +++++++++++ .../ScannerRecoveryTests.cs | 76 ++++++ .../Services/AttendanceSyncService.cs | 16 +- .../Services/TerminalIpc.cs | 1 + 10 files changed, 962 insertions(+), 4 deletions(-) create mode 100644 apps/KronaStaffTerminal.Tests/AdminPinServiceTests.cs create mode 100644 apps/KronaStaffTerminal.Tests/AttendanceSyncTests.cs create mode 100644 apps/KronaStaffTerminal.Tests/AttendanceTrackerTests.cs create mode 100644 apps/KronaStaffTerminal.Tests/EmployeeFingerprintTests.cs create mode 100644 apps/KronaStaffTerminal.Tests/KronaStaffTerminal.Tests.csproj create mode 100644 apps/KronaStaffTerminal.Tests/Mocks/MockFingerprintScanner.cs create mode 100644 apps/KronaStaffTerminal.Tests/ScannerRecoveryTests.cs diff --git a/.gitignore b/.gitignore index 619fe86..9d8783f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,9 @@ backups/ restore-work/ apps/KronaStaffTerminal/bin/ apps/KronaStaffTerminal/obj/ +apps/KronaStaffService/bin/ +apps/KronaStaffService/obj/ +apps/KronaStaffTerminal.Tests/bin/ +apps/KronaStaffTerminal.Tests/obj/ artifacts/ .audit-build/ diff --git a/apps/KronaStaffTerminal.Tests/AdminPinServiceTests.cs b/apps/KronaStaffTerminal.Tests/AdminPinServiceTests.cs new file mode 100644 index 0000000..7f5ad17 --- /dev/null +++ b/apps/KronaStaffTerminal.Tests/AdminPinServiceTests.cs @@ -0,0 +1,124 @@ +using System.Security.Cryptography; +using System.Text; +using KronaStaffTerminal.Models; +using KronaStaffTerminal.Services; +using Xunit; + +namespace KronaStaffTerminal.Tests; + +public class AdminPinServiceTests +{ + [Fact] + public void HashAndVerify_ValidPin_ReturnsTrue() + { + var pin = "1234"; + var (salt, hash, iterations) = AdminPinService.Hash(pin); + + Assert.NotNull(salt); + Assert.StartsWith(AdminPinService.Argon2Prefix, hash); + Assert.True(iterations >= 1); + + var verified = AdminPinService.Verify(pin, salt, hash, iterations); + Assert.True(verified); + } + + [Fact] + public void Verify_InvalidPin_ReturnsFalse() + { + var (salt, hash, iterations) = AdminPinService.Hash("5678"); + + var verified = AdminPinService.Verify("0000", salt, hash, iterations); + Assert.False(verified); + } + + [Fact] + public void Verify_LegacyPbkdf2Hash_VerifiesSuccessfully() + { + var pin = "9876"; + var salt = RandomNumberGenerator.GetBytes(16); + var saltBase64 = Convert.ToBase64String(salt); + var legacyIterations = 120_000; + var hashBytes = Rfc2898DeriveBytes.Pbkdf2( + Encoding.UTF8.GetBytes(pin), + salt, + legacyIterations, + HashAlgorithmName.SHA256, + 32); + var hashBase64 = Convert.ToBase64String(hashBytes); + + var verified = AdminPinService.Verify(pin, saltBase64, hashBase64, legacyIterations); + Assert.True(verified); + } + + [Fact] + public void VerifyAndRecord_LegacyHashUpgradesToArgon2id() + { + var pin = "4321"; + var salt = RandomNumberGenerator.GetBytes(16); + var legacyIterations = 120_000; + var hashBytes = Rfc2898DeriveBytes.Pbkdf2( + Encoding.UTF8.GetBytes(pin), + salt, + legacyIterations, + HashAlgorithmName.SHA256, + 32); + + var settings = new TerminalSettings + { + AdminPinSalt = Convert.ToBase64String(salt), + AdminPinHash = Convert.ToBase64String(hashBytes), + AdminPinIterations = legacyIterations, + FailedAdminPinAttempts = 0 + }; + + var now = DateTimeOffset.UtcNow; + var success = AdminPinService.VerifyAndRecord(pin, settings, now, out var error, out var rehashNeeded); + + Assert.True(success); + Assert.Null(error); + Assert.True(rehashNeeded); + Assert.StartsWith(AdminPinService.Argon2Prefix, settings.AdminPinHash); + } + + [Fact] + public void VerifyAndRecord_ThreeFailedAttempts_TriggersLockout() + { + var (salt, hash, iterations) = AdminPinService.Hash("1111"); + var settings = new TerminalSettings + { + AdminPinSalt = salt, + AdminPinHash = hash, + AdminPinIterations = iterations, + FailedAdminPinAttempts = 0 + }; + + var now = DateTimeOffset.UtcNow; + + // Attempt 1: Fail + var r1 = AdminPinService.VerifyAndRecord("0000", settings, now, out var err1, out _); + Assert.False(r1); + Assert.Contains("до блокировки: 2", err1); + + // Attempt 2: Fail + var r2 = AdminPinService.VerifyAndRecord("0000", settings, now, out var err2, out _); + Assert.False(r2); + Assert.Contains("до блокировки: 1", err2); + + // Attempt 3: Lockout triggered (30s) + var r3 = AdminPinService.VerifyAndRecord("0000", settings, now, out var err3, out _); + Assert.False(r3); + Assert.Contains("заблокирован на 30 сек", err3); + Assert.True(settings.AdminPinBlockedUntilUtc > now); + + // Attempt 4: Even with correct PIN, blocked during lockout + var r4 = AdminPinService.VerifyAndRecord("1111", settings, now.AddSeconds(5), out var err4, out _); + Assert.False(r4); + Assert.Contains("временно заблокирован", err4); + + // Attempt 5: After lockout expires, correct PIN succeeds + var r5 = AdminPinService.VerifyAndRecord("1111", settings, now.AddSeconds(35), out var err5, out _); + Assert.True(r5); + Assert.Null(err5); + Assert.Equal(0, settings.FailedAdminPinAttempts); + } +} diff --git a/apps/KronaStaffTerminal.Tests/AttendanceSyncTests.cs b/apps/KronaStaffTerminal.Tests/AttendanceSyncTests.cs new file mode 100644 index 0000000..d4ff771 --- /dev/null +++ b/apps/KronaStaffTerminal.Tests/AttendanceSyncTests.cs @@ -0,0 +1,186 @@ +using System.Net; +using KronaStaffTerminal.Models; +using KronaStaffTerminal.Services; +using Xunit; + +namespace KronaStaffTerminal.Tests; + +public class AttendanceSyncTests +{ + private sealed class MockHttpMessageHandler : HttpMessageHandler + { + public Func HandlerFunc { get; set; } = + _ => new HttpResponseMessage(HttpStatusCode.OK); + + public List CapturedRequests { get; } = new(); + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + CapturedRequests.Add(request); + return Task.FromResult(HandlerFunc(request)); + } + } + + private static TerminalState CreateConfiguredState() + { + return new TerminalState + { + Settings = new TerminalSettings + { + StaffServerUrl = "https://staff.krona.internal:8453", + StaffServerToken = "test_token_secret_123", + TerminalDeviceId = "TERM-TEST-01" + } + }; + } + + [Fact] + public async Task SyncPending_With200Ok_MarksRecordsAsSyncedAndSendsHeaders() + { + var mockHandler = new MockHttpMessageHandler + { + HandlerFunc = _ => new HttpResponseMessage(HttpStatusCode.OK) + }; + var httpClient = new HttpClient(mockHandler); + var syncService = new AttendanceSyncService(httpClient); + var state = CreateConfiguredState(); + + var record1 = new AttendanceRecord + { + Id = UlidGenerator.New(), + EmployeeId = "emp-10", + EventType = "Приход", + OccurredAtUtc = DateTimeOffset.UtcNow.AddMinutes(-5), + ServerSynced = false + }; + var record2 = new AttendanceRecord + { + Id = UlidGenerator.New(), + EmployeeId = "emp-10", + EventType = "Уход", + OccurredAtUtc = DateTimeOffset.UtcNow, + ServerSynced = false + }; + + state.Attendance.Add(record1); + state.Attendance.Add(record2); + + var result = await syncService.SyncPendingAsync(state, TestContext.Current.CancellationToken); + + Assert.Equal(2, result.ProcessedCount); + Assert.Equal(2, result.SuccessCount); + Assert.Equal(0, result.FailedCount); + Assert.True(record1.ServerSynced); + Assert.True(record2.ServerSynced); + + // Verify headers + Assert.Equal(2, mockHandler.CapturedRequests.Count); + var req = mockHandler.CapturedRequests[0]; + Assert.Equal("Bearer test_token_secret_123", req.Headers.Authorization?.ToString()); + Assert.True(req.Headers.Contains("Idempotency-Key")); + Assert.True(req.Headers.Contains("X-Request-Id")); + } + + [Fact] + public async Task SyncPending_With409Conflict_TreatedAsIdempotentSuccess() + { + var mockHandler = new MockHttpMessageHandler + { + HandlerFunc = _ => new HttpResponseMessage(HttpStatusCode.Conflict) + }; + var httpClient = new HttpClient(mockHandler); + var syncService = new AttendanceSyncService(httpClient); + var state = CreateConfiguredState(); + + var record = new AttendanceRecord + { + Id = UlidGenerator.New(), + EmployeeId = "emp-15", + EventType = "Приход", + OccurredAtUtc = DateTimeOffset.UtcNow, + ServerSynced = false + }; + state.Attendance.Add(record); + + var result = await syncService.SyncPendingAsync(state, TestContext.Current.CancellationToken); + + Assert.Equal(1, result.ProcessedCount); + Assert.Equal(1, result.SuccessCount); + Assert.Equal(0, result.FailedCount); + Assert.True(record.ServerSynced); + Assert.Null(record.ServerSyncError); + } + + [Fact] + public async Task SyncPending_With500Error_RetainsRecordForRetry() + { + var mockHandler = new MockHttpMessageHandler + { + HandlerFunc = _ => new HttpResponseMessage(HttpStatusCode.InternalServerError) + }; + var httpClient = new HttpClient(mockHandler); + var syncService = new AttendanceSyncService(httpClient); + var state = CreateConfiguredState(); + + var record = new AttendanceRecord + { + Id = UlidGenerator.New(), + EmployeeId = "emp-20", + EventType = "Приход", + OccurredAtUtc = DateTimeOffset.UtcNow, + ServerSynced = false + }; + state.Attendance.Add(record); + + var result = await syncService.SyncPendingAsync(state, TestContext.Current.CancellationToken); + + Assert.Equal(1, result.ProcessedCount); + Assert.Equal(0, result.SuccessCount); + Assert.Equal(1, result.FailedCount); + Assert.False(record.ServerSynced); + Assert.Equal("HTTP 500", record.ServerSyncError); + } + + [Fact] + public async Task SyncPending_WithNetworkException_HaltsCurrentCycle() + { + var mockHandler = new MockHttpMessageHandler + { + HandlerFunc = _ => throw new HttpRequestException("Connection refused") + }; + var httpClient = new HttpClient(mockHandler); + var syncService = new AttendanceSyncService(httpClient); + var state = CreateConfiguredState(); + + var record1 = new AttendanceRecord + { + Id = UlidGenerator.New(), + EmployeeId = "emp-1", + EventType = "Приход", + OccurredAtUtc = DateTimeOffset.UtcNow.AddMinutes(-2), + ServerSynced = false + }; + var record2 = new AttendanceRecord + { + Id = UlidGenerator.New(), + EmployeeId = "emp-2", + EventType = "Приход", + OccurredAtUtc = DateTimeOffset.UtcNow.AddMinutes(-1), + ServerSynced = false + }; + state.Attendance.Add(record1); + state.Attendance.Add(record2); + + var result = await syncService.SyncPendingAsync(state, TestContext.Current.CancellationToken); + + Assert.Equal(2, result.ProcessedCount); + Assert.Equal(1, result.FailedCount); + Assert.True(result.HasNetworkError); + Assert.False(record1.ServerSynced); + Assert.False(record2.ServerSynced); + // Only first was attempted because loop broke on network drop + Assert.Single(mockHandler.CapturedRequests); + } +} diff --git a/apps/KronaStaffTerminal.Tests/AttendanceTrackerTests.cs b/apps/KronaStaffTerminal.Tests/AttendanceTrackerTests.cs new file mode 100644 index 0000000..1deb238 --- /dev/null +++ b/apps/KronaStaffTerminal.Tests/AttendanceTrackerTests.cs @@ -0,0 +1,223 @@ +using KronaStaffTerminal.Models; +using KronaStaffTerminal.Services; +using Xunit; + +namespace KronaStaffTerminal.Tests; + +public class AttendanceTrackerTests +{ + private static (TerminalState, EmployeeRecord) CreateTestState() + { + var emp = new EmployeeRecord + { + Id = "emp-1", + DisplayName = "Тестовый Сотрудник", + FingerprintId = 1 + }; + + var state = new TerminalState + { + Employees = { emp }, + Settings = new TerminalSettings + { + DuplicateScanGuardSeconds = 30, + MaximumShiftHours = 14, + AutomaticCheckoutEnabled = true, + AutomaticCheckoutHour = 23, + AutomaticCheckoutMinute = 0 + } + }; + + return (state, emp); + } + + [Fact] + public async Task TryRecordAttendance_FirstScan_RecordsCheckIn() + { + var (state, emp) = CreateTestState(); + var tracker = new AttendanceTracker(); + var now = DateTimeOffset.UtcNow; + + var result = await tracker.TryRecordAttendanceAsync( + state, emp, 0, now, _ => Task.CompletedTask); + + Assert.True(result.Success); + Assert.False(result.IsDuplicateSuppressed); + Assert.NotNull(result.Record); + Assert.Equal("Приход", result.Record.EventType); + Assert.Equal(emp.Id, result.Record.EmployeeId); + Assert.False(result.Record.IsAutomatic); + Assert.Single(state.Attendance); + } + + [Fact] + public async Task TryRecordAttendance_ImmediateSecondScan_RejectedAsDuplicate() + { + var (state, emp) = CreateTestState(); + var tracker = new AttendanceTracker(); + var now = DateTimeOffset.UtcNow; + + // First scan + var first = await tracker.TryRecordAttendanceAsync( + state, emp, 0, now, _ => Task.CompletedTask); + Assert.True(first.Success); + + // Immediate second scan 5 seconds later (guard is 30s) + var second = await tracker.TryRecordAttendanceAsync( + state, emp, 0, now.AddSeconds(5), _ => Task.CompletedTask); + + Assert.False(second.Success); + Assert.True(second.IsDuplicateSuppressed); + Assert.Null(second.Record); + Assert.Equal(25, second.RemainingSeconds); + Assert.Contains("Повторный скан отклонён", second.Message); + Assert.Single(state.Attendance); // No extra record inserted + } + + [Fact] + public async Task TryRecordAttendance_SecondScanAfterGuard_RecordsCheckOut() + { + var (state, emp) = CreateTestState(); + var tracker = new AttendanceTracker(); + var now = DateTimeOffset.UtcNow; + + // Check in + await tracker.TryRecordAttendanceAsync( + state, emp, 0, now, _ => Task.CompletedTask); + + // Check out after 35 seconds (guard is 30s) + var second = await tracker.TryRecordAttendanceAsync( + state, emp, 0, now.AddSeconds(35), _ => Task.CompletedTask); + + Assert.True(second.Success); + Assert.False(second.IsDuplicateSuppressed); + Assert.NotNull(second.Record); + Assert.Equal("Уход", second.Record.EventType); + Assert.Equal(2, state.Attendance.Count); + } + + [Fact] + public async Task TryRecordAttendance_DifferentEmployee_NotSuppressed() + { + var (state, emp1) = CreateTestState(); + var emp2 = new EmployeeRecord + { + Id = "emp-2", + DisplayName = "Второй Сотрудник", + FingerprintId = 2 + }; + state.Employees.Add(emp2); + + var tracker = new AttendanceTracker(); + var now = DateTimeOffset.UtcNow; + + // Scan emp 1 + var r1 = await tracker.TryRecordAttendanceAsync( + state, emp1, 0, now, _ => Task.CompletedTask); + Assert.True(r1.Success); + + // Scan emp 2 1 second later + var r2 = await tracker.TryRecordAttendanceAsync( + state, emp2, 0, now.AddSeconds(1), _ => Task.CompletedTask); + Assert.True(r2.Success); + Assert.False(r2.IsDuplicateSuppressed); + Assert.Equal(2, state.Attendance.Count); + } + + [Fact] + public async Task Concurrency_FiftyParallelScans_OnlyOneSucceeds() + { + var (state, emp) = CreateTestState(); + var tracker = new AttendanceTracker(); + var now = DateTimeOffset.UtcNow; + + var saveCount = 0; + Task SaveCallback(TerminalState s) + { + Interlocked.Increment(ref saveCount); + return Task.CompletedTask; + } + + // Launch 50 concurrent tasks at the exact same moment + var tasks = Enumerable.Range(0, 50).Select(_ => + tracker.TryRecordAttendanceAsync(state, emp, 0, now, SaveCallback) + ).ToArray(); + + var results = await Task.WhenAll(tasks); + + var successCount = results.Count(r => r.Success); + var suppressedCount = results.Count(r => r.IsDuplicateSuppressed); + + Assert.Equal(1, successCount); + Assert.Equal(49, suppressedCount); + Assert.Equal(1, saveCount); + Assert.Single(state.Attendance); + } + + [Fact] + public async Task RunScheduledCheckout_AutoChecksOutOpenShifts() + { + var (state, emp) = CreateTestState(); + var tracker = new AttendanceTracker(); + + // Employee checked in at 09:00 UTC + var checkInTime = new DateTimeOffset(2026, 9, 24, 9, 0, 0, TimeSpan.Zero); + state.Attendance.Add(new AttendanceRecord + { + Id = UlidGenerator.New(), + EmployeeId = emp.Id, + EmployeeName = emp.DisplayName, + OccurredAtUtc = checkInTime, + EventType = "Приход", + IsAutomatic = false + }); + + // Current time is 23:30 (checkout scheduled for 23:00) + var nowUtc = new DateTimeOffset(2026, 9, 24, 23, 30, 0, TimeSpan.Zero); + + var checkedOutRecords = await tracker.RunScheduledCheckoutAsync( + state, nowUtc, _ => Task.CompletedTask); + + Assert.Single(checkedOutRecords); + var autoRecord = checkedOutRecords[0]; + Assert.Equal("Уход автоматически", autoRecord.EventType); + Assert.True(autoRecord.IsAutomatic); + Assert.Equal(emp.Id, autoRecord.EmployeeId); + Assert.Equal(2, state.Attendance.Count); + } + + [Fact] + public async Task RunScheduledCheckout_AlreadyCheckedOut_DoesNotDoubleCheckout() + { + var (state, emp) = CreateTestState(); + var tracker = new AttendanceTracker(); + + var checkInTime = new DateTimeOffset(2026, 9, 24, 9, 0, 0, TimeSpan.Zero); + var checkOutTime = new DateTimeOffset(2026, 9, 24, 18, 0, 0, TimeSpan.Zero); + state.Attendance.Add(new AttendanceRecord + { + Id = UlidGenerator.New(), + EmployeeId = emp.Id, + EmployeeName = emp.DisplayName, + OccurredAtUtc = checkOutTime, + EventType = "Уход", + IsAutomatic = false + }); + state.Attendance.Add(new AttendanceRecord + { + Id = UlidGenerator.New(), + EmployeeId = emp.Id, + EmployeeName = emp.DisplayName, + OccurredAtUtc = checkInTime, + EventType = "Приход", + IsAutomatic = false + }); + + var nowUtc = new DateTimeOffset(2026, 9, 24, 23, 30, 0, TimeSpan.Zero); + + var checkedOutRecords = await tracker.RunScheduledCheckoutAsync( + state, nowUtc, _ => Task.CompletedTask); + + Assert.Empty(checkedOutRecords); + } +} diff --git a/apps/KronaStaffTerminal.Tests/EmployeeFingerprintTests.cs b/apps/KronaStaffTerminal.Tests/EmployeeFingerprintTests.cs new file mode 100644 index 0000000..e43f9da --- /dev/null +++ b/apps/KronaStaffTerminal.Tests/EmployeeFingerprintTests.cs @@ -0,0 +1,150 @@ +using KronaStaffTerminal.Models; +using KronaStaffTerminal.Tests.Mocks; +using Xunit; + +namespace KronaStaffTerminal.Tests; + +public class EmployeeFingerprintTests +{ + [Fact] + public void Employee_CanHaveMultipleFingerprintsWithDistinctLabels() + { + var emp = new EmployeeRecord + { + Id = Guid.NewGuid().ToString(), + LastName = "Иванов", + FirstName = "Иван", + Patronymic = "Иванович", + Fingerprints = new List() + }; + + var fp1 = new FingerprintRecord + { + Id = 1, + Template = Convert.ToBase64String(new byte[] { 1, 2, 3, 4 }), + Label = "right_index", + EnrolledAtUtc = DateTimeOffset.UtcNow + }; + + var fp2 = new FingerprintRecord + { + Id = 2, + Template = Convert.ToBase64String(new byte[] { 5, 6, 7, 8 }), + Label = "left_thumb", + EnrolledAtUtc = DateTimeOffset.UtcNow + }; + + emp.Fingerprints.Add(fp1); + emp.Fingerprints.Add(fp2); + + Assert.Equal(2, emp.Fingerprints.Count); + Assert.Contains(emp.Fingerprints, f => f.Label == "right_index"); + Assert.Contains(emp.Fingerprints, f => f.Label == "left_thumb"); + } + + [Fact] + public void Employee_CanReplaceSpecificFingerprint() + { + var emp = new EmployeeRecord + { + Id = Guid.NewGuid().ToString(), + DisplayName = "Петров Петр Петрович", + Fingerprints = new List + { + new() { Id = 10, Label = "right_index", Template = "OLD_TEMPLATE" } + } + }; + + // Replace right_index + var existing = emp.Fingerprints.FirstOrDefault(f => f.Label == "right_index"); + Assert.NotNull(existing); + + existing.Template = "NEW_UPDATED_TEMPLATE"; + existing.EnrolledAtUtc = DateTimeOffset.UtcNow; + + Assert.Single(emp.Fingerprints); + Assert.Equal("NEW_UPDATED_TEMPLATE", emp.Fingerprints[0].Template); + } + + [Fact] + public void Employee_CanDeleteSingleFingerprintWithoutAffectingOthers() + { + var emp = new EmployeeRecord + { + Id = Guid.NewGuid().ToString(), + DisplayName = "Сидоров Сидор", + Fingerprints = new List + { + new() { Id = 1, Label = "right_thumb", Template = "T1" }, + new() { Id = 2, Label = "right_index", Template = "T2" } + } + }; + + // Remove right_thumb + emp.Fingerprints.RemoveAll(f => f.Label == "right_thumb"); + + Assert.Single(emp.Fingerprints); + Assert.Equal("right_index", emp.Fingerprints[0].Label); + } + + [Fact] + public void DuplicateFingerprint_ProhibitedAcrossDifferentEmployees() + { + var employees = new List + { + new() + { + Id = "emp-1", + DisplayName = "Алексеев Алексей", + Fingerprints = new List + { + new() { Id = 100, Label = "right_index", Template = "UNIQUE_TEMPLATE_HASH_A" } + } + }, + new() + { + Id = "emp-2", + DisplayName = "Борисов Борис", + Fingerprints = new List() + } + }; + + // Attempting to enroll the same template to emp-2 + var candidateTemplate = "UNIQUE_TEMPLATE_HASH_A"; + var candidateEmployeeId = "emp-2"; + + // Validation logic used by terminal + var conflict = employees.FirstOrDefault(e => + e.Id != candidateEmployeeId && + e.Fingerprints.Any(f => f.Template == candidateTemplate)); + + Assert.NotNull(conflict); + Assert.Equal("emp-1", conflict.Id); + Assert.Equal("Алексеев Алексей", conflict.DisplayName); + } + + [Fact] + public void MockScanner_SimulatesMultiFingerEnrollmentAndMatching() + { + using var scanner = new MockFingerprintScanner(); + var recognizedId = -1; + var recognizedScore = 0; + + scanner.FingerRecognized += (id, score) => + { + recognizedId = id; + recognizedScore = score; + }; + + scanner.Connect(0, new[] { (42, "TEMPLATE_42"), (99, "TEMPLATE_99") }); + Assert.True(scanner.IsConnected); + + scanner.SimulateFingerScan(42, 95); + Assert.Equal(42, recognizedId); + Assert.Equal(95, recognizedScore); + + scanner.SimulateFingerScan(99, 88); + Assert.Equal(99, recognizedId); + Assert.Equal(88, recognizedScore); + } +} diff --git a/apps/KronaStaffTerminal.Tests/KronaStaffTerminal.Tests.csproj b/apps/KronaStaffTerminal.Tests/KronaStaffTerminal.Tests.csproj new file mode 100644 index 0000000..9d60a86 --- /dev/null +++ b/apps/KronaStaffTerminal.Tests/KronaStaffTerminal.Tests.csproj @@ -0,0 +1,44 @@ + + + + net10.0-windows10.0.26100.0 + enable + enable + false + true + x64 + x64 + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + ..\KronaStaffTerminal\Vendor\ZKFinger\x64\libzkfpcsharp.dll + true + + + + + + + + + + + + + + + + + diff --git a/apps/KronaStaffTerminal.Tests/Mocks/MockFingerprintScanner.cs b/apps/KronaStaffTerminal.Tests/Mocks/MockFingerprintScanner.cs new file mode 100644 index 0000000..b3218f2 --- /dev/null +++ b/apps/KronaStaffTerminal.Tests/Mocks/MockFingerprintScanner.cs @@ -0,0 +1,142 @@ +using KronaStaffTerminal.Services; + +namespace KronaStaffTerminal.Tests.Mocks; + +public sealed class MockFingerprintScanner : IFingerprintScanner +{ + private readonly List _devices = new(); + private readonly Dictionary _loadedTemplates = new(); + private int _registrationTargetId; + private int _registrationStep; + + public ScannerState State { get; private set; } = ScannerState.Disconnected; + public int DeviceCount => _devices.Count; + public int ReaderIndex { get; private set; } = -1; + public IReadOnlyList DiscoveredDevices => _devices; + public bool IsConnected => State == ScannerState.Connected || State == ScannerState.Busy; + public int SkippedTemplateCount { get; set; } = 0; + + public event Action? FingerRecognized; + public event Action? RegistrationProgress; + public event Action? RegistrationCompleted; + public event Action? StatusChanged; + public event Action? StateChanged; + public event Action? ConnectionLost; + public event Action? RegistrationCancelled; + + public MockFingerprintScanner() + { + // Default with 1 virtual SLK20R + _devices.Add(new ScannerDeviceInfo(0, "SLK20R-Virtual", "ZK-VIRT-001", "SLK20R [ZK-VIRT-001]")); + } + + public void AddVirtualDevice(int index, string model, string serial) + { + _devices.Add(new ScannerDeviceInfo(index, model, serial, $"{model} [{serial}]")); + } + + public void ClearDevices() + { + _devices.Clear(); + } + + public IReadOnlyList DiscoverDevices() + { + return _devices; + } + + public void Connect(int readerIndex, IEnumerable<(int FingerprintId, string Template)> templates) + { + if (_devices.Count == 0) + { + State = ScannerState.DriverError; + StateChanged?.Invoke(ScannerState.DriverError, "No devices found"); + return; + } + + ReaderIndex = readerIndex; + _loadedTemplates.Clear(); + foreach (var (id, tpl) in templates) + { + _loadedTemplates[id] = tpl; + } + + State = ScannerState.Connected; + StateChanged?.Invoke(ScannerState.Connected, "Scanner ready"); + StatusChanged?.Invoke("Scanner ready"); + } + + public void Disconnect() + { + State = ScannerState.Disconnected; + ReaderIndex = -1; + _loadedTemplates.Clear(); + StateChanged?.Invoke(ScannerState.Disconnected, "Scanner disconnected"); + StatusChanged?.Invoke("Scanner disconnected"); + } + + public void BeginRegistration(int fingerprintId) + { + _registrationTargetId = fingerprintId; + _registrationStep = 0; + State = ScannerState.Busy; + StateChanged?.Invoke(ScannerState.Busy, "Enrolling finger"); + } + + public void CancelRegistration() + { + _registrationTargetId = 0; + _registrationStep = 0; + if (State == ScannerState.Busy) + { + State = ScannerState.Connected; + StateChanged?.Invoke(ScannerState.Connected, "Registration cancelled"); + } + RegistrationCancelled?.Invoke(); + } + + public void SimulateFingerScan(int fingerprintId, int score = 85) + { + if (State != ScannerState.Connected) + { + return; + } + + FingerRecognized?.Invoke(fingerprintId, score); + } + + public void SimulateEnrollmentSample(string? finalTemplate = null) + { + if (State != ScannerState.Busy) return; + + _registrationStep++; + RegistrationProgress?.Invoke(_registrationStep); + + if (_registrationStep >= 3) + { + string tpl = finalTemplate ?? Convert.ToBase64String(new byte[512]); + _loadedTemplates[_registrationTargetId] = tpl; + State = ScannerState.Connected; + StateChanged?.Invoke(ScannerState.Connected, "Registration complete"); + RegistrationCompleted?.Invoke(_registrationTargetId, tpl); + } + } + + public void SimulateDisconnect() + { + State = ScannerState.Disconnected; + StateChanged?.Invoke(ScannerState.Disconnected, "Device unplugged"); + ConnectionLost?.Invoke(); + } + + public void SimulateReconnect() + { + State = ScannerState.Connected; + StateChanged?.Invoke(ScannerState.Connected, "Device reconnected"); + } + + public void Dispose() + { + Disconnect(); + } +} diff --git a/apps/KronaStaffTerminal.Tests/ScannerRecoveryTests.cs b/apps/KronaStaffTerminal.Tests/ScannerRecoveryTests.cs new file mode 100644 index 0000000..7373a43 --- /dev/null +++ b/apps/KronaStaffTerminal.Tests/ScannerRecoveryTests.cs @@ -0,0 +1,76 @@ +using KronaStaffTerminal.Models; +using KronaStaffTerminal.Services; +using KronaStaffTerminal.Tests.Mocks; +using Xunit; + +namespace KronaStaffTerminal.Tests; + +public class ScannerRecoveryTests +{ + [Fact] + public void Scanner_SimulatesUsbDisconnectAndFiresConnectionLost() + { + using var scanner = new MockFingerprintScanner(); + scanner.Connect(0, Array.Empty<(int, string)>()); + Assert.True(scanner.IsConnected); + Assert.Equal(ScannerState.Connected, scanner.State); + + var connectionLostFired = false; + ScannerState? reportedState = null; + + scanner.ConnectionLost += () => connectionLostFired = true; + scanner.StateChanged += (state, _) => reportedState = state; + + // Simulate physical USB drop + scanner.SimulateDisconnect(); + + Assert.False(scanner.IsConnected); + Assert.Equal(ScannerState.Disconnected, scanner.State); + Assert.True(connectionLostFired); + Assert.Equal(ScannerState.Disconnected, reportedState); + } + + [Fact] + public void Scanner_SimulatesUsbReconnectAndRestoresReadyState() + { + using var scanner = new MockFingerprintScanner(); + scanner.Connect(0, Array.Empty<(int, string)>()); + scanner.SimulateDisconnect(); + Assert.Equal(ScannerState.Disconnected, scanner.State); + + ScannerState? reportedState = null; + scanner.StateChanged += (state, _) => reportedState = state; + + // Simulate USB re-plug + scanner.SimulateReconnect(); + + Assert.True(scanner.IsConnected); + Assert.Equal(ScannerState.Connected, scanner.State); + Assert.Equal(ScannerState.Connected, reportedState); + } + + [Fact] + public void AdminPinVerification_OperatesNormallyWhenScannerIsDisconnected() + { + using var scanner = new MockFingerprintScanner(); + scanner.SimulateDisconnect(); + Assert.Equal(ScannerState.Disconnected, scanner.State); + + // Verify that AdminPinService functions 100% independently of hardware scanner state + var (salt, hash, iterations) = AdminPinService.Hash("5555"); + var settings = new TerminalSettings + { + AdminPinSalt = salt, + AdminPinHash = hash, + AdminPinIterations = iterations + }; + + var now = DateTimeOffset.UtcNow; + var allowed = AdminPinService.VerifyAndRecord( + "5555", settings, now, out var error, out var rehashNeeded); + + Assert.True(allowed); + Assert.Null(error); + Assert.False(rehashNeeded); + } +} diff --git a/apps/KronaStaffTerminal/Services/AttendanceSyncService.cs b/apps/KronaStaffTerminal/Services/AttendanceSyncService.cs index 7cac9d9..4b000bb 100644 --- a/apps/KronaStaffTerminal/Services/AttendanceSyncService.cs +++ b/apps/KronaStaffTerminal/Services/AttendanceSyncService.cs @@ -18,6 +18,13 @@ public sealed class AttendanceSyncResult public sealed class AttendanceSyncService { + private readonly HttpClient? _httpClient; + + public AttendanceSyncService(HttpClient? httpClient = null) + { + _httpClient = httpClient; + } + public async Task SyncPendingAsync(TerminalState state, CancellationToken cancellationToken = default) { var result = new AttendanceSyncResult(); @@ -29,13 +36,14 @@ public sealed class AttendanceSyncService var endpoint = BuildEndpoint(state.Settings.StaffServerUrl); var expectedCertificate = NormalizeFingerprint(state.Settings.StaffServerCertificateSha256); - using var handler = new HttpClientHandler { AllowAutoRedirect = false }; - if (expectedCertificate.Length == 64) + using var fallbackHandler = _httpClient == null ? new HttpClientHandler { AllowAutoRedirect = false } : null; + if (fallbackHandler != null && expectedCertificate.Length == 64) { - handler.ServerCertificateCustomValidationCallback = (_, certificate, _, _) => + fallbackHandler.ServerCertificateCustomValidationCallback = (_, certificate, _, _) => CertificateMatches(certificate, expectedCertificate); } - using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(15) }; + using var fallbackClient = fallbackHandler != null ? new HttpClient(fallbackHandler) { Timeout = TimeSpan.FromSeconds(15) } : null; + var client = _httpClient ?? fallbackClient!; var pending = state.Attendance .Where(item => !item.ServerSynced && IsSyncableEvent(item.EventType)) diff --git a/apps/KronaStaffTerminal/Services/TerminalIpc.cs b/apps/KronaStaffTerminal/Services/TerminalIpc.cs index 0a5efb3..f287eed 100644 --- a/apps/KronaStaffTerminal/Services/TerminalIpc.cs +++ b/apps/KronaStaffTerminal/Services/TerminalIpc.cs @@ -19,6 +19,7 @@ public sealed class IpcResponse public string? ErrorMessage { get; set; } public IpcServiceStatus? Status { get; set; } public int? SyncedCount { get; set; } + public string? Payload { get; set; } } public sealed class IpcServiceStatus