test(terminal): add comprehensive unit test suite covering scanner mock, deduplication, sync, and security
This commit is contained in:
parent
7ca8966084
commit
b3fdec1eb2
10 changed files with 962 additions and 4 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -12,5 +12,9 @@ backups/
|
||||||
restore-work/
|
restore-work/
|
||||||
apps/KronaStaffTerminal/bin/
|
apps/KronaStaffTerminal/bin/
|
||||||
apps/KronaStaffTerminal/obj/
|
apps/KronaStaffTerminal/obj/
|
||||||
|
apps/KronaStaffService/bin/
|
||||||
|
apps/KronaStaffService/obj/
|
||||||
|
apps/KronaStaffTerminal.Tests/bin/
|
||||||
|
apps/KronaStaffTerminal.Tests/obj/
|
||||||
artifacts/
|
artifacts/
|
||||||
.audit-build/
|
.audit-build/
|
||||||
|
|
|
||||||
124
apps/KronaStaffTerminal.Tests/AdminPinServiceTests.cs
Normal file
124
apps/KronaStaffTerminal.Tests/AdminPinServiceTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
186
apps/KronaStaffTerminal.Tests/AttendanceSyncTests.cs
Normal file
186
apps/KronaStaffTerminal.Tests/AttendanceSyncTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
223
apps/KronaStaffTerminal.Tests/AttendanceTrackerTests.cs
Normal file
223
apps/KronaStaffTerminal.Tests/AttendanceTrackerTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
150
apps/KronaStaffTerminal.Tests/EmployeeFingerprintTests.cs
Normal file
150
apps/KronaStaffTerminal.Tests/EmployeeFingerprintTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
142
apps/KronaStaffTerminal.Tests/Mocks/MockFingerprintScanner.cs
Normal file
142
apps/KronaStaffTerminal.Tests/Mocks/MockFingerprintScanner.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
76
apps/KronaStaffTerminal.Tests/ScannerRecoveryTests.cs
Normal file
76
apps/KronaStaffTerminal.Tests/ScannerRecoveryTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,13 @@ public sealed class AttendanceSyncResult
|
||||||
|
|
||||||
public sealed class AttendanceSyncService
|
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)
|
public async Task<AttendanceSyncResult> SyncPendingAsync(TerminalState state, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var result = new AttendanceSyncResult();
|
var result = new AttendanceSyncResult();
|
||||||
|
|
@ -29,13 +36,14 @@ public sealed class AttendanceSyncService
|
||||||
|
|
||||||
var endpoint = BuildEndpoint(state.Settings.StaffServerUrl);
|
var endpoint = BuildEndpoint(state.Settings.StaffServerUrl);
|
||||||
var expectedCertificate = NormalizeFingerprint(state.Settings.StaffServerCertificateSha256);
|
var expectedCertificate = NormalizeFingerprint(state.Settings.StaffServerCertificateSha256);
|
||||||
using var handler = new HttpClientHandler { AllowAutoRedirect = false };
|
using var fallbackHandler = _httpClient == null ? new HttpClientHandler { AllowAutoRedirect = false } : null;
|
||||||
if (expectedCertificate.Length == 64)
|
if (fallbackHandler != null && expectedCertificate.Length == 64)
|
||||||
{
|
{
|
||||||
handler.ServerCertificateCustomValidationCallback = (_, certificate, _, _) =>
|
fallbackHandler.ServerCertificateCustomValidationCallback = (_, certificate, _, _) =>
|
||||||
CertificateMatches(certificate, expectedCertificate);
|
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
|
var pending = state.Attendance
|
||||||
.Where(item => !item.ServerSynced && IsSyncableEvent(item.EventType))
|
.Where(item => !item.ServerSynced && IsSyncableEvent(item.EventType))
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ public sealed class IpcResponse
|
||||||
public string? ErrorMessage { get; set; }
|
public string? ErrorMessage { get; set; }
|
||||||
public IpcServiceStatus? Status { get; set; }
|
public IpcServiceStatus? Status { get; set; }
|
||||||
public int? SyncedCount { get; set; }
|
public int? SyncedCount { get; set; }
|
||||||
|
public string? Payload { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class IpcServiceStatus
|
public sealed class IpcServiceStatus
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue