Add UK education verification and security fixes
Features: - Add UK institution recognition (170+ universities) - Add diploma mill detection (100+ blacklisted institutions) - Add education verification service with date plausibility checks - Add local file storage option (no Azure required) - Add default admin user seeding on startup - Enhance Serilog logging with file output Security fixes: - Fix path traversal vulnerability in LocalFileStorageService - Fix open redirect in login endpoint (use LocalRedirect) - Fix password validation message (12 chars, not 6) - Fix login to use HTTP POST endpoint (avoid Blazor cookie issues) Code improvements: - Add CancellationToken propagation to CV parser - Add shared helpers (JsonDefaults, DateHelpers, ScoreThresholds) - Add IUserContextService for user ID extraction - Parallelized company verification in ProcessCVCheckJob - Add 28 unit tests for education verification Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -218,3 +218,7 @@ local/
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
|
# Local file uploads
|
||||||
|
src/TrueCV.Web/uploads/
|
||||||
|
logs/
|
||||||
|
|||||||
7
Directory.Build.props
Normal file
7
Directory.Build.props
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<BuildInParallel>false</BuildInParallel>
|
||||||
|
<RestoreBuildInParallel>false</RestoreBuildInParallel>
|
||||||
|
<UseSharedCompilation>false</UseSharedCompilation>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
210
src/TrueCV.Application/Data/DiplomaMills.cs
Normal file
210
src/TrueCV.Application/Data/DiplomaMills.cs
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
namespace TrueCV.Application.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Known diploma mills and fake educational institutions.
|
||||||
|
/// Sources: HEDD, Oregon ODA, UNESCO warnings, Michigan AG list
|
||||||
|
/// </summary>
|
||||||
|
public static class DiplomaMills
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Known diploma mills and unaccredited institutions that sell fake degrees.
|
||||||
|
/// This list includes institutions identified by various regulatory bodies.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly HashSet<string> KnownDiplomaMills = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
// Well-known diploma mills
|
||||||
|
"Almeda University",
|
||||||
|
"Ashwood University",
|
||||||
|
"Belford University",
|
||||||
|
"Bircham University",
|
||||||
|
"Breyer State University",
|
||||||
|
"Brighton University (not Brighton UK)",
|
||||||
|
"Buxton University",
|
||||||
|
"Cambridge State University",
|
||||||
|
"Chadwick University",
|
||||||
|
"Clayton University",
|
||||||
|
"Columbus University",
|
||||||
|
"Corllins University",
|
||||||
|
"Dartington University",
|
||||||
|
"Dickinson State University Online",
|
||||||
|
"Fairfax University",
|
||||||
|
"Glendale University",
|
||||||
|
"Greenleaf University",
|
||||||
|
"Hamilton University",
|
||||||
|
"Harrington University",
|
||||||
|
"Hill University",
|
||||||
|
"Hollywood University",
|
||||||
|
"International University (generic)",
|
||||||
|
"Irish International University",
|
||||||
|
"James Monroe University",
|
||||||
|
"Jamestown University",
|
||||||
|
"Kennedy-Western University",
|
||||||
|
"Kensington University",
|
||||||
|
"Knightsbridge University",
|
||||||
|
"LaSalle University (Louisiana)",
|
||||||
|
"Lexington University",
|
||||||
|
"Lincoln University (if not Pennsylvania)",
|
||||||
|
"Madison University",
|
||||||
|
"Metropolitan University (generic)",
|
||||||
|
"Middletown University",
|
||||||
|
"Monticello University",
|
||||||
|
"Northern University",
|
||||||
|
"Northfield University",
|
||||||
|
"Pacific Southern University",
|
||||||
|
"Pacific Western University",
|
||||||
|
"Paramount University",
|
||||||
|
"Parkwood University",
|
||||||
|
"Preston University",
|
||||||
|
"Redding University",
|
||||||
|
"Richmond University (not American Intl)",
|
||||||
|
"Robertstown University",
|
||||||
|
"Rochdale University",
|
||||||
|
"Rochville University",
|
||||||
|
"Saint Regis University",
|
||||||
|
"St Regis University",
|
||||||
|
"Shaftesbury University",
|
||||||
|
"Shelbourne University",
|
||||||
|
"Stanton University",
|
||||||
|
"Stratford University (if unaccredited)",
|
||||||
|
"Suffield University",
|
||||||
|
"Summit University (diploma mill)",
|
||||||
|
"Sussex College of Technology",
|
||||||
|
"Trinity College and University",
|
||||||
|
"Trinity Southern University",
|
||||||
|
"University Degree Program",
|
||||||
|
"University of Atlanta",
|
||||||
|
"University of Berkley",
|
||||||
|
"University of Devonshire",
|
||||||
|
"University of Dunham",
|
||||||
|
"University of England",
|
||||||
|
"University of Northern Washington",
|
||||||
|
"University of Palmers Green",
|
||||||
|
"University of San Moritz",
|
||||||
|
"University of Sussex (fake - not real Sussex)",
|
||||||
|
"University of Wexford",
|
||||||
|
"Vocational University",
|
||||||
|
"Warnborough University",
|
||||||
|
"Washington International University",
|
||||||
|
"Weston Reserve University",
|
||||||
|
"Westbourne University",
|
||||||
|
"Western States University",
|
||||||
|
"Woodfield University",
|
||||||
|
"Yorker International University",
|
||||||
|
|
||||||
|
// Pakistani diploma mills commonly seen in UK
|
||||||
|
"Axact University",
|
||||||
|
"Brooklyn Park University",
|
||||||
|
"Columbiana University",
|
||||||
|
"Hillford University",
|
||||||
|
"Nixon University",
|
||||||
|
"Oxbridge University",
|
||||||
|
"University of Newford",
|
||||||
|
|
||||||
|
// Online diploma mills
|
||||||
|
"American World University",
|
||||||
|
"Ashford University (pre-2005)",
|
||||||
|
"Concordia College and University",
|
||||||
|
"Columbus State University (fake)",
|
||||||
|
"Frederick Taylor University",
|
||||||
|
"International Theological University",
|
||||||
|
"Nations University",
|
||||||
|
"Paramount California University",
|
||||||
|
"University of Ancient Studies",
|
||||||
|
"University of Asia",
|
||||||
|
"Virtual University (unaccredited)",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Suspicious patterns in institution names that often indicate diploma mills.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly string[] SuspiciousPatterns =
|
||||||
|
[
|
||||||
|
"online university",
|
||||||
|
"virtual university",
|
||||||
|
"life experience",
|
||||||
|
"no classes required",
|
||||||
|
"degree in days",
|
||||||
|
"accredited by", // followed by fake accreditor
|
||||||
|
"internationally recognised",
|
||||||
|
"worldwide university",
|
||||||
|
"global university",
|
||||||
|
"premier university",
|
||||||
|
"elite university",
|
||||||
|
"executive university",
|
||||||
|
"professional university",
|
||||||
|
"distance learning university", // be careful - some are legit
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fake accreditation bodies used by diploma mills.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly HashSet<string> FakeAccreditors = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"World Association of Universities and Colleges",
|
||||||
|
"WAUC",
|
||||||
|
"International Accreditation Agency",
|
||||||
|
"Universal Accreditation Council",
|
||||||
|
"Board of Online Universities Accreditation",
|
||||||
|
"International Council for Open and Distance Education",
|
||||||
|
"World Online Education Accrediting Commission",
|
||||||
|
"Central States Consortium of Colleges and Schools",
|
||||||
|
"American Council of Private Colleges and Universities",
|
||||||
|
"Association of Distance Learning Programs",
|
||||||
|
"International Distance Education Certification Agency",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if an institution is a known diploma mill.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsDiplomaMill(string institutionName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(institutionName))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var normalised = institutionName.Trim();
|
||||||
|
|
||||||
|
// Direct match
|
||||||
|
if (KnownDiplomaMills.Contains(normalised))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Check if name contains known diploma mill
|
||||||
|
foreach (var mill in KnownDiplomaMills)
|
||||||
|
{
|
||||||
|
if (normalised.Contains(mill, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if institution name has suspicious patterns common in diploma mills.
|
||||||
|
/// Returns true if suspicious (but not confirmed fake).
|
||||||
|
/// </summary>
|
||||||
|
public static bool HasSuspiciousPattern(string institutionName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(institutionName))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var lower = institutionName.ToLowerInvariant();
|
||||||
|
|
||||||
|
foreach (var pattern in SuspiciousPatterns)
|
||||||
|
{
|
||||||
|
if (lower.Contains(pattern))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if an accreditor is known to be fake.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsFakeAccreditor(string accreditorName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(accreditorName))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return FakeAccreditors.Contains(accreditorName.Trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
285
src/TrueCV.Application/Data/UKInstitutions.cs
Normal file
285
src/TrueCV.Application/Data/UKInstitutions.cs
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
namespace TrueCV.Application.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// List of recognised UK higher education institutions.
|
||||||
|
/// Source: GOV.UK Register of Higher Education Providers
|
||||||
|
/// </summary>
|
||||||
|
public static class UKInstitutions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Recognised UK universities and higher education providers.
|
||||||
|
/// These are legitimate degree-awarding institutions.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly HashSet<string> RecognisedInstitutions = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
// Russell Group Universities
|
||||||
|
"University of Birmingham",
|
||||||
|
"University of Bristol",
|
||||||
|
"University of Cambridge",
|
||||||
|
"Cardiff University",
|
||||||
|
"Durham University",
|
||||||
|
"University of Edinburgh",
|
||||||
|
"University of Exeter",
|
||||||
|
"University of Glasgow",
|
||||||
|
"Imperial College London",
|
||||||
|
"King's College London",
|
||||||
|
"University of Leeds",
|
||||||
|
"University of Liverpool",
|
||||||
|
"London School of Economics",
|
||||||
|
"London School of Economics and Political Science",
|
||||||
|
"LSE",
|
||||||
|
"University of Manchester",
|
||||||
|
"Newcastle University",
|
||||||
|
"University of Nottingham",
|
||||||
|
"University of Oxford",
|
||||||
|
"Queen Mary University of London",
|
||||||
|
"Queen's University Belfast",
|
||||||
|
"University of Sheffield",
|
||||||
|
"University of Southampton",
|
||||||
|
"University College London",
|
||||||
|
"UCL",
|
||||||
|
"University of Warwick",
|
||||||
|
"University of York",
|
||||||
|
|
||||||
|
// Other Major Universities
|
||||||
|
"Aston University",
|
||||||
|
"University of Bath",
|
||||||
|
"Birkbeck, University of London",
|
||||||
|
"Bournemouth University",
|
||||||
|
"University of Bradford",
|
||||||
|
"University of Brighton",
|
||||||
|
"Brunel University London",
|
||||||
|
"University of Buckingham",
|
||||||
|
"Canterbury Christ Church University",
|
||||||
|
"City, University of London",
|
||||||
|
"Coventry University",
|
||||||
|
"Cranfield University",
|
||||||
|
"De Montfort University",
|
||||||
|
"University of Derby",
|
||||||
|
"University of Dundee",
|
||||||
|
"University of East Anglia",
|
||||||
|
"UEA",
|
||||||
|
"University of East London",
|
||||||
|
"Edge Hill University",
|
||||||
|
"University of Essex",
|
||||||
|
"Falmouth University",
|
||||||
|
"University of Greenwich",
|
||||||
|
"Heriot-Watt University",
|
||||||
|
"University of Hertfordshire",
|
||||||
|
"University of Huddersfield",
|
||||||
|
"University of Hull",
|
||||||
|
"Keele University",
|
||||||
|
"University of Kent",
|
||||||
|
"Kingston University",
|
||||||
|
"Lancaster University",
|
||||||
|
"University of Leicester",
|
||||||
|
"University of Lincoln",
|
||||||
|
"Liverpool John Moores University",
|
||||||
|
"Liverpool Hope University",
|
||||||
|
"University of London",
|
||||||
|
"London Metropolitan University",
|
||||||
|
"London South Bank University",
|
||||||
|
"Loughborough University",
|
||||||
|
"Manchester Metropolitan University",
|
||||||
|
"Middlesex University",
|
||||||
|
"Northumbria University",
|
||||||
|
"Norwich University of the Arts",
|
||||||
|
"Nottingham Trent University",
|
||||||
|
"Open University",
|
||||||
|
"The Open University",
|
||||||
|
"Oxford Brookes University",
|
||||||
|
"University of Plymouth",
|
||||||
|
"University of Portsmouth",
|
||||||
|
"Queen Margaret University",
|
||||||
|
"University of Reading",
|
||||||
|
"Robert Gordon University",
|
||||||
|
"Roehampton University",
|
||||||
|
"Royal Holloway, University of London",
|
||||||
|
"Royal Holloway",
|
||||||
|
"University of Salford",
|
||||||
|
"SOAS University of London",
|
||||||
|
"SOAS",
|
||||||
|
"Sheffield Hallam University",
|
||||||
|
"University of South Wales",
|
||||||
|
"University of St Andrews",
|
||||||
|
"St Andrews",
|
||||||
|
"Staffordshire University",
|
||||||
|
"University of Stirling",
|
||||||
|
"University of Strathclyde",
|
||||||
|
"University of Sunderland",
|
||||||
|
"University of Surrey",
|
||||||
|
"University of Sussex",
|
||||||
|
"Swansea University",
|
||||||
|
"Teesside University",
|
||||||
|
"Ulster University",
|
||||||
|
"University of the West of England",
|
||||||
|
"UWE Bristol",
|
||||||
|
"University of the West of Scotland",
|
||||||
|
"University of Westminster",
|
||||||
|
"University of Winchester",
|
||||||
|
"University of Wolverhampton",
|
||||||
|
"University of Worcester",
|
||||||
|
"Wrexham University",
|
||||||
|
"York St John University",
|
||||||
|
|
||||||
|
// Scottish Universities
|
||||||
|
"University of Aberdeen",
|
||||||
|
"Abertay University",
|
||||||
|
"Edinburgh Napier University",
|
||||||
|
"Glasgow Caledonian University",
|
||||||
|
"University of the Highlands and Islands",
|
||||||
|
|
||||||
|
// Welsh Universities
|
||||||
|
"Aberystwyth University",
|
||||||
|
"Bangor University",
|
||||||
|
"University of South Wales",
|
||||||
|
"Wrexham Glyndwr University",
|
||||||
|
|
||||||
|
// Northern Ireland
|
||||||
|
"Ulster University",
|
||||||
|
"Queen's University Belfast",
|
||||||
|
|
||||||
|
// Specialist Institutions
|
||||||
|
"Royal Academy of Music",
|
||||||
|
"Royal College of Art",
|
||||||
|
"Royal College of Music",
|
||||||
|
"Royal Northern College of Music",
|
||||||
|
"Royal Veterinary College",
|
||||||
|
"Goldsmiths, University of London",
|
||||||
|
"Goldsmiths",
|
||||||
|
"Courtauld Institute of Art",
|
||||||
|
"London Business School",
|
||||||
|
"LBS",
|
||||||
|
"Guildhall School of Music and Drama",
|
||||||
|
"Trinity Laban Conservatoire of Music and Dance",
|
||||||
|
"Arts University Bournemouth",
|
||||||
|
"University for the Creative Arts",
|
||||||
|
"Ravensbourne University London",
|
||||||
|
|
||||||
|
// Business Schools (accredited)
|
||||||
|
"Henley Business School",
|
||||||
|
"Warwick Business School",
|
||||||
|
"Manchester Business School",
|
||||||
|
"Said Business School",
|
||||||
|
"Judge Business School",
|
||||||
|
"Cass Business School",
|
||||||
|
"Bayes Business School",
|
||||||
|
"Imperial College Business School",
|
||||||
|
"Cranfield School of Management",
|
||||||
|
"Ashridge Business School",
|
||||||
|
"Alliance Manchester Business School",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Common name variations and abbreviations mapped to official names.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly Dictionary<string, string> NameVariations = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["Cambridge"] = "University of Cambridge",
|
||||||
|
["Oxford"] = "University of Oxford",
|
||||||
|
["Cambridge University"] = "University of Cambridge",
|
||||||
|
["Oxford University"] = "University of Oxford",
|
||||||
|
["Imperial"] = "Imperial College London",
|
||||||
|
["Imperial College"] = "Imperial College London",
|
||||||
|
["Kings College London"] = "King's College London",
|
||||||
|
["Kings London"] = "King's College London",
|
||||||
|
["KCL"] = "King's College London",
|
||||||
|
["Edinburgh"] = "University of Edinburgh",
|
||||||
|
["Manchester"] = "University of Manchester",
|
||||||
|
["Bristol"] = "University of Bristol",
|
||||||
|
["Warwick"] = "University of Warwick",
|
||||||
|
["Durham"] = "Durham University",
|
||||||
|
["Bath"] = "University of Bath",
|
||||||
|
["Exeter"] = "University of Exeter",
|
||||||
|
["York"] = "University of York",
|
||||||
|
["Leeds"] = "University of Leeds",
|
||||||
|
["Sheffield"] = "University of Sheffield",
|
||||||
|
["Birmingham"] = "University of Birmingham",
|
||||||
|
["Nottingham"] = "University of Nottingham",
|
||||||
|
["Southampton"] = "University of Southampton",
|
||||||
|
["Glasgow"] = "University of Glasgow",
|
||||||
|
["Liverpool"] = "University of Liverpool",
|
||||||
|
["Lancaster"] = "Lancaster University",
|
||||||
|
["Leicester"] = "University of Leicester",
|
||||||
|
["Surrey"] = "University of Surrey",
|
||||||
|
["Sussex"] = "University of Sussex",
|
||||||
|
["Reading"] = "University of Reading",
|
||||||
|
["Loughborough"] = "Loughborough University",
|
||||||
|
["Brunel"] = "Brunel University London",
|
||||||
|
["Kent"] = "University of Kent",
|
||||||
|
["Essex"] = "University of Essex",
|
||||||
|
["Strathclyde"] = "University of Strathclyde",
|
||||||
|
["Heriot Watt"] = "Heriot-Watt University",
|
||||||
|
["Heriot-Watt"] = "Heriot-Watt University",
|
||||||
|
["St Andrews University"] = "University of St Andrews",
|
||||||
|
["Saint Andrews"] = "University of St Andrews",
|
||||||
|
["Birkbeck"] = "Birkbeck, University of London",
|
||||||
|
["QMUL"] = "Queen Mary University of London",
|
||||||
|
["Queen Mary"] = "Queen Mary University of London",
|
||||||
|
["Royal Holloway University"] = "Royal Holloway, University of London",
|
||||||
|
["RHUL"] = "Royal Holloway, University of London",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if an institution is recognised. Handles common variations.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsRecognised(string institutionName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(institutionName))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var normalised = institutionName.Trim();
|
||||||
|
|
||||||
|
// Direct match
|
||||||
|
if (RecognisedInstitutions.Contains(normalised))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Check variations
|
||||||
|
if (NameVariations.TryGetValue(normalised, out var officialName))
|
||||||
|
return RecognisedInstitutions.Contains(officialName);
|
||||||
|
|
||||||
|
// Fuzzy match - check if any recognised institution contains the search term
|
||||||
|
// or if the search term contains a recognised institution
|
||||||
|
foreach (var institution in RecognisedInstitutions)
|
||||||
|
{
|
||||||
|
if (institution.Contains(normalised, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalised.Contains(institution, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the official name of an institution if found.
|
||||||
|
/// </summary>
|
||||||
|
public static string? GetOfficialName(string institutionName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(institutionName))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var normalised = institutionName.Trim();
|
||||||
|
|
||||||
|
// Direct match
|
||||||
|
if (RecognisedInstitutions.Contains(normalised))
|
||||||
|
return normalised;
|
||||||
|
|
||||||
|
// Check variations
|
||||||
|
if (NameVariations.TryGetValue(normalised, out var officialName))
|
||||||
|
return officialName;
|
||||||
|
|
||||||
|
// Fuzzy match
|
||||||
|
foreach (var institution in RecognisedInstitutions)
|
||||||
|
{
|
||||||
|
if (institution.Contains(normalised, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalised.Contains(institution, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return institution;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/TrueCV.Application/Helpers/DateHelpers.cs
Normal file
19
src/TrueCV.Application/Helpers/DateHelpers.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
namespace TrueCV.Application.Helpers;
|
||||||
|
|
||||||
|
public static class DateHelpers
|
||||||
|
{
|
||||||
|
public static DateOnly? ParseDate(string? dateString)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(dateString))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DateOnly.TryParse(dateString, out var date))
|
||||||
|
{
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/TrueCV.Application/Helpers/JsonDefaults.cs
Normal file
19
src/TrueCV.Application/Helpers/JsonDefaults.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace TrueCV.Application.Helpers;
|
||||||
|
|
||||||
|
public static class JsonDefaults
|
||||||
|
{
|
||||||
|
public static readonly JsonSerializerOptions CamelCase = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
public static readonly JsonSerializerOptions CamelCaseIndented = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
WriteIndented = true
|
||||||
|
};
|
||||||
|
}
|
||||||
21
src/TrueCV.Application/Helpers/ScoreThresholds.cs
Normal file
21
src/TrueCV.Application/Helpers/ScoreThresholds.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
namespace TrueCV.Application.Helpers;
|
||||||
|
|
||||||
|
public static class ScoreThresholds
|
||||||
|
{
|
||||||
|
public const int High = 70;
|
||||||
|
public const int Medium = 50;
|
||||||
|
|
||||||
|
public static string GetScoreClass(int score) => score switch
|
||||||
|
{
|
||||||
|
> High => "score-high",
|
||||||
|
>= Medium => "score-medium",
|
||||||
|
_ => "score-low"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string GetBadgeClass(int score) => score switch
|
||||||
|
{
|
||||||
|
> High => "bg-success",
|
||||||
|
>= Medium => "bg-warning text-dark",
|
||||||
|
_ => "bg-danger"
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,5 +4,5 @@ namespace TrueCV.Application.Interfaces;
|
|||||||
|
|
||||||
public interface ICVParserService
|
public interface ICVParserService
|
||||||
{
|
{
|
||||||
Task<CVData> ParseAsync(Stream fileStream, string fileName);
|
Task<CVData> ParseAsync(Stream fileStream, string fileName, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using TrueCV.Application.Models;
|
||||||
|
|
||||||
|
namespace TrueCV.Application.Interfaces;
|
||||||
|
|
||||||
|
public interface IEducationVerifierService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Verify a single education entry.
|
||||||
|
/// </summary>
|
||||||
|
EducationVerificationResult Verify(EducationEntry education);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verify all education entries and check for timeline issues.
|
||||||
|
/// </summary>
|
||||||
|
List<EducationVerificationResult> VerifyAll(List<EducationEntry> education, List<EmploymentEntry>? employment = null);
|
||||||
|
}
|
||||||
6
src/TrueCV.Application/Interfaces/IUserContextService.cs
Normal file
6
src/TrueCV.Application/Interfaces/IUserContextService.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace TrueCV.Application.Interfaces;
|
||||||
|
|
||||||
|
public interface IUserContextService
|
||||||
|
{
|
||||||
|
Task<Guid?> GetCurrentUserIdAsync();
|
||||||
|
}
|
||||||
22
src/TrueCV.Application/Models/EducationVerificationResult.cs
Normal file
22
src/TrueCV.Application/Models/EducationVerificationResult.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
namespace TrueCV.Application.Models;
|
||||||
|
|
||||||
|
public sealed record EducationVerificationResult
|
||||||
|
{
|
||||||
|
public required string ClaimedInstitution { get; init; }
|
||||||
|
public string? MatchedInstitution { get; init; }
|
||||||
|
public required string Status { get; init; } // Recognised, NotRecognised, DiplomaMill, Suspicious, Unknown
|
||||||
|
public bool IsVerified { get; init; }
|
||||||
|
public bool IsDiplomaMill { get; init; }
|
||||||
|
public bool IsSuspicious { get; init; }
|
||||||
|
public string? VerificationNotes { get; init; }
|
||||||
|
|
||||||
|
// Date plausibility
|
||||||
|
public DateOnly? ClaimedStartDate { get; init; }
|
||||||
|
public DateOnly? ClaimedEndDate { get; init; }
|
||||||
|
public bool DatesArePlausible { get; init; } = true;
|
||||||
|
public string? DatePlausibilityNotes { get; init; }
|
||||||
|
|
||||||
|
// Qualification info
|
||||||
|
public string? ClaimedQualification { get; init; }
|
||||||
|
public string? ClaimedSubject { get; init; }
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ public sealed record VeracityReport
|
|||||||
public required int OverallScore { get; init; }
|
public required int OverallScore { get; init; }
|
||||||
public required string ScoreLabel { get; init; }
|
public required string ScoreLabel { get; init; }
|
||||||
public List<CompanyVerificationResult> EmploymentVerifications { get; init; } = [];
|
public List<CompanyVerificationResult> EmploymentVerifications { get; init; } = [];
|
||||||
|
public List<EducationVerificationResult> EducationVerifications { get; init; } = [];
|
||||||
public required TimelineAnalysisResult TimelineAnalysis { get; init; }
|
public required TimelineAnalysisResult TimelineAnalysis { get; init; }
|
||||||
public List<FlagResult> Flags { get; init; } = [];
|
public List<FlagResult> Flags { get; init; } = [];
|
||||||
public required DateTime GeneratedAt { get; init; }
|
public required DateTime GeneratedAt { get; init; }
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
using TrueCV.Domain.Enums;
|
using TrueCV.Domain.Enums;
|
||||||
|
|
||||||
namespace TrueCV.Domain.Entities;
|
namespace TrueCV.Domain.Entities;
|
||||||
@@ -31,9 +30,5 @@ public class CVCheck
|
|||||||
|
|
||||||
public DateTime? CompletedAt { get; set; }
|
public DateTime? CompletedAt { get; set; }
|
||||||
|
|
||||||
// Navigation properties
|
|
||||||
[ForeignKey(nameof(UserId))]
|
|
||||||
public User User { get; set; } = null!;
|
|
||||||
|
|
||||||
public ICollection<CVFlag> Flags { get; set; } = new List<CVFlag>();
|
public ICollection<CVFlag> Flags { get; set; } = new List<CVFlag>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using TrueCV.Domain.Enums;
|
|
||||||
|
|
||||||
namespace TrueCV.Domain.Entities;
|
|
||||||
|
|
||||||
public class User
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
[MaxLength(256)]
|
|
||||||
public string Email { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public UserPlan Plan { get; set; }
|
|
||||||
|
|
||||||
[MaxLength(256)]
|
|
||||||
public string? StripeCustomerId { get; set; }
|
|
||||||
|
|
||||||
public int ChecksUsedThisMonth { get; set; }
|
|
||||||
|
|
||||||
// Navigation property
|
|
||||||
public ICollection<CVCheck> CVChecks { get; set; } = new List<CVCheck>();
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace TrueCV.Infrastructure.Configuration;
|
||||||
|
|
||||||
|
public sealed class LocalStorageSettings
|
||||||
|
{
|
||||||
|
public const string SectionName = "LocalStorage";
|
||||||
|
|
||||||
|
public string StoragePath { get; set; } = "./uploads";
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Identity;
|
|||||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TrueCV.Domain.Entities;
|
using TrueCV.Domain.Entities;
|
||||||
using TrueCV.Domain.Enums;
|
|
||||||
using TrueCV.Infrastructure.Identity;
|
using TrueCV.Infrastructure.Identity;
|
||||||
|
|
||||||
namespace TrueCV.Infrastructure.Data;
|
namespace TrueCV.Infrastructure.Data;
|
||||||
@@ -64,9 +63,6 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityR
|
|||||||
.WithOne(f => f.CVCheck)
|
.WithOne(f => f.CVCheck)
|
||||||
.HasForeignKey(f => f.CVCheckId)
|
.HasForeignKey(f => f.CVCheckId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
// Ignore the User navigation property since we're using ApplicationUser
|
|
||||||
entity.Ignore(c => c.User);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using Azure.Storage.Blobs;
|
|
||||||
using Hangfire;
|
using Hangfire;
|
||||||
using Hangfire.SqlServer;
|
using Hangfire.SqlServer;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -59,6 +58,9 @@ public static class DependencyInjection
|
|||||||
services.Configure<AzureBlobSettings>(
|
services.Configure<AzureBlobSettings>(
|
||||||
configuration.GetSection(AzureBlobSettings.SectionName));
|
configuration.GetSection(AzureBlobSettings.SectionName));
|
||||||
|
|
||||||
|
services.Configure<LocalStorageSettings>(
|
||||||
|
configuration.GetSection(LocalStorageSettings.SectionName));
|
||||||
|
|
||||||
// Configure HttpClient for CompaniesHouseClient with retry policy
|
// Configure HttpClient for CompaniesHouseClient with retry policy
|
||||||
services.AddHttpClient<CompaniesHouseClient>((serviceProvider, client) =>
|
services.AddHttpClient<CompaniesHouseClient>((serviceProvider, client) =>
|
||||||
{
|
{
|
||||||
@@ -73,22 +75,24 @@ public static class DependencyInjection
|
|||||||
})
|
})
|
||||||
.AddPolicyHandler(GetRetryPolicy());
|
.AddPolicyHandler(GetRetryPolicy());
|
||||||
|
|
||||||
// Configure BlobServiceClient
|
|
||||||
var azureBlobConnectionString = configuration
|
|
||||||
.GetSection(AzureBlobSettings.SectionName)
|
|
||||||
.GetValue<string>("ConnectionString");
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(azureBlobConnectionString))
|
|
||||||
{
|
|
||||||
services.AddSingleton(_ => new BlobServiceClient(azureBlobConnectionString));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register services
|
// Register services
|
||||||
services.AddScoped<ICVParserService, CVParserService>();
|
services.AddScoped<ICVParserService, CVParserService>();
|
||||||
services.AddScoped<ICompanyVerifierService, CompanyVerifierService>();
|
services.AddScoped<ICompanyVerifierService, CompanyVerifierService>();
|
||||||
|
services.AddScoped<IEducationVerifierService, EducationVerifierService>();
|
||||||
services.AddScoped<ITimelineAnalyserService, TimelineAnalyserService>();
|
services.AddScoped<ITimelineAnalyserService, TimelineAnalyserService>();
|
||||||
services.AddScoped<IFileStorageService, FileStorageService>();
|
|
||||||
services.AddScoped<ICVCheckService, CVCheckService>();
|
services.AddScoped<ICVCheckService, CVCheckService>();
|
||||||
|
services.AddScoped<IUserContextService, UserContextService>();
|
||||||
|
|
||||||
|
// Register file storage - use local storage if configured, otherwise Azure
|
||||||
|
var useLocalStorage = configuration.GetValue<bool>("UseLocalStorage");
|
||||||
|
if (useLocalStorage)
|
||||||
|
{
|
||||||
|
services.AddScoped<IFileStorageService, LocalFileStorageService>();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
services.AddScoped<IFileStorageService, FileStorageService>();
|
||||||
|
}
|
||||||
|
|
||||||
// Register Hangfire jobs
|
// Register Hangfire jobs
|
||||||
services.AddTransient<ProcessCVCheckJob>();
|
services.AddTransient<ProcessCVCheckJob>();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using TrueCV.Application.Helpers;
|
||||||
using TrueCV.Application.Interfaces;
|
using TrueCV.Application.Interfaces;
|
||||||
using TrueCV.Application.Models;
|
using TrueCV.Application.Models;
|
||||||
using TrueCV.Domain.Entities;
|
using TrueCV.Domain.Entities;
|
||||||
@@ -15,26 +16,26 @@ public sealed class ProcessCVCheckJob
|
|||||||
private readonly IFileStorageService _fileStorageService;
|
private readonly IFileStorageService _fileStorageService;
|
||||||
private readonly ICVParserService _cvParserService;
|
private readonly ICVParserService _cvParserService;
|
||||||
private readonly ICompanyVerifierService _companyVerifierService;
|
private readonly ICompanyVerifierService _companyVerifierService;
|
||||||
|
private readonly IEducationVerifierService _educationVerifierService;
|
||||||
private readonly ITimelineAnalyserService _timelineAnalyserService;
|
private readonly ITimelineAnalyserService _timelineAnalyserService;
|
||||||
private readonly ILogger<ProcessCVCheckJob> _logger;
|
private readonly ILogger<ProcessCVCheckJob> _logger;
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
||||||
{
|
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
||||||
WriteIndented = true
|
|
||||||
};
|
|
||||||
|
|
||||||
private const int BaseScore = 100;
|
private const int BaseScore = 100;
|
||||||
private const int UnverifiedCompanyPenalty = 10;
|
private const int UnverifiedCompanyPenalty = 10;
|
||||||
private const int GapMonthPenalty = 1;
|
private const int GapMonthPenalty = 1;
|
||||||
private const int MaxGapPenalty = 10;
|
private const int MaxGapPenalty = 10;
|
||||||
private const int OverlapMonthPenalty = 2;
|
private const int OverlapMonthPenalty = 2;
|
||||||
|
private const int DiplomaMillPenalty = 25;
|
||||||
|
private const int SuspiciousInstitutionPenalty = 15;
|
||||||
|
private const int UnverifiedEducationPenalty = 5;
|
||||||
|
private const int EducationDatePenalty = 10;
|
||||||
|
|
||||||
public ProcessCVCheckJob(
|
public ProcessCVCheckJob(
|
||||||
ApplicationDbContext dbContext,
|
ApplicationDbContext dbContext,
|
||||||
IFileStorageService fileStorageService,
|
IFileStorageService fileStorageService,
|
||||||
ICVParserService cvParserService,
|
ICVParserService cvParserService,
|
||||||
ICompanyVerifierService companyVerifierService,
|
ICompanyVerifierService companyVerifierService,
|
||||||
|
IEducationVerifierService educationVerifierService,
|
||||||
ITimelineAnalyserService timelineAnalyserService,
|
ITimelineAnalyserService timelineAnalyserService,
|
||||||
ILogger<ProcessCVCheckJob> logger)
|
ILogger<ProcessCVCheckJob> logger)
|
||||||
{
|
{
|
||||||
@@ -42,6 +43,7 @@ public sealed class ProcessCVCheckJob
|
|||||||
_fileStorageService = fileStorageService;
|
_fileStorageService = fileStorageService;
|
||||||
_cvParserService = cvParserService;
|
_cvParserService = cvParserService;
|
||||||
_companyVerifierService = companyVerifierService;
|
_companyVerifierService = companyVerifierService;
|
||||||
|
_educationVerifierService = educationVerifierService;
|
||||||
_timelineAnalyserService = timelineAnalyserService;
|
_timelineAnalyserService = timelineAnalyserService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
@@ -73,53 +75,78 @@ public sealed class ProcessCVCheckJob
|
|||||||
_logger.LogDebug("Downloaded CV file for check {CheckId}", cvCheckId);
|
_logger.LogDebug("Downloaded CV file for check {CheckId}", cvCheckId);
|
||||||
|
|
||||||
// Step 3: Parse CV
|
// Step 3: Parse CV
|
||||||
var cvData = await _cvParserService.ParseAsync(fileStream, cvCheck.OriginalFileName);
|
var cvData = await _cvParserService.ParseAsync(fileStream, cvCheck.OriginalFileName, cancellationToken);
|
||||||
|
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"Parsed CV for check {CheckId}: {EmploymentCount} employment entries",
|
"Parsed CV for check {CheckId}: {EmploymentCount} employment entries",
|
||||||
cvCheckId, cvData.Employment.Count);
|
cvCheckId, cvData.Employment.Count);
|
||||||
|
|
||||||
// Step 4: Save extracted data
|
// Step 4: Save extracted data
|
||||||
cvCheck.ExtractedDataJson = JsonSerializer.Serialize(cvData, JsonOptions);
|
cvCheck.ExtractedDataJson = JsonSerializer.Serialize(cvData, JsonDefaults.CamelCaseIndented);
|
||||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
// Step 5: Verify each employment entry
|
// Step 5: Verify each employment entry (parallelized with rate limiting)
|
||||||
var verificationResults = new List<CompanyVerificationResult>();
|
var verificationTasks = cvData.Employment.Select(async employment =>
|
||||||
foreach (var employment in cvData.Employment)
|
|
||||||
{
|
{
|
||||||
var result = await _companyVerifierService.VerifyCompanyAsync(
|
var result = await _companyVerifierService.VerifyCompanyAsync(
|
||||||
employment.CompanyName,
|
employment.CompanyName,
|
||||||
employment.StartDate,
|
employment.StartDate,
|
||||||
employment.EndDate);
|
employment.EndDate);
|
||||||
|
|
||||||
verificationResults.Add(result);
|
|
||||||
|
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"Verified {Company}: {IsVerified} (Score: {Score}%)",
|
"Verified {Company}: {IsVerified} (Score: {Score}%)",
|
||||||
employment.CompanyName, result.IsVerified, result.MatchScore);
|
employment.CompanyName, result.IsVerified, result.MatchScore);
|
||||||
}
|
|
||||||
|
|
||||||
// Step 6: Analyse timeline
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
var verificationResults = (await Task.WhenAll(verificationTasks)).ToList();
|
||||||
|
|
||||||
|
// Step 6: Verify education entries
|
||||||
|
var educationResults = _educationVerifierService.VerifyAll(
|
||||||
|
cvData.Education,
|
||||||
|
cvData.Employment);
|
||||||
|
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Education verification for check {CheckId}: {Count} entries verified ({Recognised} recognised, {DiplomaMill} diploma mills)",
|
||||||
|
cvCheckId,
|
||||||
|
educationResults.Count,
|
||||||
|
educationResults.Count(e => e.IsVerified),
|
||||||
|
educationResults.Count(e => e.IsDiplomaMill));
|
||||||
|
|
||||||
|
// Step 7: Analyse timeline
|
||||||
var timelineAnalysis = _timelineAnalyserService.Analyse(cvData.Employment);
|
var timelineAnalysis = _timelineAnalyserService.Analyse(cvData.Employment);
|
||||||
|
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"Timeline analysis for check {CheckId}: {GapCount} gaps, {OverlapCount} overlaps",
|
"Timeline analysis for check {CheckId}: {GapCount} gaps, {OverlapCount} overlaps",
|
||||||
cvCheckId, timelineAnalysis.Gaps.Count, timelineAnalysis.Overlaps.Count);
|
cvCheckId, timelineAnalysis.Gaps.Count, timelineAnalysis.Overlaps.Count);
|
||||||
|
|
||||||
// Step 7: Calculate veracity score
|
// Step 8: Calculate veracity score
|
||||||
var (score, flags) = CalculateVeracityScore(verificationResults, timelineAnalysis);
|
var (score, flags) = CalculateVeracityScore(verificationResults, educationResults, timelineAnalysis);
|
||||||
|
|
||||||
_logger.LogDebug("Calculated veracity score for check {CheckId}: {Score}", cvCheckId, score);
|
_logger.LogDebug("Calculated veracity score for check {CheckId}: {Score}", cvCheckId, score);
|
||||||
|
|
||||||
// Step 8: Create CVFlag records
|
// Step 9: Create CVFlag records
|
||||||
foreach (var flag in flags)
|
foreach (var flag in flags)
|
||||||
{
|
{
|
||||||
|
if (!Enum.TryParse<FlagCategory>(flag.Category, out var category))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Unknown flag category: {Category}, defaulting to Timeline", flag.Category);
|
||||||
|
category = FlagCategory.Timeline;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Enum.TryParse<FlagSeverity>(flag.Severity, out var severity))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Unknown flag severity: {Severity}, defaulting to Info", flag.Severity);
|
||||||
|
severity = FlagSeverity.Info;
|
||||||
|
}
|
||||||
|
|
||||||
var cvFlag = new CVFlag
|
var cvFlag = new CVFlag
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
CVCheckId = cvCheckId,
|
CVCheckId = cvCheckId,
|
||||||
Category = Enum.Parse<FlagCategory>(flag.Category),
|
Category = category,
|
||||||
Severity = Enum.Parse<FlagSeverity>(flag.Severity),
|
Severity = severity,
|
||||||
Title = flag.Title,
|
Title = flag.Title,
|
||||||
Description = flag.Description,
|
Description = flag.Description,
|
||||||
ScoreImpact = flag.ScoreImpact
|
ScoreImpact = flag.ScoreImpact
|
||||||
@@ -128,21 +155,22 @@ public sealed class ProcessCVCheckJob
|
|||||||
_dbContext.CVFlags.Add(cvFlag);
|
_dbContext.CVFlags.Add(cvFlag);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 9: Generate veracity report
|
// Step 10: Generate veracity report
|
||||||
var report = new VeracityReport
|
var report = new VeracityReport
|
||||||
{
|
{
|
||||||
OverallScore = score,
|
OverallScore = score,
|
||||||
ScoreLabel = GetScoreLabel(score),
|
ScoreLabel = GetScoreLabel(score),
|
||||||
EmploymentVerifications = verificationResults,
|
EmploymentVerifications = verificationResults,
|
||||||
|
EducationVerifications = educationResults,
|
||||||
TimelineAnalysis = timelineAnalysis,
|
TimelineAnalysis = timelineAnalysis,
|
||||||
Flags = flags,
|
Flags = flags,
|
||||||
GeneratedAt = DateTime.UtcNow
|
GeneratedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
cvCheck.ReportJson = JsonSerializer.Serialize(report, JsonOptions);
|
cvCheck.ReportJson = JsonSerializer.Serialize(report, JsonDefaults.CamelCaseIndented);
|
||||||
cvCheck.VeracityScore = score;
|
cvCheck.VeracityScore = score;
|
||||||
|
|
||||||
// Step 10: Update status to Completed
|
// Step 11: Update status to Completed
|
||||||
cvCheck.Status = CheckStatus.Completed;
|
cvCheck.Status = CheckStatus.Completed;
|
||||||
cvCheck.CompletedAt = DateTime.UtcNow;
|
cvCheck.CompletedAt = DateTime.UtcNow;
|
||||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
@@ -156,7 +184,8 @@ public sealed class ProcessCVCheckJob
|
|||||||
_logger.LogError(ex, "Error processing CV check {CheckId}", cvCheckId);
|
_logger.LogError(ex, "Error processing CV check {CheckId}", cvCheckId);
|
||||||
|
|
||||||
cvCheck.Status = CheckStatus.Failed;
|
cvCheck.Status = CheckStatus.Failed;
|
||||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
// Use CancellationToken.None to ensure failure status is saved even if original token is cancelled
|
||||||
|
await _dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
@@ -164,6 +193,7 @@ public sealed class ProcessCVCheckJob
|
|||||||
|
|
||||||
private static (int Score, List<FlagResult> Flags) CalculateVeracityScore(
|
private static (int Score, List<FlagResult> Flags) CalculateVeracityScore(
|
||||||
List<CompanyVerificationResult> verifications,
|
List<CompanyVerificationResult> verifications,
|
||||||
|
List<EducationVerificationResult> educationResults,
|
||||||
TimelineAnalysisResult timeline)
|
TimelineAnalysisResult timeline)
|
||||||
{
|
{
|
||||||
var score = BaseScore;
|
var score = BaseScore;
|
||||||
@@ -184,6 +214,66 @@ public sealed class ProcessCVCheckJob
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Penalty for diploma mills (critical)
|
||||||
|
foreach (var edu in educationResults.Where(e => e.IsDiplomaMill))
|
||||||
|
{
|
||||||
|
score -= DiplomaMillPenalty;
|
||||||
|
|
||||||
|
flags.Add(new FlagResult
|
||||||
|
{
|
||||||
|
Category = FlagCategory.Education.ToString(),
|
||||||
|
Severity = FlagSeverity.Critical.ToString(),
|
||||||
|
Title = "Diploma Mill Detected",
|
||||||
|
Description = $"'{edu.ClaimedInstitution}' is a known diploma mill. {edu.VerificationNotes}",
|
||||||
|
ScoreImpact = -DiplomaMillPenalty
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Penalty for suspicious institutions
|
||||||
|
foreach (var edu in educationResults.Where(e => e.IsSuspicious && !e.IsDiplomaMill))
|
||||||
|
{
|
||||||
|
score -= SuspiciousInstitutionPenalty;
|
||||||
|
|
||||||
|
flags.Add(new FlagResult
|
||||||
|
{
|
||||||
|
Category = FlagCategory.Education.ToString(),
|
||||||
|
Severity = FlagSeverity.Warning.ToString(),
|
||||||
|
Title = "Suspicious Institution",
|
||||||
|
Description = $"'{edu.ClaimedInstitution}' has suspicious characteristics. {edu.VerificationNotes}",
|
||||||
|
ScoreImpact = -SuspiciousInstitutionPenalty
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Penalty for unverified education (not recognised, but not flagged as fake)
|
||||||
|
foreach (var edu in educationResults.Where(e => !e.IsVerified && !e.IsDiplomaMill && !e.IsSuspicious && e.Status == "Unknown"))
|
||||||
|
{
|
||||||
|
score -= UnverifiedEducationPenalty;
|
||||||
|
|
||||||
|
flags.Add(new FlagResult
|
||||||
|
{
|
||||||
|
Category = FlagCategory.Education.ToString(),
|
||||||
|
Severity = FlagSeverity.Info.ToString(),
|
||||||
|
Title = "Unverified Institution",
|
||||||
|
Description = $"Could not verify '{edu.ClaimedInstitution}'. {edu.VerificationNotes}",
|
||||||
|
ScoreImpact = -UnverifiedEducationPenalty
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Penalty for implausible education dates
|
||||||
|
foreach (var edu in educationResults.Where(e => !e.DatesArePlausible))
|
||||||
|
{
|
||||||
|
score -= EducationDatePenalty;
|
||||||
|
|
||||||
|
flags.Add(new FlagResult
|
||||||
|
{
|
||||||
|
Category = FlagCategory.Education.ToString(),
|
||||||
|
Severity = FlagSeverity.Warning.ToString(),
|
||||||
|
Title = "Education Date Issues",
|
||||||
|
Description = $"Date issues for '{edu.ClaimedInstitution}': {edu.DatePlausibilityNotes}",
|
||||||
|
ScoreImpact = -EducationDatePenalty
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Penalty for gaps (max -10 per gap)
|
// Penalty for gaps (max -10 per gap)
|
||||||
foreach (var gap in timeline.Gaps)
|
foreach (var gap in timeline.Gaps)
|
||||||
{
|
{
|
||||||
@@ -205,7 +295,7 @@ public sealed class ProcessCVCheckJob
|
|||||||
// Penalty for overlaps (only if > 2 months)
|
// Penalty for overlaps (only if > 2 months)
|
||||||
foreach (var overlap in timeline.Overlaps)
|
foreach (var overlap in timeline.Overlaps)
|
||||||
{
|
{
|
||||||
var excessMonths = overlap.Months - 2; // Allow 2 month transition
|
var excessMonths = Math.Max(0, overlap.Months - 2); // Allow 2 month transition, prevent negative
|
||||||
var overlapPenalty = excessMonths * OverlapMonthPenalty;
|
var overlapPenalty = excessMonths * OverlapMonthPenalty;
|
||||||
score -= overlapPenalty;
|
score -= overlapPenalty;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Hangfire;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using TrueCV.Application.DTOs;
|
using TrueCV.Application.DTOs;
|
||||||
|
using TrueCV.Application.Helpers;
|
||||||
using TrueCV.Application.Interfaces;
|
using TrueCV.Application.Interfaces;
|
||||||
using TrueCV.Application.Models;
|
using TrueCV.Application.Models;
|
||||||
using TrueCV.Domain.Entities;
|
using TrueCV.Domain.Entities;
|
||||||
@@ -139,7 +140,7 @@ public sealed class CVCheckService : ICVCheckService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var report = JsonSerializer.Deserialize<VeracityReport>(cvCheck.ReportJson);
|
var report = JsonSerializer.Deserialize<VeracityReport>(cvCheck.ReportJson, JsonDefaults.CamelCase);
|
||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
catch (JsonException ex)
|
catch (JsonException ex)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using DocumentFormat.OpenXml.Packaging;
|
|||||||
using DocumentFormat.OpenXml.Wordprocessing;
|
using DocumentFormat.OpenXml.Wordprocessing;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using TrueCV.Application.Helpers;
|
||||||
using TrueCV.Application.Interfaces;
|
using TrueCV.Application.Interfaces;
|
||||||
using TrueCV.Application.Models;
|
using TrueCV.Application.Models;
|
||||||
using TrueCV.Infrastructure.Configuration;
|
using TrueCV.Infrastructure.Configuration;
|
||||||
@@ -18,12 +19,6 @@ public sealed class CVParserService : ICVParserService
|
|||||||
private readonly AnthropicClient _anthropicClient;
|
private readonly AnthropicClient _anthropicClient;
|
||||||
private readonly ILogger<CVParserService> _logger;
|
private readonly ILogger<CVParserService> _logger;
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
||||||
{
|
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
||||||
PropertyNameCaseInsensitive = true
|
|
||||||
};
|
|
||||||
|
|
||||||
private const string SystemPrompt = """
|
private const string SystemPrompt = """
|
||||||
You are a CV/Resume parser. Your task is to extract structured information from CV text.
|
You are a CV/Resume parser. Your task is to extract structured information from CV text.
|
||||||
You must respond ONLY with valid JSON, no other text or markdown.
|
You must respond ONLY with valid JSON, no other text or markdown.
|
||||||
@@ -80,14 +75,14 @@ public sealed class CVParserService : ICVParserService
|
|||||||
_anthropicClient = new AnthropicClient(settings.Value.ApiKey);
|
_anthropicClient = new AnthropicClient(settings.Value.ApiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<CVData> ParseAsync(Stream fileStream, string fileName)
|
public async Task<CVData> ParseAsync(Stream fileStream, string fileName, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(fileStream);
|
ArgumentNullException.ThrowIfNull(fileStream);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
|
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
|
||||||
|
|
||||||
_logger.LogDebug("Parsing CV file: {FileName}", fileName);
|
_logger.LogDebug("Parsing CV file: {FileName}", fileName);
|
||||||
|
|
||||||
var text = await ExtractTextAsync(fileStream, fileName);
|
var text = await ExtractTextAsync(fileStream, fileName, cancellationToken);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
{
|
{
|
||||||
@@ -97,7 +92,7 @@ public sealed class CVParserService : ICVParserService
|
|||||||
|
|
||||||
_logger.LogDebug("Extracted {CharCount} characters from {FileName}", text.Length, fileName);
|
_logger.LogDebug("Extracted {CharCount} characters from {FileName}", text.Length, fileName);
|
||||||
|
|
||||||
var cvData = await ParseWithClaudeAsync(text);
|
var cvData = await ParseWithClaudeAsync(text, cancellationToken);
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Successfully parsed CV for {FullName} with {EmploymentCount} employment entries and {EducationCount} education entries",
|
"Successfully parsed CV for {FullName} with {EmploymentCount} employment entries and {EducationCount} education entries",
|
||||||
@@ -108,23 +103,23 @@ public sealed class CVParserService : ICVParserService
|
|||||||
return cvData;
|
return cvData;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> ExtractTextAsync(Stream fileStream, string fileName)
|
private async Task<string> ExtractTextAsync(Stream fileStream, string fileName, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var extension = Path.GetExtension(fileName).ToLowerInvariant();
|
var extension = Path.GetExtension(fileName).ToLowerInvariant();
|
||||||
|
|
||||||
return extension switch
|
return extension switch
|
||||||
{
|
{
|
||||||
".pdf" => await ExtractTextFromPdfAsync(fileStream),
|
".pdf" => await ExtractTextFromPdfAsync(fileStream, cancellationToken),
|
||||||
".docx" => ExtractTextFromDocx(fileStream),
|
".docx" => ExtractTextFromDocx(fileStream),
|
||||||
_ => throw new NotSupportedException($"File type '{extension}' is not supported. Only PDF and DOCX files are accepted.")
|
_ => throw new NotSupportedException($"File type '{extension}' is not supported. Only PDF and DOCX files are accepted.")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> ExtractTextFromPdfAsync(Stream fileStream)
|
private async Task<string> ExtractTextFromPdfAsync(Stream fileStream, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// Copy stream to memory for PdfPig (requires seekable stream)
|
// Copy stream to memory for PdfPig (requires seekable stream)
|
||||||
using var memoryStream = new MemoryStream();
|
using var memoryStream = new MemoryStream();
|
||||||
await fileStream.CopyToAsync(memoryStream);
|
await fileStream.CopyToAsync(memoryStream, cancellationToken);
|
||||||
memoryStream.Position = 0;
|
memoryStream.Position = 0;
|
||||||
|
|
||||||
using var document = PdfDocument.Open(memoryStream);
|
using var document = PdfDocument.Open(memoryStream);
|
||||||
@@ -132,6 +127,7 @@ public sealed class CVParserService : ICVParserService
|
|||||||
|
|
||||||
foreach (var page in document.GetPages())
|
foreach (var page in document.GetPages())
|
||||||
{
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
var pageText = page.Text;
|
var pageText = page.Text;
|
||||||
textBuilder.AppendLine(pageText);
|
textBuilder.AppendLine(pageText);
|
||||||
}
|
}
|
||||||
@@ -163,7 +159,7 @@ public sealed class CVParserService : ICVParserService
|
|||||||
return textBuilder.ToString();
|
return textBuilder.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<CVData> ParseWithClaudeAsync(string cvText)
|
private async Task<CVData> ParseWithClaudeAsync(string cvText, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var prompt = ExtractionPrompt.Replace("{CV_TEXT}", cvText);
|
var prompt = ExtractionPrompt.Replace("{CV_TEXT}", cvText);
|
||||||
|
|
||||||
@@ -182,7 +178,7 @@ public sealed class CVParserService : ICVParserService
|
|||||||
|
|
||||||
_logger.LogDebug("Sending CV text to Claude API for parsing");
|
_logger.LogDebug("Sending CV text to Claude API for parsing");
|
||||||
|
|
||||||
var response = await _anthropicClient.Messages.GetClaudeMessageAsync(parameters);
|
var response = await _anthropicClient.Messages.GetClaudeMessageAsync(parameters, cancellationToken);
|
||||||
|
|
||||||
var responseText = response.Content
|
var responseText = response.Content
|
||||||
.OfType<TextContent>()
|
.OfType<TextContent>()
|
||||||
@@ -201,7 +197,7 @@ public sealed class CVParserService : ICVParserService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var parsedResponse = JsonSerializer.Deserialize<ClaudeCVResponse>(responseText, JsonOptions);
|
var parsedResponse = JsonSerializer.Deserialize<ClaudeCVResponse>(responseText, JsonDefaults.CamelCase);
|
||||||
|
|
||||||
if (parsedResponse is null)
|
if (parsedResponse is null)
|
||||||
{
|
{
|
||||||
@@ -251,8 +247,8 @@ public sealed class CVParserService : ICVParserService
|
|||||||
CompanyName = e.CompanyName ?? "Unknown Company",
|
CompanyName = e.CompanyName ?? "Unknown Company",
|
||||||
JobTitle = e.JobTitle ?? "Unknown Position",
|
JobTitle = e.JobTitle ?? "Unknown Position",
|
||||||
Location = e.Location,
|
Location = e.Location,
|
||||||
StartDate = ParseDate(e.StartDate),
|
StartDate = DateHelpers.ParseDate(e.StartDate),
|
||||||
EndDate = ParseDate(e.EndDate),
|
EndDate = DateHelpers.ParseDate(e.EndDate),
|
||||||
IsCurrent = e.IsCurrent ?? false,
|
IsCurrent = e.IsCurrent ?? false,
|
||||||
Description = e.Description
|
Description = e.Description
|
||||||
}).ToList() ?? [],
|
}).ToList() ?? [],
|
||||||
@@ -262,28 +258,13 @@ public sealed class CVParserService : ICVParserService
|
|||||||
Qualification = e.Qualification,
|
Qualification = e.Qualification,
|
||||||
Subject = e.Subject,
|
Subject = e.Subject,
|
||||||
Grade = e.Grade,
|
Grade = e.Grade,
|
||||||
StartDate = ParseDate(e.StartDate),
|
StartDate = DateHelpers.ParseDate(e.StartDate),
|
||||||
EndDate = ParseDate(e.EndDate)
|
EndDate = DateHelpers.ParseDate(e.EndDate)
|
||||||
}).ToList() ?? [],
|
}).ToList() ?? [],
|
||||||
Skills = response.Skills ?? []
|
Skills = response.Skills ?? []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DateOnly? ParseDate(string? dateString)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(dateString))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (DateOnly.TryParse(dateString, out var date))
|
|
||||||
{
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Internal DTOs for Claude response parsing
|
// Internal DTOs for Claude response parsing
|
||||||
private sealed record ClaudeCVResponse
|
private sealed record ClaudeCVResponse
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using FuzzySharp;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using TrueCV.Application.DTOs;
|
using TrueCV.Application.DTOs;
|
||||||
|
using TrueCV.Application.Helpers;
|
||||||
using TrueCV.Application.Interfaces;
|
using TrueCV.Application.Interfaces;
|
||||||
using TrueCV.Application.Models;
|
using TrueCV.Application.Models;
|
||||||
using TrueCV.Domain.Entities;
|
using TrueCV.Domain.Entities;
|
||||||
@@ -113,7 +114,7 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
|||||||
CompanyNumber = item.CompanyNumber,
|
CompanyNumber = item.CompanyNumber,
|
||||||
CompanyName = item.Title,
|
CompanyName = item.Title,
|
||||||
CompanyStatus = item.CompanyStatus ?? "Unknown",
|
CompanyStatus = item.CompanyStatus ?? "Unknown",
|
||||||
IncorporationDate = ParseDate(item.DateOfCreation),
|
IncorporationDate = DateHelpers.ParseDate(item.DateOfCreation),
|
||||||
AddressSnippet = item.AddressSnippet
|
AddressSnippet = item.AddressSnippet
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
@@ -166,8 +167,8 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
|||||||
{
|
{
|
||||||
existingCache.CompanyName = item.Title;
|
existingCache.CompanyName = item.Title;
|
||||||
existingCache.Status = item.CompanyStatus ?? "Unknown";
|
existingCache.Status = item.CompanyStatus ?? "Unknown";
|
||||||
existingCache.IncorporationDate = ParseDate(item.DateOfCreation);
|
existingCache.IncorporationDate = DateHelpers.ParseDate(item.DateOfCreation);
|
||||||
existingCache.DissolutionDate = ParseDate(item.DateOfCessation);
|
existingCache.DissolutionDate = DateHelpers.ParseDate(item.DateOfCessation);
|
||||||
existingCache.CachedAt = DateTime.UtcNow;
|
existingCache.CachedAt = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -177,8 +178,8 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
|||||||
CompanyNumber = item.CompanyNumber,
|
CompanyNumber = item.CompanyNumber,
|
||||||
CompanyName = item.Title,
|
CompanyName = item.Title,
|
||||||
Status = item.CompanyStatus ?? "Unknown",
|
Status = item.CompanyStatus ?? "Unknown",
|
||||||
IncorporationDate = ParseDate(item.DateOfCreation),
|
IncorporationDate = DateHelpers.ParseDate(item.DateOfCreation),
|
||||||
DissolutionDate = ParseDate(item.DateOfCessation),
|
DissolutionDate = DateHelpers.ParseDate(item.DateOfCessation),
|
||||||
CachedAt = DateTime.UtcNow
|
CachedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -230,18 +231,4 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DateOnly? ParseDate(string? dateString)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(dateString))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (DateOnly.TryParse(dateString, out var date))
|
|
||||||
{
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
267
src/TrueCV.Infrastructure/Services/EducationVerifierService.cs
Normal file
267
src/TrueCV.Infrastructure/Services/EducationVerifierService.cs
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
using TrueCV.Application.Data;
|
||||||
|
using TrueCV.Application.Interfaces;
|
||||||
|
using TrueCV.Application.Models;
|
||||||
|
|
||||||
|
namespace TrueCV.Infrastructure.Services;
|
||||||
|
|
||||||
|
public class EducationVerifierService : IEducationVerifierService
|
||||||
|
{
|
||||||
|
private const int MinimumDegreeYears = 1;
|
||||||
|
private const int MaximumDegreeYears = 8;
|
||||||
|
private const int MinimumGraduationAge = 18;
|
||||||
|
|
||||||
|
public EducationVerificationResult Verify(EducationEntry education)
|
||||||
|
{
|
||||||
|
var institution = education.Institution;
|
||||||
|
|
||||||
|
// Check for diploma mill first (highest priority flag)
|
||||||
|
if (DiplomaMills.IsDiplomaMill(institution))
|
||||||
|
{
|
||||||
|
return new EducationVerificationResult
|
||||||
|
{
|
||||||
|
ClaimedInstitution = institution,
|
||||||
|
Status = "DiplomaMill",
|
||||||
|
IsVerified = false,
|
||||||
|
IsDiplomaMill = true,
|
||||||
|
IsSuspicious = true,
|
||||||
|
VerificationNotes = "Institution is on the diploma mill blacklist",
|
||||||
|
ClaimedStartDate = education.StartDate,
|
||||||
|
ClaimedEndDate = education.EndDate,
|
||||||
|
DatesArePlausible = true,
|
||||||
|
ClaimedQualification = education.Qualification,
|
||||||
|
ClaimedSubject = education.Subject
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for suspicious patterns
|
||||||
|
if (DiplomaMills.HasSuspiciousPattern(institution))
|
||||||
|
{
|
||||||
|
return new EducationVerificationResult
|
||||||
|
{
|
||||||
|
ClaimedInstitution = institution,
|
||||||
|
Status = "Suspicious",
|
||||||
|
IsVerified = false,
|
||||||
|
IsDiplomaMill = false,
|
||||||
|
IsSuspicious = true,
|
||||||
|
VerificationNotes = "Institution name contains suspicious patterns common in diploma mills",
|
||||||
|
ClaimedStartDate = education.StartDate,
|
||||||
|
ClaimedEndDate = education.EndDate,
|
||||||
|
DatesArePlausible = true,
|
||||||
|
ClaimedQualification = education.Qualification,
|
||||||
|
ClaimedSubject = education.Subject
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a recognised UK institution
|
||||||
|
var officialName = UKInstitutions.GetOfficialName(institution);
|
||||||
|
if (officialName != null)
|
||||||
|
{
|
||||||
|
var (datesPlausible, dateNotes) = CheckDatePlausibility(education.StartDate, education.EndDate);
|
||||||
|
|
||||||
|
return new EducationVerificationResult
|
||||||
|
{
|
||||||
|
ClaimedInstitution = institution,
|
||||||
|
MatchedInstitution = officialName,
|
||||||
|
Status = "Recognised",
|
||||||
|
IsVerified = true,
|
||||||
|
IsDiplomaMill = false,
|
||||||
|
IsSuspicious = false,
|
||||||
|
VerificationNotes = institution.Equals(officialName, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? "Verified UK higher education institution"
|
||||||
|
: $"Matched to official name: {officialName}",
|
||||||
|
ClaimedStartDate = education.StartDate,
|
||||||
|
ClaimedEndDate = education.EndDate,
|
||||||
|
DatesArePlausible = datesPlausible,
|
||||||
|
DatePlausibilityNotes = dateNotes,
|
||||||
|
ClaimedQualification = education.Qualification,
|
||||||
|
ClaimedSubject = education.Subject
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not in our database - could be international or unrecognised
|
||||||
|
return new EducationVerificationResult
|
||||||
|
{
|
||||||
|
ClaimedInstitution = institution,
|
||||||
|
Status = "Unknown",
|
||||||
|
IsVerified = false,
|
||||||
|
IsDiplomaMill = false,
|
||||||
|
IsSuspicious = false,
|
||||||
|
VerificationNotes = "Institution not found in UK recognised institutions database. May be an international institution.",
|
||||||
|
ClaimedStartDate = education.StartDate,
|
||||||
|
ClaimedEndDate = education.EndDate,
|
||||||
|
DatesArePlausible = true,
|
||||||
|
ClaimedQualification = education.Qualification,
|
||||||
|
ClaimedSubject = education.Subject
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<EducationVerificationResult> VerifyAll(
|
||||||
|
List<EducationEntry> education,
|
||||||
|
List<EmploymentEntry>? employment = null)
|
||||||
|
{
|
||||||
|
var results = new List<EducationVerificationResult>();
|
||||||
|
|
||||||
|
foreach (var edu in education)
|
||||||
|
{
|
||||||
|
var result = Verify(edu);
|
||||||
|
|
||||||
|
// If we have employment data, check for timeline issues
|
||||||
|
if (employment?.Count > 0 && result.ClaimedEndDate.HasValue)
|
||||||
|
{
|
||||||
|
var (timelinePlausible, timelineNotes) = CheckEducationEmploymentTimeline(
|
||||||
|
result.ClaimedEndDate.Value,
|
||||||
|
employment);
|
||||||
|
|
||||||
|
if (!timelinePlausible)
|
||||||
|
{
|
||||||
|
result = result with
|
||||||
|
{
|
||||||
|
DatesArePlausible = false,
|
||||||
|
DatePlausibilityNotes = CombineNotes(result.DatePlausibilityNotes, timelineNotes)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.Add(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for overlapping education periods
|
||||||
|
CheckOverlappingEducation(results);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (bool isPlausible, string? notes) CheckDatePlausibility(DateOnly? startDate, DateOnly? endDate)
|
||||||
|
{
|
||||||
|
if (!startDate.HasValue || !endDate.HasValue)
|
||||||
|
{
|
||||||
|
return (true, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var start = startDate.Value;
|
||||||
|
var end = endDate.Value;
|
||||||
|
|
||||||
|
// End date should be after start date
|
||||||
|
if (end <= start)
|
||||||
|
{
|
||||||
|
return (false, "End date is before or equal to start date");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check course duration is reasonable
|
||||||
|
var years = (end.ToDateTime(TimeOnly.MinValue) - start.ToDateTime(TimeOnly.MinValue)).TotalDays / 365.25;
|
||||||
|
|
||||||
|
if (years < MinimumDegreeYears)
|
||||||
|
{
|
||||||
|
return (false, $"Course duration ({years:F1} years) is unusually short for a degree");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (years > MaximumDegreeYears)
|
||||||
|
{
|
||||||
|
return (false, $"Course duration ({years:F1} years) is unusually long");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if graduation date is in the future
|
||||||
|
if (end > DateOnly.FromDateTime(DateTime.UtcNow))
|
||||||
|
{
|
||||||
|
return (true, "Graduation date is in the future - possibly currently studying");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (true, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (bool isPlausible, string? notes) CheckEducationEmploymentTimeline(
|
||||||
|
DateOnly graduationDate,
|
||||||
|
List<EmploymentEntry> employment)
|
||||||
|
{
|
||||||
|
// Find the earliest employment start date
|
||||||
|
var earliestEmployment = employment
|
||||||
|
.Where(e => e.StartDate.HasValue)
|
||||||
|
.OrderBy(e => e.StartDate)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (earliestEmployment?.StartDate == null)
|
||||||
|
{
|
||||||
|
return (true, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var employmentStart = earliestEmployment.StartDate.Value;
|
||||||
|
|
||||||
|
// If someone claims to have started full-time work significantly before graduating,
|
||||||
|
// that's suspicious (unless it's clearly an internship/part-time role)
|
||||||
|
var monthsBeforeGraduation = (graduationDate.ToDateTime(TimeOnly.MinValue) -
|
||||||
|
employmentStart.ToDateTime(TimeOnly.MinValue)).TotalDays / 30;
|
||||||
|
|
||||||
|
if (monthsBeforeGraduation > 24) // More than 2 years before graduation
|
||||||
|
{
|
||||||
|
var isLikelyInternship = earliestEmployment.JobTitle.Contains("intern", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
earliestEmployment.JobTitle.Contains("placement", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
earliestEmployment.JobTitle.Contains("trainee", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (!isLikelyInternship)
|
||||||
|
{
|
||||||
|
return (false, $"Employment at {earliestEmployment.CompanyName} started {monthsBeforeGraduation:F0} months before claimed graduation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (true, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CheckOverlappingEducation(List<EducationVerificationResult> results)
|
||||||
|
{
|
||||||
|
var datedResults = results
|
||||||
|
.Where(r => r.ClaimedStartDate.HasValue && r.ClaimedEndDate.HasValue)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
for (var i = 0; i < datedResults.Count; i++)
|
||||||
|
{
|
||||||
|
for (var j = i + 1; j < datedResults.Count; j++)
|
||||||
|
{
|
||||||
|
var edu1 = datedResults[i];
|
||||||
|
var edu2 = datedResults[j];
|
||||||
|
|
||||||
|
if (PeriodsOverlap(
|
||||||
|
edu1.ClaimedStartDate!.Value, edu1.ClaimedEndDate!.Value,
|
||||||
|
edu2.ClaimedStartDate!.Value, edu2.ClaimedEndDate!.Value))
|
||||||
|
{
|
||||||
|
// Find the actual index in the original results list
|
||||||
|
var idx1 = results.IndexOf(edu1);
|
||||||
|
var idx2 = results.IndexOf(edu2);
|
||||||
|
|
||||||
|
if (idx1 >= 0)
|
||||||
|
{
|
||||||
|
results[idx1] = edu1 with
|
||||||
|
{
|
||||||
|
DatePlausibilityNotes = CombineNotes(
|
||||||
|
edu1.DatePlausibilityNotes,
|
||||||
|
$"Overlaps with education at {edu2.ClaimedInstitution}")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idx2 >= 0)
|
||||||
|
{
|
||||||
|
results[idx2] = edu2 with
|
||||||
|
{
|
||||||
|
DatePlausibilityNotes = CombineNotes(
|
||||||
|
edu2.DatePlausibilityNotes,
|
||||||
|
$"Overlaps with education at {edu1.ClaimedInstitution}")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool PeriodsOverlap(DateOnly start1, DateOnly end1, DateOnly start2, DateOnly end2)
|
||||||
|
{
|
||||||
|
return start1 < end2 && start2 < end1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? CombineNotes(string? existing, string? additional)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(additional))
|
||||||
|
return existing;
|
||||||
|
if (string.IsNullOrEmpty(existing))
|
||||||
|
return additional;
|
||||||
|
return $"{existing}; {additional}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,11 +68,15 @@ public sealed class FileStorageService : IFileStorageService
|
|||||||
|
|
||||||
var blobClient = _containerClient.GetBlobClient(blobName);
|
var blobClient = _containerClient.GetBlobClient(blobName);
|
||||||
|
|
||||||
var response = await blobClient.DownloadStreamingAsync();
|
// Download to memory stream to ensure proper resource management
|
||||||
|
// The caller will own and dispose this stream
|
||||||
|
var memoryStream = new MemoryStream();
|
||||||
|
await blobClient.DownloadToAsync(memoryStream);
|
||||||
|
memoryStream.Position = 0;
|
||||||
|
|
||||||
_logger.LogDebug("Successfully downloaded blob {BlobName}", blobName);
|
_logger.LogDebug("Successfully downloaded blob {BlobName}", blobName);
|
||||||
|
|
||||||
return response.Value.Content;
|
return memoryStream;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(string blobUrl)
|
public async Task DeleteAsync(string blobUrl)
|
||||||
@@ -99,12 +103,21 @@ public sealed class FileStorageService : IFileStorageService
|
|||||||
|
|
||||||
private static string ExtractBlobNameFromUrl(string blobUrl)
|
private static string ExtractBlobNameFromUrl(string blobUrl)
|
||||||
{
|
{
|
||||||
var uri = new Uri(blobUrl);
|
if (!Uri.TryCreate(blobUrl, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Invalid blob URL format: '{blobUrl}'", nameof(blobUrl));
|
||||||
|
}
|
||||||
|
|
||||||
var segments = uri.Segments;
|
var segments = uri.Segments;
|
||||||
|
|
||||||
// The blob name is the last segment after the container name
|
// The blob name is the last segment after the container name
|
||||||
// URL format: https://account.blob.core.windows.net/container/blobname
|
// URL format: https://account.blob.core.windows.net/container/blobname
|
||||||
return segments.Length > 2 ? segments[^1] : throw new ArgumentException("Invalid blob URL", nameof(blobUrl));
|
if (segments.Length <= 2)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Blob URL does not contain a valid blob name: '{blobUrl}'", nameof(blobUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments[^1];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetContentType(string extension)
|
private static string GetContentType(string extension)
|
||||||
|
|||||||
117
src/TrueCV.Infrastructure/Services/LocalFileStorageService.cs
Normal file
117
src/TrueCV.Infrastructure/Services/LocalFileStorageService.cs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using TrueCV.Application.Interfaces;
|
||||||
|
using TrueCV.Infrastructure.Configuration;
|
||||||
|
|
||||||
|
namespace TrueCV.Infrastructure.Services;
|
||||||
|
|
||||||
|
public sealed class LocalFileStorageService : IFileStorageService
|
||||||
|
{
|
||||||
|
private readonly string _storagePath;
|
||||||
|
private readonly ILogger<LocalFileStorageService> _logger;
|
||||||
|
|
||||||
|
public LocalFileStorageService(
|
||||||
|
IOptions<LocalStorageSettings> settings,
|
||||||
|
ILogger<LocalFileStorageService> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_storagePath = settings.Value.StoragePath;
|
||||||
|
|
||||||
|
if (!Directory.Exists(_storagePath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_storagePath);
|
||||||
|
_logger.LogInformation("Created local storage directory: {Path}", _storagePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> UploadAsync(Stream fileStream, string fileName)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(fileStream);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
|
||||||
|
|
||||||
|
var extension = Path.GetExtension(fileName);
|
||||||
|
var uniqueFileName = $"{Guid.NewGuid()}{extension}";
|
||||||
|
var filePath = Path.Combine(_storagePath, uniqueFileName);
|
||||||
|
|
||||||
|
_logger.LogDebug("Uploading file {FileName} to {FilePath}", fileName, filePath);
|
||||||
|
|
||||||
|
await using var fileStreamOut = new FileStream(filePath, FileMode.Create, FileAccess.Write);
|
||||||
|
await fileStream.CopyToAsync(fileStreamOut);
|
||||||
|
|
||||||
|
// Return a file:// URL for local storage
|
||||||
|
var fileUrl = $"file://{filePath}";
|
||||||
|
|
||||||
|
_logger.LogInformation("Successfully uploaded file {FileName} to {FileUrl}", fileName, fileUrl);
|
||||||
|
|
||||||
|
return fileUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Stream> DownloadAsync(string blobUrl)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(blobUrl);
|
||||||
|
|
||||||
|
var filePath = ExtractFilePathFromUrl(blobUrl);
|
||||||
|
|
||||||
|
_logger.LogDebug("Downloading file from {FilePath}", filePath);
|
||||||
|
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException($"File not found: {filePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var memoryStream = new MemoryStream();
|
||||||
|
await using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
|
||||||
|
await fileStream.CopyToAsync(memoryStream);
|
||||||
|
memoryStream.Position = 0;
|
||||||
|
|
||||||
|
_logger.LogDebug("Successfully downloaded file from {FilePath}", filePath);
|
||||||
|
|
||||||
|
return memoryStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DeleteAsync(string blobUrl)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(blobUrl);
|
||||||
|
|
||||||
|
var filePath = ExtractFilePathFromUrl(blobUrl);
|
||||||
|
|
||||||
|
_logger.LogDebug("Deleting file {FilePath}", filePath);
|
||||||
|
|
||||||
|
if (File.Exists(filePath))
|
||||||
|
{
|
||||||
|
File.Delete(filePath);
|
||||||
|
_logger.LogInformation("Successfully deleted file {FilePath}", filePath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("File {FilePath} did not exist when attempting to delete", filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ExtractFilePathFromUrl(string fileUrl)
|
||||||
|
{
|
||||||
|
string filePath;
|
||||||
|
|
||||||
|
if (fileUrl.StartsWith("file://", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
filePath = fileUrl[7..];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
filePath = fileUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve to absolute path and validate it's within storage directory
|
||||||
|
var fullPath = Path.GetFullPath(filePath);
|
||||||
|
var storagePath = Path.GetFullPath(_storagePath);
|
||||||
|
|
||||||
|
if (!fullPath.StartsWith(storagePath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new UnauthorizedAccessException($"Access denied: path is outside storage directory");
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/TrueCV.Infrastructure/Services/UserContextService.cs
Normal file
28
src/TrueCV.Infrastructure/Services/UserContextService.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using TrueCV.Application.Interfaces;
|
||||||
|
|
||||||
|
namespace TrueCV.Infrastructure.Services;
|
||||||
|
|
||||||
|
public sealed class UserContextService : IUserContextService
|
||||||
|
{
|
||||||
|
private readonly AuthenticationStateProvider _authenticationStateProvider;
|
||||||
|
|
||||||
|
public UserContextService(AuthenticationStateProvider authenticationStateProvider)
|
||||||
|
{
|
||||||
|
_authenticationStateProvider = authenticationStateProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Guid?> GetCurrentUserIdAsync()
|
||||||
|
{
|
||||||
|
var authState = await _authenticationStateProvider.GetAuthenticationStateAsync();
|
||||||
|
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
@page "/account/login"
|
@page "/account/login"
|
||||||
@using TrueCV.Web.Components.Layout
|
@using TrueCV.Web.Components.Layout
|
||||||
@layout MainLayout
|
@layout MainLayout
|
||||||
@rendermode InteractiveServer
|
|
||||||
|
|
||||||
@using Microsoft.AspNetCore.Identity
|
@using Microsoft.AspNetCore.Identity
|
||||||
@using TrueCV.Infrastructure.Identity
|
@using TrueCV.Infrastructure.Identity
|
||||||
@@ -26,50 +25,40 @@
|
|||||||
|
|
||||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||||
{
|
{
|
||||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
<div class="alert alert-danger" role="alert">
|
||||||
@_errorMessage
|
@_errorMessage
|
||||||
<button type="button" class="btn-close" @onclick="() => _errorMessage = null" aria-label="Close"></button>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<EditForm Model="_model" OnValidSubmit="HandleLogin" FormName="login">
|
<form method="post" action="/account/perform-login">
|
||||||
<DataAnnotationsValidator />
|
<AntiforgeryToken />
|
||||||
|
<input type="hidden" name="returnUrl" value="@ReturnUrl" />
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="email" class="form-label">Email address</label>
|
<label for="email" class="form-label">Email address</label>
|
||||||
<InputText id="email" class="form-control form-control-lg" @bind-Value="_model.Email"
|
<input id="email" name="email" type="email" class="form-control form-control-lg"
|
||||||
placeholder="name@example.com" />
|
placeholder="name@example.com" required />
|
||||||
<ValidationMessage For="() => _model.Email" class="text-danger" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="password" class="form-label">Password</label>
|
<label for="password" class="form-label">Password</label>
|
||||||
<InputText id="password" type="password" class="form-control form-control-lg"
|
<input id="password" name="password" type="password" class="form-control form-control-lg"
|
||||||
@bind-Value="_model.Password" placeholder="Enter your password" />
|
placeholder="Enter your password" required />
|
||||||
<ValidationMessage For="() => _model.Password" class="text-danger" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3 form-check">
|
<div class="mb-3 form-check">
|
||||||
<InputCheckbox id="rememberMe" class="form-check-input" @bind-Value="_model.RememberMe" />
|
<input id="rememberMe" name="rememberMe" type="checkbox" class="form-check-input" value="true" />
|
||||||
<label class="form-check-label" for="rememberMe">
|
<label class="form-check-label" for="rememberMe">
|
||||||
Remember me
|
Remember me
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-grid">
|
<div class="d-grid">
|
||||||
<button type="submit" class="btn btn-primary btn-lg" disabled="@_isLoading">
|
<button type="submit" class="btn btn-primary btn-lg">
|
||||||
@if (_isLoading)
|
Sign In
|
||||||
{
|
|
||||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
|
||||||
<span>Signing in...</span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<span>Sign In</span>
|
|
||||||
}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</EditForm>
|
</form>
|
||||||
|
|
||||||
<hr class="my-4" />
|
<hr class="my-4" />
|
||||||
|
|
||||||
@@ -86,63 +75,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private LoginModel _model = new();
|
|
||||||
private bool _isLoading;
|
|
||||||
private string? _errorMessage;
|
private string? _errorMessage;
|
||||||
|
|
||||||
[SupplyParameterFromQuery]
|
[SupplyParameterFromQuery]
|
||||||
public string? ReturnUrl { get; set; }
|
public string? ReturnUrl { get; set; }
|
||||||
|
|
||||||
private async Task HandleLogin()
|
[SupplyParameterFromQuery(Name = "error")]
|
||||||
{
|
public string? Error { get; set; }
|
||||||
_isLoading = true;
|
|
||||||
_errorMessage = null;
|
|
||||||
|
|
||||||
try
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
var result = await SignInManager.PasswordSignInAsync(
|
_errorMessage = Error;
|
||||||
_model.Email,
|
|
||||||
_model.Password,
|
|
||||||
_model.RememberMe,
|
|
||||||
lockoutOnFailure: false);
|
|
||||||
|
|
||||||
if (result.Succeeded)
|
|
||||||
{
|
|
||||||
var returnUrl = string.IsNullOrEmpty(ReturnUrl) ? "/dashboard" : ReturnUrl;
|
|
||||||
NavigationManager.NavigateTo(returnUrl, forceLoad: true);
|
|
||||||
}
|
|
||||||
else if (result.IsLockedOut)
|
|
||||||
{
|
|
||||||
_errorMessage = "This account has been locked out. Please try again later.";
|
|
||||||
}
|
|
||||||
else if (result.IsNotAllowed)
|
|
||||||
{
|
|
||||||
_errorMessage = "This account is not allowed to sign in.";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_errorMessage = "Invalid email or password.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_errorMessage = $"An error occurred: {ex.Message}";
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class LoginModel
|
|
||||||
{
|
|
||||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Email is required")]
|
|
||||||
[System.ComponentModel.DataAnnotations.EmailAddress(ErrorMessage = "Invalid email format")]
|
|
||||||
public string Email { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Password is required")]
|
|
||||||
public string Password { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public bool RememberMe { get; set; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
<InputText id="password" type="password" class="form-control form-control-lg"
|
<InputText id="password" type="password" class="form-control form-control-lg"
|
||||||
@bind-Value="_model.Password" placeholder="Create a password" />
|
@bind-Value="_model.Password" placeholder="Create a password" />
|
||||||
<ValidationMessage For="() => _model.Password" class="text-danger" />
|
<ValidationMessage For="() => _model.Password" class="text-danger" />
|
||||||
<div class="form-text">Password must be at least 6 characters.</div>
|
<div class="form-text">Password must be at least 12 characters with uppercase, lowercase, number, and symbol.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
@@ -153,7 +153,7 @@
|
|||||||
public string Email { get; set; } = string.Empty;
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Password is required")]
|
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Password is required")]
|
||||||
[System.ComponentModel.DataAnnotations.MinLength(6, ErrorMessage = "Password must be at least 6 characters")]
|
[System.ComponentModel.DataAnnotations.MinLength(12, ErrorMessage = "Password must be at least 12 characters")]
|
||||||
public string Password { get; set; } = string.Empty;
|
public string Password { get; set; } = string.Empty;
|
||||||
|
|
||||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Please confirm your password")]
|
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Please confirm your password")]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
@inject ICVCheckService CVCheckService
|
@inject ICVCheckService CVCheckService
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||||
|
@inject ILogger<Check> Logger
|
||||||
|
|
||||||
<PageTitle>Upload CV - TrueCV</PageTitle>
|
<PageTitle>Upload CV - TrueCV</PageTitle>
|
||||||
|
|
||||||
@@ -145,6 +146,10 @@
|
|||||||
|
|
||||||
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
|
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
|
||||||
|
|
||||||
|
// Magic bytes for file type validation
|
||||||
|
private static readonly byte[] PdfMagicBytes = [0x25, 0x50, 0x44, 0x46]; // %PDF
|
||||||
|
private static readonly byte[] DocxMagicBytes = [0x50, 0x4B, 0x03, 0x04]; // PK.. (ZIP signature)
|
||||||
|
|
||||||
private void HandleDragEnter()
|
private void HandleDragEnter()
|
||||||
{
|
{
|
||||||
_isDragging = true;
|
_isDragging = true;
|
||||||
@@ -186,10 +191,15 @@
|
|||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private CancellationTokenSource? _progressCts;
|
||||||
|
|
||||||
private async Task UploadFile()
|
private async Task UploadFile()
|
||||||
{
|
{
|
||||||
if (_selectedFile is null) return;
|
if (_selectedFile is null) return;
|
||||||
|
|
||||||
|
_progressCts = new CancellationTokenSource();
|
||||||
|
Task? progressTask = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_isUploading = true;
|
_isUploading = true;
|
||||||
@@ -207,36 +217,82 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Simulate progress for better UX
|
// Simulate progress for better UX
|
||||||
var progressTask = SimulateProgress();
|
progressTask = SimulateProgress(_progressCts.Token);
|
||||||
|
|
||||||
await using var stream = _selectedFile.OpenReadStream(MaxFileSize);
|
await using var stream = _selectedFile.OpenReadStream(MaxFileSize);
|
||||||
using var memoryStream = new MemoryStream();
|
using var memoryStream = new MemoryStream();
|
||||||
await stream.CopyToAsync(memoryStream);
|
await stream.CopyToAsync(memoryStream);
|
||||||
memoryStream.Position = 0;
|
memoryStream.Position = 0;
|
||||||
|
|
||||||
|
// Validate file content (magic bytes)
|
||||||
|
if (!await ValidateFileContentAsync(memoryStream, _selectedFile.Name))
|
||||||
|
{
|
||||||
|
_errorMessage = "Invalid file content. The file appears to be corrupted or not a valid PDF/DOCX.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var checkId = await CVCheckService.CreateCheckAsync(userId, memoryStream, _selectedFile.Name);
|
var checkId = await CVCheckService.CreateCheckAsync(userId, memoryStream, _selectedFile.Name);
|
||||||
|
|
||||||
_uploadProgress = 100;
|
_uploadProgress = 100;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
await Task.Delay(500); // Brief pause to show completion
|
await Task.Delay(500); // Brief pause to show completion
|
||||||
|
|
||||||
NavigationManager.NavigateTo($"/report/{checkId}");
|
NavigationManager.NavigateTo($"/report/{checkId}");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_errorMessage = $"An error occurred while uploading: {ex.Message}";
|
Logger.LogError(ex, "Error uploading CV");
|
||||||
|
_errorMessage = "An error occurred while uploading. Please try again.";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
_isUploading = false;
|
_isUploading = false;
|
||||||
|
_progressCts?.Cancel();
|
||||||
|
if (progressTask is not null)
|
||||||
|
{
|
||||||
|
try { await progressTask; } catch (OperationCanceledException) { }
|
||||||
|
}
|
||||||
|
_progressCts?.Dispose();
|
||||||
|
_progressCts = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SimulateProgress()
|
private async Task SimulateProgress(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
while (_uploadProgress < 90 && _isUploading)
|
try
|
||||||
{
|
{
|
||||||
await Task.Delay(200);
|
while (_uploadProgress < 90 && _isUploading && !cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
await Task.Delay(200, cancellationToken);
|
||||||
_uploadProgress += 10;
|
_uploadProgress += 10;
|
||||||
StateHasChanged();
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Expected when upload completes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> ValidateFileContentAsync(MemoryStream stream, string fileName)
|
||||||
|
{
|
||||||
|
var extension = Path.GetExtension(fileName).ToLowerInvariant();
|
||||||
|
var header = new byte[4];
|
||||||
|
|
||||||
|
stream.Position = 0;
|
||||||
|
var bytesRead = await stream.ReadAsync(header.AsMemory(0, 4));
|
||||||
|
stream.Position = 0;
|
||||||
|
|
||||||
|
if (bytesRead < 4)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return extension switch
|
||||||
|
{
|
||||||
|
".pdf" => header.AsSpan().StartsWith(PdfMagicBytes),
|
||||||
|
".docx" => header.AsSpan().StartsWith(DocxMagicBytes),
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private bool IsValidFileType(string fileName)
|
private bool IsValidFileType(string fileName)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
@inject ICVCheckService CVCheckService
|
@inject ICVCheckService CVCheckService
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||||
|
@inject ILogger<Dashboard> Logger
|
||||||
|
|
||||||
<PageTitle>Dashboard - TrueCV</PageTitle>
|
<PageTitle>Dashboard - TrueCV</PageTitle>
|
||||||
|
|
||||||
@@ -255,7 +256,8 @@
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_errorMessage = $"An error occurred while loading checks: {ex.Message}";
|
Logger.LogError(ex, "Error loading CV checks");
|
||||||
|
_errorMessage = "An error occurred while loading checks. Please try again.";
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
@inject ICVCheckService CVCheckService
|
@inject ICVCheckService CVCheckService
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||||
|
@inject ILogger<Report> Logger
|
||||||
|
|
||||||
<PageTitle>Verification Report - TrueCV</PageTitle>
|
<PageTitle>Verification Report - TrueCV</PageTitle>
|
||||||
|
|
||||||
@@ -509,7 +510,8 @@
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_errorMessage = $"An error occurred: {ex.Message}";
|
Logger.LogError(ex, "Error loading report data");
|
||||||
|
_errorMessage = "An error occurred while loading the report. Please try again.";
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Forms
|
|
||||||
|
|
||||||
<div class="cv-uploader @(_isDragOver ? "drag-over" : "")"
|
|
||||||
@ondragenter="HandleDragEnter"
|
|
||||||
@ondragenter:preventDefault
|
|
||||||
@ondragleave="HandleDragLeave"
|
|
||||||
@ondragleave:preventDefault
|
|
||||||
@ondragover:preventDefault
|
|
||||||
@ondrop="HandleDrop"
|
|
||||||
@ondrop:preventDefault>
|
|
||||||
|
|
||||||
<InputFile OnChange="HandleFileSelected"
|
|
||||||
accept=".pdf,.docx"
|
|
||||||
class="cv-uploader-input"
|
|
||||||
id="cv-file-input" />
|
|
||||||
|
|
||||||
<label for="cv-file-input" class="cv-uploader-label">
|
|
||||||
@if (string.IsNullOrEmpty(_selectedFileName))
|
|
||||||
{
|
|
||||||
<div class="cv-uploader-icon">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-cloud-arrow-up" viewBox="0 0 16 16">
|
|
||||||
<path fill-rule="evenodd" d="M7.646 5.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 6.707V10.5a.5.5 0 0 1-1 0V6.707L6.354 7.854a.5.5 0 1 1-.708-.708z"/>
|
|
||||||
<path d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383m.653.757c-.757.653-1.153 1.44-1.153 2.056v.448l-.445.049C2.064 6.805 1 7.952 1 9.318 1 10.785 2.23 12 3.781 12h8.906C13.98 12 15 10.988 15 9.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 4.825 10.328 3 8 3a4.53 4.53 0 0 0-2.941 1.1z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="cv-uploader-text">
|
|
||||||
<span class="cv-uploader-title">Drag and drop your CV here</span>
|
|
||||||
<span class="cv-uploader-subtitle">or click to browse</span>
|
|
||||||
<span class="cv-uploader-hint">Accepts .pdf and .docx files (max 10MB)</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="cv-uploader-icon text-success">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-file-earmark-check" viewBox="0 0 16 16">
|
|
||||||
<path d="M10.854 7.854a.5.5 0 0 0-.708-.708L7.5 9.793 6.354 8.646a.5.5 0 1 0-.708.708l1.5 1.5a.5.5 0 0 0 .708 0z"/>
|
|
||||||
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2M9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="cv-uploader-text">
|
|
||||||
<span class="cv-uploader-title text-success">File selected</span>
|
|
||||||
<span class="cv-uploader-filename">@_selectedFileName</span>
|
|
||||||
<span class="cv-uploader-hint">Click or drag to replace</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
|
||||||
{
|
|
||||||
<div class="alert alert-danger mt-3 mb-0" role="alert">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-exclamation-triangle-fill me-2" viewBox="0 0 16 16">
|
|
||||||
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5m.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2"/>
|
|
||||||
</svg>
|
|
||||||
@_errorMessage
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.cv-uploader {
|
|
||||||
position: relative;
|
|
||||||
border: 2px dashed #dee2e6;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
transition: all 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cv-uploader:hover,
|
|
||||||
.cv-uploader.drag-over {
|
|
||||||
border-color: #0d6efd;
|
|
||||||
background-color: #e7f1ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cv-uploader.drag-over {
|
|
||||||
transform: scale(1.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cv-uploader-input {
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
opacity: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cv-uploader-label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cv-uploader-icon {
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cv-uploader.drag-over .cv-uploader-icon {
|
|
||||||
color: #0d6efd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cv-uploader-text {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cv-uploader-title {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #212529;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cv-uploader-subtitle {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cv-uploader-hint {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #adb5bd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cv-uploader-filename {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #495057;
|
|
||||||
font-weight: 500;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private const long MaxFileSizeBytes = 10 * 1024 * 1024; // 10MB
|
|
||||||
private static readonly string[] AllowedExtensions = [".pdf", ".docx"];
|
|
||||||
|
|
||||||
private bool _isDragOver;
|
|
||||||
private string? _selectedFileName;
|
|
||||||
private string? _errorMessage;
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public EventCallback<IBrowserFile> OnFileSelected { get; set; }
|
|
||||||
|
|
||||||
private void HandleDragEnter()
|
|
||||||
{
|
|
||||||
_isDragOver = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleDragLeave()
|
|
||||||
{
|
|
||||||
_isDragOver = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleDrop()
|
|
||||||
{
|
|
||||||
_isDragOver = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleFileSelected(InputFileChangeEventArgs e)
|
|
||||||
{
|
|
||||||
_errorMessage = null;
|
|
||||||
_selectedFileName = null;
|
|
||||||
|
|
||||||
var file = e.File;
|
|
||||||
if (file is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var extension = Path.GetExtension(file.Name).ToLowerInvariant();
|
|
||||||
if (!AllowedExtensions.Contains(extension))
|
|
||||||
{
|
|
||||||
_errorMessage = "Invalid file type. Please upload a .pdf or .docx file.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.Size > MaxFileSizeBytes)
|
|
||||||
{
|
|
||||||
_errorMessage = "File size exceeds 10MB limit. Please upload a smaller file.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_selectedFileName = file.Name;
|
|
||||||
await OnFileSelected.InvokeAsync(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Web
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
|
@using Microsoft.Extensions.Logging
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using TrueCV.Web
|
@using TrueCV.Web
|
||||||
@using TrueCV.Web.Components
|
@using TrueCV.Web.Components
|
||||||
|
|||||||
@@ -32,15 +32,19 @@ try
|
|||||||
// Add Infrastructure services (DbContext, Hangfire, HttpClients, Services)
|
// Add Infrastructure services (DbContext, Hangfire, HttpClients, Services)
|
||||||
builder.Services.AddInfrastructure(builder.Configuration);
|
builder.Services.AddInfrastructure(builder.Configuration);
|
||||||
|
|
||||||
// Add Identity
|
// Add Identity with secure password requirements
|
||||||
builder.Services.AddIdentity<ApplicationUser, IdentityRole<Guid>>(options =>
|
builder.Services.AddIdentity<ApplicationUser, IdentityRole<Guid>>(options =>
|
||||||
{
|
{
|
||||||
options.Password.RequireDigit = false;
|
options.Password.RequireDigit = true;
|
||||||
options.Password.RequireLowercase = false;
|
options.Password.RequireLowercase = true;
|
||||||
options.Password.RequireUppercase = false;
|
options.Password.RequireUppercase = true;
|
||||||
options.Password.RequireNonAlphanumeric = false;
|
options.Password.RequireNonAlphanumeric = true;
|
||||||
options.Password.RequiredLength = 6;
|
options.Password.RequiredLength = 12;
|
||||||
|
options.Password.RequiredUniqueChars = 4;
|
||||||
options.SignIn.RequireConfirmedAccount = false;
|
options.SignIn.RequireConfirmedAccount = false;
|
||||||
|
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
|
||||||
|
options.Lockout.MaxFailedAccessAttempts = 5;
|
||||||
|
options.Lockout.AllowedForNewUsers = true;
|
||||||
})
|
})
|
||||||
.AddEntityFrameworkStores<ApplicationDbContext>()
|
.AddEntityFrameworkStores<ApplicationDbContext>()
|
||||||
.AddDefaultTokenProviders();
|
.AddDefaultTokenProviders();
|
||||||
@@ -62,6 +66,26 @@ try
|
|||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Seed default admin user
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||||
|
var defaultEmail = "admin@truecv.local";
|
||||||
|
var defaultPassword = "TrueCV_Admin123!";
|
||||||
|
|
||||||
|
if (await userManager.FindByEmailAsync(defaultEmail) == null)
|
||||||
|
{
|
||||||
|
var adminUser = new ApplicationUser
|
||||||
|
{
|
||||||
|
UserName = defaultEmail,
|
||||||
|
Email = defaultEmail,
|
||||||
|
EmailConfirmed = true
|
||||||
|
};
|
||||||
|
await userManager.CreateAsync(adminUser, defaultPassword);
|
||||||
|
Log.Information("Created default admin user: {Email}", defaultEmail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline.
|
||||||
if (!app.Environment.IsDevelopment())
|
if (!app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
@@ -98,6 +122,44 @@ try
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Login endpoint
|
||||||
|
app.MapPost("/account/perform-login", async (
|
||||||
|
HttpContext context,
|
||||||
|
SignInManager<ApplicationUser> signInManager) =>
|
||||||
|
{
|
||||||
|
var form = await context.Request.ReadFormAsync();
|
||||||
|
var email = form["email"].ToString();
|
||||||
|
var password = form["password"].ToString();
|
||||||
|
var rememberMe = form["rememberMe"].ToString() == "true";
|
||||||
|
var returnUrl = form["returnUrl"].ToString();
|
||||||
|
|
||||||
|
Log.Information("Login attempt for {Email}", email);
|
||||||
|
|
||||||
|
// Validate returnUrl is local to prevent open redirect attacks
|
||||||
|
if (string.IsNullOrEmpty(returnUrl) || !Uri.IsWellFormedUriString(returnUrl, UriKind.Relative) || returnUrl.StartsWith("//"))
|
||||||
|
{
|
||||||
|
returnUrl = "/dashboard";
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await signInManager.PasswordSignInAsync(email, password, rememberMe, lockoutOnFailure: true);
|
||||||
|
|
||||||
|
if (result.Succeeded)
|
||||||
|
{
|
||||||
|
Log.Information("User {Email} logged in successfully", email);
|
||||||
|
return Results.LocalRedirect(returnUrl);
|
||||||
|
}
|
||||||
|
else if (result.IsLockedOut)
|
||||||
|
{
|
||||||
|
Log.Warning("User {Email} account is locked out", email);
|
||||||
|
return Results.Redirect("/account/login?error=Account+locked.+Try+again+later.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log.Warning("Failed login attempt for {Email}", email);
|
||||||
|
return Results.Redirect("/account/login?error=Invalid+email+or+password.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Logout endpoint
|
// Logout endpoint
|
||||||
app.MapPost("/account/logout", async (SignInManager<ApplicationUser> signInManager) =>
|
app.MapPost("/account/logout", async (SignInManager<ApplicationUser> signInManager) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public sealed class ProcessCVCheckJobTests : IDisposable
|
|||||||
private readonly Mock<IFileStorageService> _fileStorageServiceMock;
|
private readonly Mock<IFileStorageService> _fileStorageServiceMock;
|
||||||
private readonly Mock<ICVParserService> _cvParserServiceMock;
|
private readonly Mock<ICVParserService> _cvParserServiceMock;
|
||||||
private readonly Mock<ICompanyVerifierService> _companyVerifierServiceMock;
|
private readonly Mock<ICompanyVerifierService> _companyVerifierServiceMock;
|
||||||
|
private readonly Mock<IEducationVerifierService> _educationVerifierServiceMock;
|
||||||
private readonly Mock<ITimelineAnalyserService> _timelineAnalyserServiceMock;
|
private readonly Mock<ITimelineAnalyserService> _timelineAnalyserServiceMock;
|
||||||
private readonly Mock<ILogger<ProcessCVCheckJob>> _loggerMock;
|
private readonly Mock<ILogger<ProcessCVCheckJob>> _loggerMock;
|
||||||
private readonly ProcessCVCheckJob _sut;
|
private readonly ProcessCVCheckJob _sut;
|
||||||
@@ -37,6 +38,7 @@ public sealed class ProcessCVCheckJobTests : IDisposable
|
|||||||
_fileStorageServiceMock = new Mock<IFileStorageService>();
|
_fileStorageServiceMock = new Mock<IFileStorageService>();
|
||||||
_cvParserServiceMock = new Mock<ICVParserService>();
|
_cvParserServiceMock = new Mock<ICVParserService>();
|
||||||
_companyVerifierServiceMock = new Mock<ICompanyVerifierService>();
|
_companyVerifierServiceMock = new Mock<ICompanyVerifierService>();
|
||||||
|
_educationVerifierServiceMock = new Mock<IEducationVerifierService>();
|
||||||
_timelineAnalyserServiceMock = new Mock<ITimelineAnalyserService>();
|
_timelineAnalyserServiceMock = new Mock<ITimelineAnalyserService>();
|
||||||
_loggerMock = new Mock<ILogger<ProcessCVCheckJob>>();
|
_loggerMock = new Mock<ILogger<ProcessCVCheckJob>>();
|
||||||
|
|
||||||
@@ -45,6 +47,7 @@ public sealed class ProcessCVCheckJobTests : IDisposable
|
|||||||
_fileStorageServiceMock.Object,
|
_fileStorageServiceMock.Object,
|
||||||
_cvParserServiceMock.Object,
|
_cvParserServiceMock.Object,
|
||||||
_companyVerifierServiceMock.Object,
|
_companyVerifierServiceMock.Object,
|
||||||
|
_educationVerifierServiceMock.Object,
|
||||||
_timelineAnalyserServiceMock.Object,
|
_timelineAnalyserServiceMock.Object,
|
||||||
_loggerMock.Object);
|
_loggerMock.Object);
|
||||||
}
|
}
|
||||||
@@ -159,7 +162,7 @@ public sealed class ProcessCVCheckJobTests : IDisposable
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
_cvParserServiceMock.Verify(
|
_cvParserServiceMock.Verify(
|
||||||
x => x.ParseAsync(It.IsAny<Stream>(), "resume.pdf"),
|
x => x.ParseAsync(It.IsAny<Stream>(), "resume.pdf", It.IsAny<CancellationToken>()),
|
||||||
Times.Once);
|
Times.Once);
|
||||||
|
|
||||||
_dbContext.ChangeTracker.Clear();
|
_dbContext.ChangeTracker.Clear();
|
||||||
@@ -843,7 +846,7 @@ public sealed class ProcessCVCheckJobTests : IDisposable
|
|||||||
x => x.DownloadAsync(It.IsAny<string>()),
|
x => x.DownloadAsync(It.IsAny<string>()),
|
||||||
Times.Never);
|
Times.Never);
|
||||||
_cvParserServiceMock.Verify(
|
_cvParserServiceMock.Verify(
|
||||||
x => x.ParseAsync(It.IsAny<Stream>(), It.IsAny<string>()),
|
x => x.ParseAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||||
Times.Never);
|
Times.Never);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1007,6 +1010,7 @@ public sealed class ProcessCVCheckJobTests : IDisposable
|
|||||||
private void SetupDefaultMocks(
|
private void SetupDefaultMocks(
|
||||||
CVData? cvData = null,
|
CVData? cvData = null,
|
||||||
List<CompanyVerificationResult>? verificationResults = null,
|
List<CompanyVerificationResult>? verificationResults = null,
|
||||||
|
List<EducationVerificationResult>? educationResults = null,
|
||||||
TimelineAnalysisResult? timelineResult = null)
|
TimelineAnalysisResult? timelineResult = null)
|
||||||
{
|
{
|
||||||
cvData ??= CreateTestCVData();
|
cvData ??= CreateTestCVData();
|
||||||
@@ -1017,7 +1021,7 @@ public sealed class ProcessCVCheckJobTests : IDisposable
|
|||||||
.ReturnsAsync(new MemoryStream());
|
.ReturnsAsync(new MemoryStream());
|
||||||
|
|
||||||
_cvParserServiceMock
|
_cvParserServiceMock
|
||||||
.Setup(x => x.ParseAsync(It.IsAny<Stream>(), It.IsAny<string>()))
|
.Setup(x => x.ParseAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(cvData);
|
.ReturnsAsync(cvData);
|
||||||
|
|
||||||
if (verificationResults != null)
|
if (verificationResults != null)
|
||||||
@@ -1040,6 +1044,12 @@ public sealed class ProcessCVCheckJobTests : IDisposable
|
|||||||
.ReturnsAsync(CreateDefaultVerificationResult());
|
.ReturnsAsync(CreateDefaultVerificationResult());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_educationVerifierServiceMock
|
||||||
|
.Setup(x => x.VerifyAll(
|
||||||
|
It.IsAny<List<EducationEntry>>(),
|
||||||
|
It.IsAny<List<EmploymentEntry>?>()))
|
||||||
|
.Returns(educationResults ?? []);
|
||||||
|
|
||||||
_timelineAnalyserServiceMock
|
_timelineAnalyserServiceMock
|
||||||
.Setup(x => x.Analyse(It.IsAny<List<EmploymentEntry>>()))
|
.Setup(x => x.Analyse(It.IsAny<List<EmploymentEntry>>()))
|
||||||
.Returns(timelineResult);
|
.Returns(timelineResult);
|
||||||
|
|||||||
418
tests/TrueCV.Tests/Services/EducationVerifierServiceTests.cs
Normal file
418
tests/TrueCV.Tests/Services/EducationVerifierServiceTests.cs
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using TrueCV.Application.Models;
|
||||||
|
using TrueCV.Infrastructure.Services;
|
||||||
|
|
||||||
|
namespace TrueCV.Tests.Services;
|
||||||
|
|
||||||
|
public sealed class EducationVerifierServiceTests
|
||||||
|
{
|
||||||
|
private readonly EducationVerifierService _sut = new();
|
||||||
|
|
||||||
|
#region Diploma Mill Detection
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Belford University")]
|
||||||
|
[InlineData("Ashwood University")]
|
||||||
|
[InlineData("Rochville University")]
|
||||||
|
[InlineData("St Regis University")]
|
||||||
|
public void Verify_DiplomaMillInstitution_ReturnsDiplomaMill(string institution)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var education = new EducationEntry
|
||||||
|
{
|
||||||
|
Institution = institution,
|
||||||
|
Qualification = "PhD",
|
||||||
|
Subject = "Business",
|
||||||
|
StartDate = new DateOnly(2020, 1, 1),
|
||||||
|
EndDate = new DateOnly(2020, 6, 1)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _sut.Verify(education);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Status.Should().Be("DiplomaMill");
|
||||||
|
result.IsDiplomaMill.Should().BeTrue();
|
||||||
|
result.IsSuspicious.Should().BeTrue();
|
||||||
|
result.IsVerified.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Verify_DiplomaMillInstitution_IncludesVerificationNotes()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var education = new EducationEntry
|
||||||
|
{
|
||||||
|
Institution = "Belford University",
|
||||||
|
Qualification = "MBA"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _sut.Verify(education);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.VerificationNotes.Should().Contain("diploma mill blacklist");
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Suspicious Pattern Detection
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Global Online University")]
|
||||||
|
[InlineData("Premier University of Excellence")]
|
||||||
|
[InlineData("Executive Virtual University")]
|
||||||
|
public void Verify_SuspiciousPatternInstitution_ReturnsSuspicious(string institution)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var education = new EducationEntry
|
||||||
|
{
|
||||||
|
Institution = institution
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _sut.Verify(education);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Status.Should().Be("Suspicious");
|
||||||
|
result.IsSuspicious.Should().BeTrue();
|
||||||
|
result.IsDiplomaMill.Should().BeFalse();
|
||||||
|
result.IsVerified.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region UK Institution Recognition
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("University of Cambridge", "University of Cambridge")]
|
||||||
|
[InlineData("Cambridge", "University of Cambridge")]
|
||||||
|
[InlineData("University of Oxford", "University of Oxford")]
|
||||||
|
[InlineData("Oxford", "University of Oxford")]
|
||||||
|
[InlineData("Imperial College London", "Imperial College London")]
|
||||||
|
[InlineData("UCL", "UCL")] // UCL is directly in the recognised list
|
||||||
|
[InlineData("LSE", "LSE")] // LSE is directly in the recognised list
|
||||||
|
public void Verify_RecognisedUKInstitution_ReturnsRecognised(string input, string expectedMatch)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var education = new EducationEntry
|
||||||
|
{
|
||||||
|
Institution = input,
|
||||||
|
Qualification = "BSc",
|
||||||
|
StartDate = new DateOnly(2018, 9, 1),
|
||||||
|
EndDate = new DateOnly(2021, 6, 1)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _sut.Verify(education);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Status.Should().Be("Recognised");
|
||||||
|
result.IsVerified.Should().BeTrue();
|
||||||
|
result.IsDiplomaMill.Should().BeFalse();
|
||||||
|
result.IsSuspicious.Should().BeFalse();
|
||||||
|
result.MatchedInstitution.Should().Be(expectedMatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Verify_RecognisedInstitution_IncludesVerificationNotes()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var education = new EducationEntry
|
||||||
|
{
|
||||||
|
Institution = "University of Manchester"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _sut.Verify(education);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.VerificationNotes.Should().Contain("Verified UK higher education institution");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Verify_RecognisedInstitutionVariation_NotesMatchedName()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var education = new EducationEntry
|
||||||
|
{
|
||||||
|
Institution = "Cambridge"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _sut.Verify(education);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.VerificationNotes.Should().Contain("Matched to official name");
|
||||||
|
result.MatchedInstitution.Should().Be("University of Cambridge");
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Unknown Institutions
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Verify_UnknownInstitution_ReturnsUnknown()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var education = new EducationEntry
|
||||||
|
{
|
||||||
|
Institution = "University of Ljubljana",
|
||||||
|
Qualification = "BSc"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _sut.Verify(education);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Status.Should().Be("Unknown");
|
||||||
|
result.IsVerified.Should().BeFalse();
|
||||||
|
result.IsDiplomaMill.Should().BeFalse();
|
||||||
|
result.IsSuspicious.Should().BeFalse();
|
||||||
|
result.VerificationNotes.Should().Contain("international institution");
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Date Plausibility
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Verify_PlausibleDates_ReturnsPlausible()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var education = new EducationEntry
|
||||||
|
{
|
||||||
|
Institution = "University of Bristol",
|
||||||
|
StartDate = new DateOnly(2018, 9, 1),
|
||||||
|
EndDate = new DateOnly(2021, 6, 1)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _sut.Verify(education);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.DatesArePlausible.Should().BeTrue();
|
||||||
|
result.DatePlausibilityNotes.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Verify_TooShortCourseDuration_ReturnsImplausible()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var education = new EducationEntry
|
||||||
|
{
|
||||||
|
Institution = "University of Bristol",
|
||||||
|
StartDate = new DateOnly(2020, 1, 1),
|
||||||
|
EndDate = new DateOnly(2020, 6, 1) // 6 months
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _sut.Verify(education);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.DatesArePlausible.Should().BeFalse();
|
||||||
|
result.DatePlausibilityNotes.Should().Contain("unusually short");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Verify_TooLongCourseDuration_ReturnsImplausible()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var education = new EducationEntry
|
||||||
|
{
|
||||||
|
Institution = "University of Bristol",
|
||||||
|
StartDate = new DateOnly(2010, 1, 1),
|
||||||
|
EndDate = new DateOnly(2020, 1, 1) // 10 years
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _sut.Verify(education);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.DatesArePlausible.Should().BeFalse();
|
||||||
|
result.DatePlausibilityNotes.Should().Contain("unusually long");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Verify_EndDateBeforeStartDate_ReturnsImplausible()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var education = new EducationEntry
|
||||||
|
{
|
||||||
|
Institution = "University of Bristol",
|
||||||
|
StartDate = new DateOnly(2021, 1, 1),
|
||||||
|
EndDate = new DateOnly(2020, 1, 1)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _sut.Verify(education);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.DatesArePlausible.Should().BeFalse();
|
||||||
|
result.DatePlausibilityNotes.Should().Contain("before or equal to start date");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Verify_NoDates_AssumesPlausible()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var education = new EducationEntry
|
||||||
|
{
|
||||||
|
Institution = "University of Bristol"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _sut.Verify(education);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.DatesArePlausible.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region VerifyAll
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VerifyAll_MultipleEducations_ReturnsResultsForEach()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var educations = new List<EducationEntry>
|
||||||
|
{
|
||||||
|
new() { Institution = "University of Cambridge" },
|
||||||
|
new() { Institution = "Belford University" },
|
||||||
|
new() { Institution = "Unknown Foreign University" }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var results = _sut.VerifyAll(educations);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
results.Should().HaveCount(3);
|
||||||
|
results[0].Status.Should().Be("Recognised");
|
||||||
|
results[1].Status.Should().Be("DiplomaMill");
|
||||||
|
results[2].Status.Should().Be("Unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VerifyAll_OverlappingEducation_NotesOverlap()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var educations = new List<EducationEntry>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Institution = "University of Bristol",
|
||||||
|
StartDate = new DateOnly(2018, 9, 1),
|
||||||
|
EndDate = new DateOnly(2021, 6, 1)
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Institution = "University of Bath",
|
||||||
|
StartDate = new DateOnly(2020, 9, 1),
|
||||||
|
EndDate = new DateOnly(2023, 6, 1)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var results = _sut.VerifyAll(educations);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
results[0].DatePlausibilityNotes.Should().Contain("Overlaps with");
|
||||||
|
results[1].DatePlausibilityNotes.Should().Contain("Overlaps with");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VerifyAll_EmploymentBeforeGraduation_ChecksTimeline()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var educations = new List<EducationEntry>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Institution = "University of Bristol",
|
||||||
|
StartDate = new DateOnly(2018, 9, 1),
|
||||||
|
EndDate = new DateOnly(2021, 6, 1)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var employment = new List<EmploymentEntry>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
CompanyName = "Tech Corp",
|
||||||
|
JobTitle = "Senior Developer",
|
||||||
|
StartDate = new DateOnly(2018, 1, 1) // Started before education started
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var results = _sut.VerifyAll(educations, employment);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
results[0].DatesArePlausible.Should().BeFalse();
|
||||||
|
results[0].DatePlausibilityNotes.Should().Contain("months before claimed graduation");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VerifyAll_InternshipBeforeGraduation_AllowsTimeline()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var educations = new List<EducationEntry>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Institution = "University of Bristol",
|
||||||
|
StartDate = new DateOnly(2018, 9, 1),
|
||||||
|
EndDate = new DateOnly(2021, 6, 1)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var employment = new List<EmploymentEntry>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
CompanyName = "Tech Corp",
|
||||||
|
JobTitle = "Software Intern",
|
||||||
|
StartDate = new DateOnly(2019, 6, 1)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var results = _sut.VerifyAll(educations, employment);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Should be plausible because it's an internship
|
||||||
|
results[0].DatesArePlausible.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Data Preservation
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Verify_PreservesAllClaimedData()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var education = new EducationEntry
|
||||||
|
{
|
||||||
|
Institution = "University of Bristol",
|
||||||
|
Qualification = "BSc Computer Science",
|
||||||
|
Subject = "Computer Science",
|
||||||
|
Grade = "First Class Honours",
|
||||||
|
StartDate = new DateOnly(2018, 9, 1),
|
||||||
|
EndDate = new DateOnly(2021, 6, 1)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _sut.Verify(education);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.ClaimedInstitution.Should().Be("University of Bristol");
|
||||||
|
result.ClaimedQualification.Should().Be("BSc Computer Science");
|
||||||
|
result.ClaimedSubject.Should().Be("Computer Science");
|
||||||
|
result.ClaimedStartDate.Should().Be(new DateOnly(2018, 9, 1));
|
||||||
|
result.ClaimedEndDate.Should().Be(new DateOnly(2021, 6, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user