test(terminal): add comprehensive unit test suite covering scanner mock, deduplication, sync, and security

This commit is contained in:
Ochenstarik 2026-09-24 16:37:59 +07:00
parent 7ca8966084
commit b3fdec1eb2
10 changed files with 962 additions and 4 deletions

4
.gitignore vendored
View file

@ -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/

View file

@ -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);
}
}

View file

@ -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<HttpRequestMessage, HttpResponseMessage> HandlerFunc { get; set; } =
_ => new HttpResponseMessage(HttpStatusCode.OK);
public List<HttpRequestMessage> CapturedRequests { get; } = new();
protected override Task<HttpResponseMessage> 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);
}
}

View file

@ -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);
}
}

View file

@ -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<FingerprintRecord>()
};
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<FingerprintRecord>
{
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<FingerprintRecord>
{
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<EmployeeRecord>
{
new()
{
Id = "emp-1",
DisplayName = "Алексеев Алексей",
Fingerprints = new List<FingerprintRecord>
{
new() { Id = 100, Label = "right_index", Template = "UNIQUE_TEMPLATE_HASH_A" }
}
},
new()
{
Id = "emp-2",
DisplayName = "Борисов Борис",
Fingerprints = new List<FingerprintRecord>()
}
};
// 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);
}
}

View file

@ -0,0 +1,44 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<Platforms>x64</Platforms>
<PlatformTarget>x64</PlatformTarget>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.0" />
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
</ItemGroup>
<ItemGroup>
<Reference Include="libzkfpcsharp">
<HintPath>..\KronaStaffTerminal\Vendor\ZKFinger\x64\libzkfpcsharp.dll</HintPath>
<Private>true</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Include="..\KronaStaffTerminal\Models\TerminalModels.cs" Link="Models\TerminalModels.cs" />
<Compile Include="..\KronaStaffTerminal\Models\UlidGenerator.cs" Link="Models\UlidGenerator.cs" />
<Compile Include="..\KronaStaffTerminal\Services\AdminPinService.cs" Link="Services\AdminPinService.cs" />
<Compile Include="..\KronaStaffTerminal\Services\AttendanceSyncService.cs" Link="Services\AttendanceSyncService.cs" />
<Compile Include="..\KronaStaffTerminal\Services\AttendanceTracker.cs" Link="Services\AttendanceTracker.cs" />
<Compile Include="..\KronaStaffTerminal\Services\CrashLogService.cs" Link="Services\CrashLogService.cs" />
<Compile Include="..\KronaStaffTerminal\Services\FingerprintScanner.cs" Link="Services\FingerprintScanner.cs" />
<Compile Include="..\KronaStaffTerminal\Services\LicenseStatus.cs" Link="Services\LicenseStatus.cs" />
<Compile Include="..\KronaStaffTerminal\Services\TerminalIpc.cs" Link="Services\TerminalIpc.cs" />
<Compile Include="..\KronaStaffTerminal\Services\TerminalStore.cs" Link="Services\TerminalStore.cs" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,142 @@
using KronaStaffTerminal.Services;
namespace KronaStaffTerminal.Tests.Mocks;
public sealed class MockFingerprintScanner : IFingerprintScanner
{
private readonly List<ScannerDeviceInfo> _devices = new();
private readonly Dictionary<int, string> _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<ScannerDeviceInfo> DiscoveredDevices => _devices;
public bool IsConnected => State == ScannerState.Connected || State == ScannerState.Busy;
public int SkippedTemplateCount { get; set; } = 0;
public event Action<int, int>? FingerRecognized;
public event Action<int>? RegistrationProgress;
public event Action<int, string>? RegistrationCompleted;
public event Action<string>? StatusChanged;
public event Action<ScannerState, string>? 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<ScannerDeviceInfo> 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();
}
}

View file

@ -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);
}
}

View file

@ -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<AttendanceSyncResult> 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))

View file

@ -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