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/
|
||||
apps/KronaStaffTerminal/bin/
|
||||
apps/KronaStaffTerminal/obj/
|
||||
apps/KronaStaffService/bin/
|
||||
apps/KronaStaffService/obj/
|
||||
apps/KronaStaffTerminal.Tests/bin/
|
||||
apps/KronaStaffTerminal.Tests/obj/
|
||||
artifacts/
|
||||
.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
|
||||
{
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue