diff --git a/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioRules.cs b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioRules.cs new file mode 100644 index 0000000..7cee61e --- /dev/null +++ b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioRules.cs @@ -0,0 +1,79 @@ +using System.Text.Json; + +namespace ProCoderJi.Api.Features.Portfolio; + +/// +/// The portfolio rules shared with the frontend, read from shared/portfolio/rules.json +/// (copied to Data/portfolio-rules.json at build time). +/// +public sealed class PortfolioRules +{ + private static readonly JsonSerializerOptions RulesJson = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Disallow + }; + + public required IReadOnlyList RequiredForPublish { get; init; } + public required IReadOnlyDictionary MaxItems { get; init; } + public required IReadOnlyDictionary MaxLength { get; init; } + public required IReadOnlyList MultilineFields { get; init; } + public required TextRules Text { get; init; } + public required LinkRules Link { get; init; } + public required string EmailPattern { get; init; } + public required UsernameRules Username { get; init; } + + public sealed record TextRules(string ControlCharacters, string HtmlLike); + + public sealed record LinkRules( + string Scheme, + string ForbiddenCharacters, + string HostPattern, + IReadOnlyDictionary HostRestrictions); + + public sealed record UsernameRules( + int MinLength, + int MaxLength, + string Pattern, + IReadOnlyList Reserved, + IReadOnlyList BlockedWords); + + private sealed record RulesDocument( + int Version, + List? RequiredForPublish, + Dictionary? MaxItems, + Dictionary? MaxLength, + List? MultilineFields, + TextRules? Text, + LinkRules? Link, + string? EmailPattern, + UsernameRules? Username); + + public static PortfolioRules FromJson(string json) + { + RulesDocument? document; + try { document = JsonSerializer.Deserialize(json, RulesJson); } + catch (JsonException cause) { throw new InvalidDataException("The shared portfolio rules are not valid JSON.", cause); } + if (document is not { Version: 1 } || document.RequiredForPublish is null || document.MaxItems is null + || document.MaxLength is null || document.MultilineFields is null || document.Text is null + || document.Link is null || document.EmailPattern is null || document.Username is null + || document.Username.Reserved is null || document.Username.BlockedWords is null + || document.Link.HostRestrictions is null) + throw new InvalidDataException("The shared portfolio rules are incomplete or use an unsupported version."); + return new PortfolioRules + { + RequiredForPublish = document.RequiredForPublish, + MaxItems = document.MaxItems, + MaxLength = document.MaxLength, + MultilineFields = document.MultilineFields, + Text = document.Text, + Link = document.Link, + EmailPattern = document.EmailPattern, + Username = document.Username + }; + } + + /// Loads the rules copied next to the application. + public static PortfolioRules LoadDefault() => + FromJson(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Data", "portfolio-rules.json"))); +} diff --git a/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioValidator.cs b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioValidator.cs new file mode 100644 index 0000000..1f6219b --- /dev/null +++ b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioValidator.cs @@ -0,0 +1,310 @@ +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace ProCoderJi.Api.Features.Portfolio; + +public enum PortfolioMode { Draft, Publish } + +/// A problem at a field path such as projects[0].link. +public readonly record struct PortfolioProblem(string Path, string Code) +{ + /// The stable path:code form used by the shared fixtures. + public override string ToString() => $"{Path}:{Code}"; +} + +/// +/// Trusted portfolio validation. The frontend implements the same rules in +/// src/features/portfolio/validation.ts, and both run against +/// shared/portfolio/fixtures.json. Keep the two in step: when a check changes, add a +/// fixture first. The rules avoid URI parsers and culture-aware string functions on purpose, +/// because those behave differently in .NET and JavaScript. +/// +public sealed class PortfolioValidator +{ + private static readonly string[] TopLevelFields = + ["name", "headline", "about", "skills", "projects", "education", "experience", "achievements", "links"]; + + private readonly PortfolioRules rules; + private readonly Regex control; + private readonly Regex htmlLike; + private readonly Regex linkForbidden; + private readonly Regex host; + private readonly Regex email; + private readonly Regex username; + private readonly HashSet reserved; + private readonly HashSet multiline; + + public PortfolioValidator(PortfolioRules rules) + { + this.rules = rules; + control = Pattern(rules.Text.ControlCharacters); + htmlLike = Pattern(rules.Text.HtmlLike); + linkForbidden = Pattern(rules.Link.ForbiddenCharacters); + host = Anchored(rules.Link.HostPattern); + email = Anchored(rules.EmailPattern); + username = Anchored(rules.Username.Pattern); + reserved = new HashSet(rules.Username.Reserved, StringComparer.Ordinal); + multiline = new HashSet(rules.MultilineFields, StringComparer.Ordinal); + } + + public static PortfolioValidator CreateDefault() => new(PortfolioRules.LoadDefault()); + + // .NET's "$" also matches before a final newline; "\z" does not, so "riya\n" cannot slip through. + private static Regex Anchored(string pattern) => new(pattern + "\\z", RegexOptions.CultureInvariant); + private static Regex Pattern(string pattern) => new(pattern, RegexOptions.CultureInvariant); + + public IReadOnlyList ValidateUsername(string value) => + ValidateUsername(JsonValue.Create(value)); + + /// Checks a username for the public address /u/<username>. + public IReadOnlyList ValidateUsername(JsonNode? node) + { + const string path = "username"; + var rule = rules.Username; + var read = new List(); + if (!Read(read, path, node, out var value)) return read; + if (IsBlank(value)) return [new(path, "required")]; + if (value.Length < rule.MinLength) return [new(path, "too-short")]; + if (value.Length > rule.MaxLength) return [new(path, "too-long")]; + if (!username.IsMatch(value)) return [new(path, "invalid-format")]; + if (reserved.Contains(value)) return [new(path, "reserved")]; + var squashed = value.Replace("-", "", StringComparison.Ordinal); + if (rule.BlockedWords.Any(word => squashed.Contains(word, StringComparison.Ordinal))) + return [new(path, "blocked-word")]; + return []; + } + + /// + /// Checks untrusted portfolio data. A draft only has to be well-formed; publishing also needs + /// the fields listed in requiredForPublish. An empty result means the data is valid. + /// + public IReadOnlyList Validate(JsonNode? node, PortfolioMode mode) + { + var problems = new List(); + if (node is not JsonObject root) return [new("$", "invalid-type")]; + if (!CheckUnknownFields(problems, "", root, TopLevelFields)) return problems; + + bool RequiredOnPublish(string key) => mode == PortfolioMode.Publish && rules.RequiredForPublish.Contains(key); + CheckText(problems, "name", Get(root, "name"), "name", RequiredOnPublish("name")); + CheckText(problems, "headline", Get(root, "headline"), "headline", RequiredOnPublish("headline")); + CheckText(problems, "about", Get(root, "about"), "about", false); + + CheckList(problems, "skills", Get(root, "skills"), "skills", (path, item) => + CheckText(problems, path, new(true, item), "skills.item", true)); + + CheckList(problems, "projects", Get(root, "projects"), "projects", (path, item) => + CheckObjectItem(problems, path, item, ["title", "link", "technologies", "description"], project => + { + CheckText(problems, $"{path}.title", Get(project, "title"), "projects.title", true); + CheckLink(problems, $"{path}.link", Get(project, "link"), null); + CheckList(problems, $"{path}.technologies", Get(project, "technologies"), "projects.technologies", (techPath, tech) => + CheckText(problems, techPath, new(true, tech), "projects.technologies.item", true)); + CheckText(problems, $"{path}.description", Get(project, "description"), "projects.description", false); + })); + + CheckList(problems, "education", Get(root, "education"), "education", (path, item) => + CheckObjectItem(problems, path, item, ["institution", "degree", "period", "details"], entry => + { + CheckText(problems, $"{path}.institution", Get(entry, "institution"), "education.institution", true); + CheckText(problems, $"{path}.degree", Get(entry, "degree"), "education.degree", false); + CheckText(problems, $"{path}.period", Get(entry, "period"), "education.period", false); + CheckText(problems, $"{path}.details", Get(entry, "details"), "education.details", false); + })); + + CheckList(problems, "experience", Get(root, "experience"), "experience", (path, item) => + CheckObjectItem(problems, path, item, ["organization", "role", "period", "description"], entry => + { + CheckText(problems, $"{path}.organization", Get(entry, "organization"), "experience.organization", true); + CheckText(problems, $"{path}.role", Get(entry, "role"), "experience.role", true); + CheckText(problems, $"{path}.period", Get(entry, "period"), "experience.period", false); + CheckText(problems, $"{path}.description", Get(entry, "description"), "experience.description", false); + })); + + CheckList(problems, "achievements", Get(root, "achievements"), "achievements", (path, item) => + CheckText(problems, path, new(true, item), "achievements.item", true)); + + var links = Get(root, "links"); + if (links.Present) + { + if (links.Node is not JsonObject linkObject) problems.Add(new("links", "invalid-type")); + else if (CheckUnknownFields(problems, "links", linkObject, ["github", "linkedin", "website", "email"])) + { + CheckLink(problems, "links.github", Get(linkObject, "github"), "links.github"); + CheckLink(problems, "links.linkedin", Get(linkObject, "linkedin"), "links.linkedin"); + CheckLink(problems, "links.website", Get(linkObject, "website"), null); + CheckEmail(problems, "links.email", Get(linkObject, "email")); + } + } + return problems; + } + + private readonly record struct Field(bool Present, JsonNode? Node); + + private static Field Get(JsonObject source, string key) => + source.TryGetPropertyValue(key, out var node) ? new(true, node) : new(false, null); + + private enum StringRead { Ok, NotAString, Malformed } + + /// + /// Reads a JSON string without throwing. Depending on the runtime, a lazily decoded string + /// that holds a lone surrogate throws while it is read; that and a programmatically built + /// string with a lone surrogate are both reported as malformed. + /// + private static StringRead TryString(JsonNode? node, out string value) + { + value = ""; + if (node is not JsonValue json) return StringRead.NotAString; + string? text; + try + { + if (!json.TryGetValue(out text) || text is null) return StringRead.NotAString; + } + catch (Exception cause) when (cause is InvalidOperationException or ArgumentException or FormatException) + { + return StringRead.Malformed; + } + if (!IsWellFormed(text)) return StringRead.Malformed; + value = text; + return StringRead.Ok; + } + + /// Adds the problem for an unreadable value; returns true when the value was read. + private static bool Read(List problems, string path, JsonNode? node, out string value) + { + switch (TryString(node, out value)) + { + case StringRead.Ok: + return true; + case StringRead.Malformed: + problems.Add(new(path, "invalid-characters")); + return false; + default: + problems.Add(new(path, "invalid-type")); + return false; + } + } + + /// False when the text has a lone surrogate half, which no encoding can represent. + private static bool IsWellFormed(string text) + { + for (var i = 0; i < text.Length; i++) + { + if (char.IsHighSurrogate(text[i])) + { + if (i + 1 >= text.Length || !char.IsLowSurrogate(text[i + 1])) return false; + i++; + } + else if (char.IsLowSurrogate(text[i])) return false; + } + return true; + } + + private static bool IsBlank(string value) => value.All(c => c is ' ' or '\t' or '\r' or '\n'); + + /// Lower-cases A-Z only, so other characters such as the Kelvin sign are never folded. + private static string AsciiLower(string value) => + string.Create(value.Length, value, static (span, source) => + { + for (var i = 0; i < source.Length; i++) + span[i] = source[i] is >= 'A' and <= 'Z' ? (char)(source[i] + 32) : source[i]; + }); + + private int Limit(IReadOnlyDictionary table, string key) => + table.TryGetValue(key, out var value) ? value : throw new InvalidDataException($"Missing portfolio rule: {key}"); + + /// + /// Reports unknown field names. Returns false, after reporting invalid-characters for the + /// object itself, when a field name is not well-formed text; the caller then skips that object. + /// System.Text.Json decodes property names lazily, so a lone surrogate escape in a parsed name + /// throws the first time the object is read. + /// + private static bool CheckUnknownFields(List problems, string path, JsonObject source, IReadOnlyCollection allowed) + { + List keys; + try { keys = source.Select(property => property.Key).ToList(); } + catch (Exception cause) when (cause is InvalidOperationException or ArgumentException or FormatException) + { + problems.Add(new(path.Length == 0 ? "$" : path, "invalid-characters")); + return false; + } + if (keys.Any(key => !IsWellFormed(key))) + { + problems.Add(new(path.Length == 0 ? "$" : path, "invalid-characters")); + return false; + } + foreach (var key in keys) + if (!allowed.Contains(key)) problems.Add(new(path.Length == 0 ? key : $"{path}.{key}", "unknown-field")); + return true; + } + + private void CheckText(List problems, string path, Field field, string key, bool required) + { + if (!field.Present) + { + if (required) problems.Add(new(path, "required")); + return; + } + if (!Read(problems, path, field.Node, out var value)) return; + // Length comes before the blank check, so a field of only spaces cannot exceed its limit. + if (value.Length > Limit(rules.MaxLength, key)) { problems.Add(new(path, "too-long")); return; } + if (IsBlank(value)) + { + if (required) problems.Add(new(path, "required")); + return; + } + if (control.IsMatch(value) || (!multiline.Contains(key) && value.AsSpan().IndexOfAny('\r', '\n') >= 0)) + { + problems.Add(new(path, "invalid-characters")); + return; + } + if (htmlLike.IsMatch(value)) problems.Add(new(path, "html-like")); + } + + /// An optional https link. selects the host restriction, such as links.github. + private void CheckLink(List problems, string path, Field field, string? hostKey) + { + if (!field.Present) return; + if (!Read(problems, path, field.Node, out var value)) return; + if (value.Length > Limit(rules.MaxLength, "link")) { problems.Add(new(path, "too-long")); return; } + if (IsBlank(value)) return; + var scheme = rules.Link.Scheme; + if (linkForbidden.IsMatch(value) || value.Length < scheme.Length || AsciiLower(value[..scheme.Length]) != scheme) + { + problems.Add(new(path, "invalid-link")); + return; + } + var rest = value[scheme.Length..]; + var end = rest.AsSpan().IndexOfAny('/', '?', '#'); + var authority = end < 0 ? rest : rest[..end]; + // The host pattern allows only letters, digits, hyphens and dots, so credentials + // (user@host), ports (host:8443), IP literals and empty hosts all fail it. + var hostName = AsciiLower(authority); + if (!host.IsMatch(hostName)) { problems.Add(new(path, "invalid-link")); return; } + if (hostKey is not null && rules.Link.HostRestrictions.TryGetValue(hostKey, out var allowed) + && !allowed.Any(domain => hostName == domain || hostName.EndsWith("." + domain, StringComparison.Ordinal))) + problems.Add(new(path, "invalid-host")); + } + + private void CheckEmail(List problems, string path, Field field) + { + if (!field.Present) return; + if (!Read(problems, path, field.Node, out var value)) return; + if (value.Length > Limit(rules.MaxLength, "email")) { problems.Add(new(path, "too-long")); return; } + if (IsBlank(value)) return; + if (!email.IsMatch(value)) problems.Add(new(path, "invalid-email")); + } + + private void CheckList(List problems, string path, Field field, string key, Action checkItem) + { + if (!field.Present) return; + if (field.Node is not JsonArray array) { problems.Add(new(path, "invalid-type")); return; } + if (array.Count > Limit(rules.MaxItems, key)) { problems.Add(new(path, "too-many")); return; } + for (var i = 0; i < array.Count; i++) checkItem($"{path}[{i}]", array[i]); + } + + private static void CheckObjectItem(List problems, string path, JsonNode? item, string[] allowed, Action checkFields) + { + if (item is not JsonObject source) { problems.Add(new(path, "invalid-type")); return; } + if (CheckUnknownFields(problems, path, source, allowed)) checkFields(source); + } +} diff --git a/backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj b/backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj index e254ada..1ef4f32 100644 --- a/backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj +++ b/backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj @@ -21,6 +21,7 @@ + diff --git a/backend/tests/ProCoderJi.Api.Tests/PortfolioValidatorTests.cs b/backend/tests/ProCoderJi.Api.Tests/PortfolioValidatorTests.cs new file mode 100644 index 0000000..37a1e4c --- /dev/null +++ b/backend/tests/ProCoderJi.Api.Tests/PortfolioValidatorTests.cs @@ -0,0 +1,165 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using ProCoderJi.Api.Features.Portfolio; +using Xunit; + +namespace ProCoderJi.Api.Tests; + +/// +/// Runs the shared fixtures that the frontend also runs (src/features/portfolio/portfolio.test.ts). +/// Add a fixture to shared/portfolio/fixtures.json first when a rule changes. +/// +public sealed class PortfolioValidatorTests +{ + private static readonly PortfolioValidator Validator = PortfolioValidator.CreateDefault(); + + private static JsonNode Fixtures => JsonNode.Parse( + File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Data", "portfolio-fixtures.json")))!; + + private static string[] Sorted(IEnumerable problems) => + problems.Select(problem => problem.ToString()).Order(StringComparer.Ordinal).ToArray(); + + private static string[] Expected(JsonNode fixture) => + fixture["problems"]!.AsArray().Select(problem => problem!.GetValue()).Order(StringComparer.Ordinal).ToArray(); + + [Fact] + public void EverySharedUsernameFixtureGivesTheExpectedProblems() + { + var cases = Fixtures["usernames"]!.AsArray(); + Assert.NotEmpty(cases); + foreach (var fixture in cases) + Assert.True( + Expected(fixture!).SequenceEqual(Sorted(Validator.ValidateUsername(fixture!["value"]))), + $"{fixture["name"]}: expected [{string.Join(", ", Expected(fixture))}] but got [{string.Join(", ", Sorted(Validator.ValidateUsername(fixture["value"])))}]"); + } + + [Fact] + public void EverySharedPortfolioFixtureGivesTheExpectedProblems() + { + var cases = Fixtures["portfolios"]!.AsArray(); + Assert.NotEmpty(cases); + foreach (var fixture in cases) + { + var mode = fixture!["mode"]!.GetValue() switch + { + "draft" => PortfolioMode.Draft, + "publish" => PortfolioMode.Publish, + var other => throw new InvalidDataException($"Unknown fixture mode: {other}") + }; + var actual = Sorted(Validator.Validate(fixture["value"], mode)); + Assert.True( + Expected(fixture).SequenceEqual(actual), + $"{fixture["name"]}: expected [{string.Join(", ", Expected(fixture))}] but got [{string.Join(", ", actual)}]"); + } + } + + // Text with a lone surrogate cannot be stored in JSON that System.Text.Json parses, so these + // fixtures list the UTF-16 code units and each runtime builds the string itself. + [Fact] + public void EveryMalformedTextFixtureGivesTheExpectedProblems() + { + var cases = Fixtures["malformedText"]!.AsArray(); + Assert.NotEmpty(cases); + foreach (var fixture in cases) + { + var text = new string(fixture!["codeUnits"]!.AsArray().Select(unit => (char)unit!.GetValue()).ToArray()); + var field = fixture["field"]!.GetValue(); + var actual = Sorted(field == "username" + ? Validator.ValidateUsername(text) + : Validator.Validate(PortfolioWith(field, text), PortfolioMode.Draft)); + Assert.True( + Expected(fixture).SequenceEqual(actual), + $"{fixture["name"]}: expected [{string.Join(", ", Expected(fixture))}] but got [{string.Join(", ", actual)}]"); + } + } + + private static JsonNode PortfolioWith(string field, string text) => field switch + { + "name" => new JsonObject { ["name"] = text }, + "about" => new JsonObject { ["about"] = text }, + "skill" => new JsonObject { ["skills"] = new JsonArray(text) }, + "projectLink" => new JsonObject { ["projects"] = new JsonArray(new JsonObject { ["title"] = "App", ["link"] = text }) }, + "website" => new JsonObject { ["links"] = new JsonObject { ["website"] = text } }, + "email" => new JsonObject { ["links"] = new JsonObject { ["email"] = text } }, + "key" => new JsonObject { [text] = 1 }, + "linksKey" => new JsonObject { ["links"] = new JsonObject { [text] = 1 } }, + "projectKey" => new JsonObject { ["projects"] = new JsonArray(new JsonObject { ["title"] = "App", [text] = 1 }) }, + _ => throw new InvalidDataException($"Unknown fixture field: {field}") + }; + + [Fact] + public void ALoneSurrogateEscapeInParsedJsonIsAProblemNotAnException() + { + // JsonNode.Parse accepts the text; System.Text.Json only fails later, when a string or a + // property name is read, so the validator has to handle the read failing. + var json = "{\"name\":\"" + Escape("d800") + "\",\"skills\":[\"" + Escape("dc00") + "\"]}"; + Assert.Equal( + ["name:invalid-characters", "skills[0]:invalid-characters"], + Sorted(Validator.Validate(JsonNode.Parse(json), PortfolioMode.Draft))); + } + + [Theory] + [InlineData("{\"{0}\":1}", "$:invalid-characters")] + [InlineData("{\"links\":{\"{0}\":1}}", "links:invalid-characters")] + [InlineData("{\"projects\":[{\"title\":\"App\",\"{0}\":1}]}", "projects[0]:invalid-characters")] + public void ALoneSurrogateEscapeInAParsedFieldNameIsAProblemNotAnException(string template, string expected) + { + var node = JsonNode.Parse(template.Replace("{0}", Escape("d800"), StringComparison.Ordinal)); + Assert.Equal([expected], Sorted(Validator.Validate(node, PortfolioMode.Draft))); + } + + // The backslash is built from a char code so this source never contains a surrogate escape. + private static string Escape(string hex) => (char)92 + "u" + hex; + + [Fact] + public void ValidatingDoesNotModifyTheInput() + { + var input = JsonNode.Parse("""{"name":"Riya","skills":["a"],"links":{"github":"https://github.com/riya"}}""")!; + var before = input.ToJsonString(); + Validator.Validate(input, PortfolioMode.Publish); + Assert.Equal(before, input.ToJsonString()); + } + + [Fact] + public void ValidUsernamesAndPortfoliosReportNothing() + { + Assert.Empty(Validator.ValidateUsername("riya-sharma")); + Assert.Empty(Validator.Validate(JsonNode.Parse("""{"name":"Riya","headline":"Student"}"""), PortfolioMode.Publish)); + } + + [Fact] + public void ReservedNamesCoverTheNamesListedInTheDecisionRecord() + { + foreach (var name in new[] { "www", "api", "app", "admin", "mail", "docs", "blog", "login", "support", "help", "procoderji", "satyam", "learn", "practice", "account", "dashboard", "games", "guides", "jobs" }) + Assert.Contains(name, PortfolioRulesForTest().Username.Reserved); + } + + [Fact] + public void IncompleteRulesAreRejectedAtStartup() + { + Assert.Throws(() => PortfolioRules.FromJson("{}")); + Assert.Throws(() => PortfolioRules.FromJson("""{"version":2}""")); + Assert.Throws(() => PortfolioRules.FromJson("not json")); + } + + [Fact] + public void AMissingRuleKeyFailsLoudlyInsteadOfSkippingTheCheck() + { + var rules = JsonNode.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Data", "portfolio-rules.json")))!.AsObject(); + rules["maxLength"]!.AsObject().Remove("name"); + var validator = new PortfolioValidator(PortfolioRules.FromJson(rules.ToJsonString())); + Assert.Throws(() => validator.Validate(JsonNode.Parse("""{"name":"Riya"}"""), PortfolioMode.Draft)); + } + + [Fact] + public void AHugeValueIsRejectedQuickly() + { + var started = DateTime.UtcNow; + var huge = JsonNode.Parse(JsonSerializer.Serialize(new { links = new { github = "https://" + new string('a', 2_000_000) } })); + Assert.Equal(["links.github:too-long"], Sorted(Validator.Validate(huge, PortfolioMode.Draft))); + Assert.True(DateTime.UtcNow - started < TimeSpan.FromSeconds(2)); + } + + private static PortfolioRules PortfolioRulesForTest() => + PortfolioRules.LoadDefault(); +} diff --git a/backend/tests/ProCoderJi.Api.Tests/ProCoderJi.Api.Tests.csproj b/backend/tests/ProCoderJi.Api.Tests/ProCoderJi.Api.Tests.csproj index c3ef368..9fef073 100644 --- a/backend/tests/ProCoderJi.Api.Tests/ProCoderJi.Api.Tests.csproj +++ b/backend/tests/ProCoderJi.Api.Tests/ProCoderJi.Api.Tests.csproj @@ -20,6 +20,10 @@ + + + + diff --git a/docs/development.md b/docs/development.md index 4649b95..fd82fd3 100644 --- a/docs/development.md +++ b/docs/development.md @@ -102,6 +102,9 @@ by Git. synchronization. - `shared/practice/questions.json` is the canonical question bank consumed by the frontend and backend. Keep IDs stable when improving wording; use a new ID when a question's meaning changes. +- `shared/portfolio/` holds the student-portfolio rules and test fixtures used by both runtimes + (see its [README](../shared/portfolio/README.md)); the validators live in + `src/features/portfolio/` and `backend/src/ProCoderJi.Api/Features/Portfolio/`. - `backend/src/ProCoderJi.Api/` owns Google sign-in, cookie/antiforgery handling, account progress, and EF persistence. Backend tests live under `backend/tests/`. - `src/content/` and `src/data/` publish reviewed notes, guides, and listings; see the diff --git a/shared/portfolio/README.md b/shared/portfolio/README.md new file mode 100644 index 0000000..e99b0fc --- /dev/null +++ b/shared/portfolio/README.md @@ -0,0 +1,101 @@ +# Portfolio rules + +The rules for student portfolios, shared by the frontend and the backend. The decisions behind +them are in [ADR 0002](../../docs/decisions/0002-student-portfolios.md). + +| File | Purpose | +| --- | --- | +| `rules.json` | Limits, reserved names, blocked words, and the patterns for links, hosts and email. The only place these numbers live. | +| `fixtures.json` | Example usernames and portfolios with the problems each must produce. Both validators run all of them. | + +The TypeScript validator is `src/features/portfolio/validation.ts`; the .NET validator is +`backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioValidator.cs`. The TypeScript validator +gives quick feedback in the editor; the backend is the trusted check before anything is stored. + +## What is checked + +A portfolio has fixed fields: `name`, `headline`, `about`, `skills`, `projects`, `education`, +`experience`, `achievements`, and `links` (`github`, `linkedin`, `website`, `email`). Anything else is +rejected as an `unknown-field`. + +- **Modes.** A *draft* only has to be well-formed, so a student can save half-finished work. *Publish* also + requires `name` and `headline`. Entries inside a list (a project's title, an + experience's role, a skill) are required whenever the entry exists. +- **Text.** No control or invisible characters (such as zero-width spaces or right-to-left overrides). Only + `about`, `projects.description`, `education.details`, `experience.description` may contain line breaks. Text that looks like an HTML tag + (a `<` directly followed by a letter, `/`, `!` or `?`) is rejected, so write `x < y` with spaces. +- **Links.** `https` only, no credentials, no port, no IP address, and no `localhost`. `links.github` must be on + `github.com` and `links.linkedin` on `linkedin.com` (subdomains are fine). The `email` field holds an + address such as `riya@example.com`; the page adds the `mailto:` link. +- **Username.** 3 to 30 characters: lowercase letters and digits, with single hyphens between + them. Reserved names and names containing a blocked word are refused. Hyphens are ignored when looking for a + blocked word, so `pay-tm` is refused too. This is deliberately strict: a harmless name such as `bank-guide` is + refused as well. + +Each problem is reported as a path and a code, written `path:code`, for example `projects[0].link:invalid-link`. +The codes are `required`, `invalid-type`, `unknown-field`, `too-short`, `too-long`, `too-many`, +`invalid-characters`, `html-like`, `invalid-format`, `invalid-link`, `invalid-host`, `invalid-email`, +`reserved` and `blocked-word`. Only the first failing check is reported for each field. + +Checks run in a fixed order for each field: type, well-formed text, length, blank, then the content rules. The length +check comes before the blank check, so a field of only spaces cannot exceed its limit. + +**Malformed text.** A lone surrogate (half of a UTF-16 pair, such as the text a broken emoji leaves behind) cannot be +stored or sent, so it is `invalid-characters` in a value, a link, an email or a username. In a field name it is +reported as `invalid-characters` on the object that holds it, and that object's other fields are skipped. +System.Text.Json decodes strings and field names lazily, so the .NET validator reads them without throwing and +reports the same problem. A lone surrogate escape is not portable JSON (editors and many parsers reject it), so the +`malformedText` fixtures list UTF-16 code units and each runtime builds the string itself. + +## Limits + +Lengths count UTF-16 code units, the unit both JavaScript and .NET use, so one emoji counts as 2. + +| Field | Maximum length | +| --- | --- | +| `name` | 80 | +| `headline` | 120 | +| `about` | 1000 | +| `skills.item` | 40 | +| `projects.title` | 80 | +| `projects.description` | 500 | +| `projects.technologies.item` | 40 | +| `education.institution` | 120 | +| `education.degree` | 120 | +| `education.period` | 40 | +| `education.details` | 300 | +| `experience.organization` | 120 | +| `experience.role` | 120 | +| `experience.period` | 40 | +| `experience.description` | 500 | +| `achievements.item` | 200 | +| `link` | 300 | +| `email` | 254 | + +| List | Maximum entries | +| --- | --- | +| `skills` | 30 | +| `projects` | 8 | +| `projects.technologies` | 12 | +| `education` | 5 | +| `experience` | 6 | +| `achievements` | 10 | + +## Reserved names + +`u`, `www`, `api`, `app`, `admin`, `administrator`, `root`, `mail`, `email`, `smtp`, `ftp`, `docs`, `blog`, `login`, `logout`, `signin`, `signup`, `register`, `auth`, `account`, `support`, `help`, `contact`, `about`, `status`, `static`, `assets`, `cdn`, `dev`, `test`, `staging`, `preview`, `dashboard`, `courses`, `learn`, `practice`, `games`, `guides`, `jobs`, `privacy`, `terms`, `disclaimer`, `copyright`, `portfolio`, `portfolios`, `procoderji`, `satyam`, `robots`, `sitemap`, `favicon`, `icon`, `apple-icon`, `logo`, `brand`, `og`, `new`, `edit`, `settings`, `profile`, `me`, `null`, `undefined` + +## Blocked words + +`paytm`, `phonepe`, `razorpay`, `bank`, `login`, `verify`, `password`, `otp`, `wallet` + +## Changing a rule + +1. Add or change a case in `fixtures.json` first, with the `path:code` problems you expect. +2. Change `rules.json` if the change is a number, a name or a pattern. +3. Change both validators if the logic changes, and keep them identical in behaviour. The validators avoid URL + parsers and locale-aware string functions on purpose, because JavaScript and .NET disagree about them. +4. Run `npm test` and `dotnet test backend/ProCoderJi.slnx --configuration Release`. Both must pass. + +The fixture file is plain ASCII: write any other character as a Unicode escape (a backslash, `u`, and four hex +digits) so reviewers can see exactly which character it is. diff --git a/shared/portfolio/fixtures.json b/shared/portfolio/fixtures.json new file mode 100644 index 0000000..cb4672a --- /dev/null +++ b/shared/portfolio/fixtures.json @@ -0,0 +1,2286 @@ +{ + "usernames": [ + { + "name": "simple name", + "value": "riya", + "problems": [] + }, + { + "name": "hyphenated name", + "value": "riya-sharma", + "problems": [] + }, + { + "name": "digits", + "value": "a1b2c3", + "problems": [] + }, + { + "name": "shortest allowed", + "value": "abc", + "problems": [] + }, + { + "name": "longest allowed (30)", + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "problems": [] + }, + { + "name": "digits and hyphen", + "value": "user-123", + "problems": [] + }, + { + "name": "empty", + "value": "", + "problems": [ + "username:required" + ] + }, + { + "name": "only spaces", + "value": " ", + "problems": [ + "username:required" + ] + }, + { + "name": "number", + "value": 123, + "problems": [ + "username:invalid-type" + ] + }, + { + "name": "null", + "value": null, + "problems": [ + "username:invalid-type" + ] + }, + { + "name": "array", + "value": [ + "riya" + ], + "problems": [ + "username:invalid-type" + ] + }, + { + "name": "too short", + "value": "ab", + "problems": [ + "username:too-short" + ] + }, + { + "name": "one letter that is also reserved", + "value": "u", + "problems": [ + "username:too-short" + ] + }, + { + "name": "31 characters", + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "problems": [ + "username:too-long" + ] + }, + { + "name": "uppercase", + "value": "Riya", + "problems": [ + "username:invalid-format" + ] + }, + { + "name": "underscore", + "value": "riya_sharma", + "problems": [ + "username:invalid-format" + ] + }, + { + "name": "leading hyphen", + "value": "-riya", + "problems": [ + "username:invalid-format" + ] + }, + { + "name": "trailing hyphen", + "value": "riya-", + "problems": [ + "username:invalid-format" + ] + }, + { + "name": "double hyphen", + "value": "ri--ya", + "problems": [ + "username:invalid-format" + ] + }, + { + "name": "space inside", + "value": "riya sharma", + "problems": [ + "username:invalid-format" + ] + }, + { + "name": "trailing newline", + "value": "riya\n", + "problems": [ + "username:invalid-format" + ] + }, + { + "name": "dot", + "value": "riya.sharma", + "problems": [ + "username:invalid-format" + ] + }, + { + "name": "devanagari", + "value": "\u0930\u093f\u092f\u093e", + "problems": [ + "username:invalid-format" + ] + }, + { + "name": "Kelvin sign is not folded to k", + "value": "\u212ariya", + "problems": [ + "username:invalid-format" + ] + }, + { + "name": "full-width letters", + "value": "\uff52\uff49\uff59\uff41", + "problems": [ + "username:invalid-format" + ] + }, + { + "name": "reserved: admin", + "value": "admin", + "problems": [ + "username:reserved" + ] + }, + { + "name": "reserved: api", + "value": "api", + "problems": [ + "username:reserved" + ] + }, + { + "name": "reserved: www", + "value": "www", + "problems": [ + "username:reserved" + ] + }, + { + "name": "reserved: brand name", + "value": "procoderji", + "problems": [ + "username:reserved" + ] + }, + { + "name": "reserved: owner site", + "value": "satyam", + "problems": [ + "username:reserved" + ] + }, + { + "name": "reserved: existing route", + "value": "dashboard", + "problems": [ + "username:reserved" + ] + }, + { + "name": "reserved: hyphenated asset name", + "value": "apple-icon", + "problems": [ + "username:reserved" + ] + }, + { + "name": "reserved and blocked", + "value": "login", + "problems": [ + "username:reserved" + ] + }, + { + "name": "blocked word with suffix", + "value": "paytm-help", + "problems": [ + "username:blocked-word" + ] + }, + { + "name": "blocked word split by hyphen", + "value": "pay-tm", + "problems": [ + "username:blocked-word" + ] + }, + { + "name": "blocked word inside", + "value": "my-bank-guide", + "problems": [ + "username:blocked-word" + ] + }, + { + "name": "blocked word with name", + "value": "riya-login", + "problems": [ + "username:blocked-word" + ] + }, + { + "name": "reserved name as part of a longer name is allowed", + "value": "satyam-fan", + "problems": [] + } + ], + "portfolios": [ + { + "name": "publish: minimal portfolio", + "mode": "publish", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student" + }, + "problems": [] + }, + { + "name": "publish: complete portfolio", + "mode": "publish", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student | Web developer", + "about": "I build small web apps.\nI am looking for a software internship.", + "skills": [ + "JavaScript", + "React", + "Node.js" + ], + "projects": [ + { + "title": "Expense Tracker", + "link": "https://github.com/riya-example/expense-tracker", + "technologies": [ + "React", + "Firebase" + ], + "description": "Tracks monthly spending.\nLearned state management." + }, + { + "title": "Notes App" + } + ], + "education": [ + { + "institution": "Example Institute of Technology", + "degree": "B.Tech CSE", + "period": "2022-2026", + "details": "CGPA 8.4" + } + ], + "experience": [ + { + "organization": "Example Labs", + "role": "Intern", + "period": "Jun-Aug 2025", + "description": "Fixed UI bugs." + } + ], + "achievements": [ + "Finalist, Example Hackathon 2025" + ], + "links": { + "github": "https://github.com/riya-example", + "linkedin": "https://www.linkedin.com/in/riya-example", + "website": "https://riya.example.dev", + "email": "riya@example.com" + } + }, + "problems": [] + }, + { + "name": "publish: empty object", + "mode": "publish", + "value": {}, + "problems": [ + "name:required", + "headline:required" + ] + }, + { + "name": "draft: empty object", + "mode": "draft", + "value": {}, + "problems": [] + }, + { + "name": "draft: blank required fields", + "mode": "draft", + "value": { + "name": " ", + "headline": "" + }, + "problems": [] + }, + { + "name": "publish: blank required fields", + "mode": "publish", + "value": { + "name": " ", + "headline": "\n" + }, + "problems": [ + "name:required", + "headline:required" + ] + }, + { + "name": "array instead of object", + "mode": "publish", + "value": [], + "problems": [ + "$:invalid-type" + ] + }, + { + "name": "null instead of object", + "mode": "publish", + "value": null, + "problems": [ + "$:invalid-type" + ] + }, + { + "name": "string instead of object", + "mode": "draft", + "value": "riya", + "problems": [ + "$:invalid-type" + ] + }, + { + "name": "unknown field", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "phone": "9999999999" + }, + "problems": [ + "phone:unknown-field" + ] + }, + { + "name": "unknown field named __proto__", + "mode": "draft", + "value": { + "name": "Riya", + "__proto__": { + "admin": true + } + }, + "problems": [ + "__proto__:unknown-field" + ] + }, + { + "name": "name has the wrong type", + "mode": "draft", + "value": { + "name": 5 + }, + "problems": [ + "name:invalid-type" + ] + }, + { + "name": "name is null", + "mode": "draft", + "value": { + "name": null + }, + "problems": [ + "name:invalid-type" + ] + }, + { + "name": "name at the limit (80 UTF-16 units)", + "mode": "publish", + "value": { + "name": "\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00", + "headline": "Final-year B.Tech CSE student" + }, + "problems": [] + }, + { + "name": "name over the limit (82 UTF-16 units)", + "mode": "publish", + "value": { + "name": "\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00", + "headline": "Final-year B.Tech CSE student" + }, + "problems": [ + "name:too-long" + ] + }, + { + "name": "name with a newline", + "mode": "draft", + "value": { + "name": "Riya\nSharma" + }, + "problems": [ + "name:invalid-characters" + ] + }, + { + "name": "headline over the limit", + "mode": "draft", + "value": { + "headline": "hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh" + }, + "problems": [ + "headline:too-long" + ] + }, + { + "name": "about at the limit", + "mode": "draft", + "value": { + "about": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "problems": [] + }, + { + "name": "about over the limit", + "mode": "draft", + "value": { + "about": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "problems": [ + "about:too-long" + ] + }, + { + "name": "about with a control character", + "mode": "draft", + "value": { + "about": "hello\u0007" + }, + "problems": [ + "about:invalid-characters" + ] + }, + { + "name": "about with a right-to-left override", + "mode": "draft", + "value": { + "about": "hello\u202eolleh" + }, + "problems": [ + "about:invalid-characters" + ] + }, + { + "name": "about with a zero-width space", + "mode": "draft", + "value": { + "about": "he\u200bllo" + }, + "problems": [ + "about:invalid-characters" + ] + }, + { + "name": "about with an HTML tag", + "mode": "draft", + "value": { + "about": "Hi " + }, + "problems": [ + "about:html-like" + ] + }, + { + "name": "headline with a bold tag", + "mode": "draft", + "value": { + "headline": "hi" + }, + "problems": [ + "headline:html-like" + ] + }, + { + "name": "comparison with spaces is fine", + "mode": "draft", + "value": { + "about": "I like it when x < y and a <= b." + }, + "problems": [] + }, + { + "name": "comparison without spaces looks like a tag", + "mode": "draft", + "value": { + "about": "if x" + } + ] + }, + "problems": [ + "projects[0].link:invalid-link" + ] + }, + { + "name": "link with credentials", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "projects": [ + { + "title": "App", + "link": "https://user:pass@example.com/" + } + ] + }, + "problems": [ + "projects[0].link:invalid-link" + ] + }, + { + "name": "link that hides the real host after @", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "projects": [ + { + "title": "App", + "link": "https://github.com@evil.example/" + } + ] + }, + "problems": [ + "projects[0].link:invalid-link" + ] + }, + { + "name": "link with a port", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "projects": [ + { + "title": "App", + "link": "https://example.com:8443/" + } + ] + }, + "problems": [ + "projects[0].link:invalid-link" + ] + }, + { + "name": "link to localhost", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "projects": [ + { + "title": "App", + "link": "https://localhost/x" + } + ] + }, + "problems": [ + "projects[0].link:invalid-link" + ] + }, + { + "name": "link to an IPv4 address", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "projects": [ + { + "title": "App", + "link": "https://192.168.0.1/" + } + ] + }, + "problems": [ + "projects[0].link:invalid-link" + ] + }, + { + "name": "link to an IPv6 address", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "projects": [ + { + "title": "App", + "link": "https://[::1]/" + } + ] + }, + "problems": [ + "projects[0].link:invalid-link" + ] + }, + { + "name": "link with a non-ASCII host", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "projects": [ + { + "title": "App", + "link": "https://m\u00fcnchen.de/" + } + ] + }, + "problems": [ + "projects[0].link:invalid-link" + ] + }, + { + "name": "link with a punycode host", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "projects": [ + { + "title": "App", + "link": "https://xn--mnchen-3ya.de/" + } + ] + }, + "problems": [] + }, + { + "name": "link with an uppercase scheme and host", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "projects": [ + { + "title": "App", + "link": "HTTPS://Example.COM/Path" + } + ] + }, + "problems": [] + }, + { + "name": "link host with a Kelvin sign is not folded to k", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "projects": [ + { + "title": "App", + "link": "https://\u212aanbal.com/" + } + ] + }, + "problems": [ + "projects[0].link:invalid-link" + ] + }, + { + "name": "link with an empty label", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "projects": [ + { + "title": "App", + "link": "https://example..com/" + } + ] + }, + "problems": [ + "projects[0].link:invalid-link" + ] + }, + { + "name": "link that is not a string", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "projects": [ + { + "title": "App", + "link": 42 + } + ] + }, + "problems": [ + "projects[0].link:invalid-type" + ] + }, + { + "name": "blank link is treated as empty", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "projects": [ + { + "title": "App", + "link": " " + } + ] + }, + "problems": [] + }, + { + "name": "education without an institution", + "mode": "draft", + "value": { + "education": [ + { + "degree": "B.Tech" + } + ] + }, + "problems": [ + "education[0].institution:required" + ] + }, + { + "name": "too many education entries", + "mode": "draft", + "value": { + "education": [ + { + "institution": "X" + }, + { + "institution": "X" + }, + { + "institution": "X" + }, + { + "institution": "X" + }, + { + "institution": "X" + }, + { + "institution": "X" + } + ] + }, + "problems": [ + "education:too-many" + ] + }, + { + "name": "education details over the limit", + "mode": "draft", + "value": { + "education": [ + { + "institution": "X", + "details": "ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + } + ] + }, + "problems": [ + "education[0].details:too-long" + ] + }, + { + "name": "experience without a role", + "mode": "draft", + "value": { + "experience": [ + { + "organization": "Acme" + } + ] + }, + "problems": [ + "experience[0].role:required" + ] + }, + { + "name": "experience without an organization", + "mode": "draft", + "value": { + "experience": [ + { + "role": "Intern" + } + ] + }, + "problems": [ + "experience[0].organization:required" + ] + }, + { + "name": "too many experience entries", + "mode": "draft", + "value": { + "experience": [ + { + "organization": "A", + "role": "B" + }, + { + "organization": "A", + "role": "B" + }, + { + "organization": "A", + "role": "B" + }, + { + "organization": "A", + "role": "B" + }, + { + "organization": "A", + "role": "B" + }, + { + "organization": "A", + "role": "B" + }, + { + "organization": "A", + "role": "B" + } + ] + }, + "problems": [ + "experience:too-many" + ] + }, + { + "name": "experience description over the limit", + "mode": "draft", + "value": { + "experience": [ + { + "organization": "A", + "role": "B", + "description": "ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + } + ] + }, + "problems": [ + "experience[0].description:too-long" + ] + }, + { + "name": "education entry with unknown field", + "mode": "draft", + "value": { + "education": [ + { + "institution": "X", + "gpa": 9 + } + ] + }, + "problems": [ + "education[0].gpa:unknown-field" + ] + }, + { + "name": "achievements at the limit", + "mode": "draft", + "value": { + "achievements": [ + "a0", + "a1", + "a2", + "a3", + "a4", + "a5", + "a6", + "a7", + "a8", + "a9" + ] + }, + "problems": [] + }, + { + "name": "too many achievements", + "mode": "draft", + "value": { + "achievements": [ + "a0", + "a1", + "a2", + "a3", + "a4", + "a5", + "a6", + "a7", + "a8", + "a9", + "a10" + ] + }, + "problems": [ + "achievements:too-many" + ] + }, + { + "name": "achievement too long", + "mode": "draft", + "value": { + "achievements": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ] + }, + "problems": [ + "achievements[0]:too-long" + ] + }, + { + "name": "blank achievement", + "mode": "draft", + "value": { + "achievements": [ + "" + ] + }, + "problems": [ + "achievements[0]:required" + ] + }, + { + "name": "github profile", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "links": { + "github": "https://github.com/riya-example" + } + }, + "problems": [] + }, + { + "name": "github subdomain", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "links": { + "github": "https://gist.github.com/riya-example" + } + }, + "problems": [] + }, + { + "name": "github on another site", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "links": { + "github": "https://example.com/riya" + } + }, + "problems": [ + "links.github:invalid-host" + ] + }, + { + "name": "github as a prefix of another host", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "links": { + "github": "https://notgithub.com/riya" + } + }, + "problems": [ + "links.github:invalid-host" + ] + }, + { + "name": "github as a subdomain of another host", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "links": { + "github": "https://github.com.evil.example/riya" + } + }, + "problems": [ + "links.github:invalid-host" + ] + }, + { + "name": "linkedin profile", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "links": { + "linkedin": "https://www.linkedin.com/in/riya-example" + } + }, + "problems": [] + }, + { + "name": "linkedin regional subdomain", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "links": { + "linkedin": "https://in.linkedin.com/in/riya-example" + } + }, + "problems": [] + }, + { + "name": "linkedin on another site", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "links": { + "linkedin": "https://example.com/in/riya" + } + }, + "problems": [ + "links.linkedin:invalid-host" + ] + }, + { + "name": "website on any host", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "links": { + "website": "https://riya.example.dev" + } + }, + "problems": [] + }, + { + "name": "website over http", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "links": { + "website": "http://riya.example.dev" + } + }, + "problems": [ + "links.website:invalid-link" + ] + }, + { + "name": "github link over 300 characters", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "links": { + "github": "https://github.com/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + "problems": [ + "links.github:too-long" + ] + }, + { + "name": "links with an unknown key", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "links": { + "twitter": "https://x.com/riya" + } + }, + "problems": [ + "links.twitter:unknown-field" + ] + }, + { + "name": "links is not an object", + "mode": "draft", + "value": { + "links": [ + "https://github.com/riya" + ] + }, + "problems": [ + "links:invalid-type" + ] + }, + { + "name": "blank link entries are treated as empty", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "links": { + "github": "", + "linkedin": " ", + "website": "", + "email": "" + } + }, + "problems": [] + }, + { + "name": "valid email", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "links": { + "email": "riya.sharma+jobs@example.co.in" + } + }, + "problems": [] + }, + { + "name": "email without a domain", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "links": { + "email": "riya@" + } + }, + "problems": [ + "links.email:invalid-email" + ] + }, + { + "name": "email without a top-level domain", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "links": { + "email": "riya@example" + } + }, + "problems": [ + "links.email:invalid-email" + ] + }, + { + "name": "email with a mailto prefix", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "links": { + "email": "mailto:riya@example.com" + } + }, + "problems": [ + "links.email:invalid-email" + ] + }, + { + "name": "email with a space", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "links": { + "email": "riya @example.com" + } + }, + "problems": [ + "links.email:invalid-email" + ] + }, + { + "name": "email with a trailing newline", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "links": { + "email": "riya@example.com\n" + } + }, + "problems": [ + "links.email:invalid-email" + ] + }, + { + "name": "email with two at signs", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "links": { + "email": "a@b@example.com" + } + }, + "problems": [ + "links.email:invalid-email" + ] + }, + { + "name": "email over 254 characters", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "links": { + "email": "a@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.com" + } + }, + "problems": [ + "links.email:too-long" + ] + }, + { + "name": "email local part over 64 characters", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "links": { + "email": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@example.com" + } + }, + "problems": [ + "links.email:invalid-email" + ] + }, + { + "name": "about of only spaces at the limit", + "mode": "draft", + "value": { + "about": " " + }, + "problems": [] + }, + { + "name": "about of only spaces over the limit", + "mode": "draft", + "value": { + "about": " " + }, + "problems": [ + "about:too-long" + ] + }, + { + "name": "name of only spaces over the limit (draft)", + "mode": "draft", + "value": { + "name": " " + }, + "problems": [ + "name:too-long" + ] + }, + { + "name": "name of only spaces over the limit (publish)", + "mode": "publish", + "value": { + "name": " ", + "headline": "Student" + }, + "problems": [ + "name:too-long" + ] + }, + { + "name": "name of only spaces at the limit is still required to publish", + "mode": "publish", + "value": { + "name": " ", + "headline": "Student" + }, + "problems": [ + "name:required" + ] + }, + { + "name": "skill of only spaces over the limit", + "mode": "draft", + "value": { + "skills": [ + " " + ] + }, + "problems": [ + "skills[0]:too-long" + ] + }, + { + "name": "project link of only spaces over the limit", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "projects": [ + { + "title": "App", + "link": " " + } + ] + }, + "problems": [ + "projects[0].link:too-long" + ] + }, + { + "name": "website of only spaces over the limit", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "links": { + "website": " " + } + }, + "problems": [ + "links.website:too-long" + ] + }, + { + "name": "website of only spaces at the limit", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "links": { + "website": " " + } + }, + "problems": [] + }, + { + "name": "email of only spaces over the limit", + "mode": "draft", + "value": { + "name": "Riya Sharma", + "headline": "Final-year B.Tech CSE student", + "links": { + "email": " " + } + }, + "problems": [ + "links.email:too-long" + ] + }, + { + "name": "several problems are all reported", + "mode": "publish", + "value": { + "name": "", + "headline": "x", + "skills": [ + "", + "ok" + ], + "projects": [ + { + "link": "http://x.example" + } + ], + "links": { + "github": "https://example.com" + } + }, + "problems": [ + "name:required", + "headline:html-like", + "skills[0]:required", + "projects[0].title:required", + "projects[0].link:invalid-link", + "links.github:invalid-host" + ] + } + ], + "malformedText": [ + { + "name": "lone high surrogate in a name", + "field": "name", + "codeUnits": [ + 97, + 55296, + 98 + ], + "problems": [ + "name:invalid-characters" + ] + }, + { + "name": "lone high surrogate at the end", + "field": "name", + "codeUnits": [ + 97, + 55296 + ], + "problems": [ + "name:invalid-characters" + ] + }, + { + "name": "lone low surrogate", + "field": "name", + "codeUnits": [ + 56320 + ], + "problems": [ + "name:invalid-characters" + ] + }, + { + "name": "low surrogate before a high one", + "field": "name", + "codeUnits": [ + 56320, + 55296 + ], + "problems": [ + "name:invalid-characters" + ] + }, + { + "name": "valid surrogate pair is fine", + "field": "name", + "codeUnits": [ + 97, + 55357, + 56832, + 98 + ], + "problems": [] + }, + { + "name": "lone surrogate in a multi-line field", + "field": "about", + "codeUnits": [ + 108, + 105, + 110, + 101, + 32, + 111, + 110, + 101, + 10, + 55296 + ], + "problems": [ + "about:invalid-characters" + ] + }, + { + "name": "lone surrogate in a skill", + "field": "skill", + "codeUnits": [ + 56320 + ], + "problems": [ + "skills[0]:invalid-characters" + ] + }, + { + "name": "lone surrogate in a project link", + "field": "projectLink", + "codeUnits": [ + 104, + 116, + 116, + 112, + 115, + 58, + 47, + 47, + 101, + 120, + 97, + 109, + 112, + 108, + 101, + 46, + 99, + 111, + 109, + 47, + 55296 + ], + "problems": [ + "projects[0].link:invalid-characters" + ] + }, + { + "name": "lone surrogate in a website", + "field": "website", + "codeUnits": [ + 104, + 116, + 116, + 112, + 115, + 58, + 47, + 47, + 101, + 120, + 97, + 109, + 112, + 108, + 101, + 46, + 99, + 111, + 109, + 47, + 56320 + ], + "problems": [ + "links.website:invalid-characters" + ] + }, + { + "name": "lone surrogate in an email", + "field": "email", + "codeUnits": [ + 114, + 105, + 121, + 97, + 64, + 101, + 120, + 97, + 109, + 112, + 108, + 101, + 46, + 99, + 111, + 109, + 55296 + ], + "problems": [ + "links.email:invalid-characters" + ] + }, + { + "name": "lone surrogate in a username", + "field": "username", + "codeUnits": [ + 97, + 98, + 99, + 55296 + ], + "problems": [ + "username:invalid-characters" + ] + }, + { + "name": "lone surrogate in a field name", + "field": "key", + "codeUnits": [ + 97, + 55296 + ], + "problems": [ + "$:invalid-characters" + ] + }, + { + "name": "lone low surrogate as a whole field name", + "field": "key", + "codeUnits": [ + 56320 + ], + "problems": [ + "$:invalid-characters" + ] + }, + { + "name": "lone surrogate in a field name inside links", + "field": "linksKey", + "codeUnits": [ + 55296 + ], + "problems": [ + "links:invalid-characters" + ] + }, + { + "name": "lone surrogate in a project field name", + "field": "projectKey", + "codeUnits": [ + 56320 + ], + "problems": [ + "projects[0]:invalid-characters" + ] + }, + { + "name": "valid pair in a field name is only an unknown field", + "field": "key", + "codeUnits": [ + 97, + 55357, + 56832 + ], + "problems": [ + "a\ud83d\ude00:unknown-field" + ] + }, + { + "name": "valid pair in a username is well-formed but not allowed", + "field": "username", + "codeUnits": [ + 97, + 98, + 55357, + 56832 + ], + "problems": [ + "username:invalid-format" + ] + } + ] +} diff --git a/shared/portfolio/rules.json b/shared/portfolio/rules.json new file mode 100644 index 0000000..2b79153 --- /dev/null +++ b/shared/portfolio/rules.json @@ -0,0 +1,67 @@ +{ + "version": 1, + "requiredForPublish": ["name", "headline"], + "maxItems": { + "skills": 30, + "projects": 8, + "projects.technologies": 12, + "education": 5, + "experience": 6, + "achievements": 10 + }, + "maxLength": { + "name": 80, + "headline": 120, + "about": 1000, + "skills.item": 40, + "projects.title": 80, + "projects.description": 500, + "projects.technologies.item": 40, + "education.institution": 120, + "education.degree": 120, + "education.period": 40, + "education.details": 300, + "experience.organization": 120, + "experience.role": 120, + "experience.period": 40, + "experience.description": 500, + "achievements.item": 200, + "link": 300, + "email": 254 + }, + "multilineFields": [ + "about", + "projects.description", + "education.details", + "experience.description" + ], + "text": { + "controlCharacters": "[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f-\\u009f\\u200b-\\u200f\\u2028\\u2029\\u202a-\\u202e\\u2066-\\u2069\\ufeff]", + "htmlLike": "<[A-Za-z/!?]" + }, + "link": { + "scheme": "https://", + "forbiddenCharacters": "[\\u0000-\\u0020\\u0022\\u003c\\u003e\\u005c\\u0060\\u007f-\\u009f\\u200b-\\u200f\\u2028\\u2029\\u202a-\\u202e\\u2066-\\u2069\\ufeff]", + "hostPattern": "^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+(?:[a-z]{2,}|xn--[a-z0-9]+(?:-[a-z0-9]+)*)$", + "hostRestrictions": { + "links.github": ["github.com"], + "links.linkedin": ["linkedin.com"] + } + }, + "emailPattern": "^[A-Za-z0-9._%+-]{1,64}@(?:[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\\.)+[A-Za-z]{2,}$", + "username": { + "minLength": 3, + "maxLength": 30, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "reserved": [ + "u", "www", "api", "app", "admin", "administrator", "root", "mail", "email", "smtp", "ftp", + "docs", "blog", "login", "logout", "signin", "signup", "register", "auth", "account", + "support", "help", "contact", "about", "status", "static", "assets", "cdn", "dev", "test", + "staging", "preview", "dashboard", "courses", "learn", "practice", "games", "guides", "jobs", + "privacy", "terms", "disclaimer", "copyright", "portfolio", "portfolios", "procoderji", + "satyam", "robots", "sitemap", "favicon", "icon", "apple-icon", "logo", "brand", "og", + "new", "edit", "settings", "profile", "me", "null", "undefined" + ], + "blockedWords": ["paytm", "phonepe", "razorpay", "bank", "login", "verify", "password", "otp", "wallet"] + } +} diff --git a/src/features/portfolio/portfolio.test.ts b/src/features/portfolio/portfolio.test.ts new file mode 100644 index 0000000..1d632ce --- /dev/null +++ b/src/features/portfolio/portfolio.test.ts @@ -0,0 +1,148 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fixtures from "../../../shared/portfolio/fixtures.json" with { type: "json" }; +import { PORTFOLIO_RULES } from "./rules.ts"; +import { + problemKey, + validatePortfolio, + validateUsername, +} from "./validation.ts"; +import type { PortfolioMode } from "./types.ts"; + +// The backend runs the same fixtures (PortfolioValidatorTests). Add a case there first when +// a rule changes, so both runtimes keep agreeing. + +const keys = (problems: Parameters[0][]) => + problems.map(problemKey).sort(); + +test("every shared username fixture gives the expected problems", () => { + for (const fixture of fixtures.usernames) + assert.deepEqual( + keys(validateUsername(fixture.value)), + [...fixture.problems].sort(), + fixture.name, + ); +}); + +test("every shared portfolio fixture gives the expected problems", () => { + for (const fixture of fixtures.portfolios) + assert.deepEqual( + keys(validatePortfolio(fixture.value, fixture.mode as PortfolioMode)), + [...fixture.problems].sort(), + fixture.name, + ); +}); + +// Text with a lone surrogate cannot be stored in JSON that .NET parses, so these fixtures list +// the UTF-16 code units and each runtime builds the string itself. +test("every malformed-text fixture gives the expected problems", () => { + assert.ok(fixtures.malformedText.length > 0); + for (const fixture of fixtures.malformedText) { + const text = String.fromCharCode(...fixture.codeUnits); + const problems = + fixture.field === "username" + ? validateUsername(text) + : validatePortfolio(portfolioWith(fixture.field, text), "draft"); + assert.deepEqual(keys(problems), [...fixture.problems].sort(), fixture.name); + } +}); + +function portfolioWith(field: string, text: string): unknown { + switch (field) { + case "name": return { name: text }; + case "about": return { about: text }; + case "skill": return { skills: [text] }; + case "projectLink": return { projects: [{ title: "App", link: text }] }; + case "website": return { links: { website: text } }; + case "email": return { links: { email: text } }; + case "key": return { [text]: 1 }; + case "linksKey": return { links: { [text]: 1 } }; + case "projectKey": return { projects: [{ title: "App", [text]: 1 }] }; + default: throw new Error(`Unknown fixture field: ${field}`); + } +} + +test("lone surrogate escapes in parsed JSON text are problems", () => { + // The backslash comes from a char code so this source never holds a surrogate escape. + const escape = (hex: string) => String.fromCharCode(92) + "u" + hex; + const values = '{"name":"' + escape("d800") + '","skills":["' + escape("dc00") + '"]}'; + assert.deepEqual(keys(validatePortfolio(JSON.parse(values), "draft")), [ + "name:invalid-characters", + "skills[0]:invalid-characters", + ]); + const name = '{"links":{"' + escape("d800") + '":1}}'; + assert.deepEqual(keys(validatePortfolio(JSON.parse(name), "draft")), ["links:invalid-characters"]); +}); + +test("the fixtures cover valid and invalid input and both modes", () => { + assert.ok(fixtures.usernames.some((f) => f.problems.length === 0)); + assert.ok(fixtures.usernames.some((f) => f.problems.length > 0)); + assert.ok(fixtures.portfolios.some((f) => f.mode === "draft")); + assert.ok(fixtures.portfolios.some((f) => f.mode === "publish")); + assert.ok(fixtures.portfolios.some((f) => f.problems.length === 0)); +}); + +test("every problem code the validator can report appears in a fixture", () => { + const seen = new Set( + [...fixtures.usernames, ...fixtures.portfolios].flatMap((f) => + f.problems.map((p) => p.split(":")[1]), + ), + ); + for (const code of [ + "required", + "invalid-type", + "unknown-field", + "too-short", + "too-long", + "too-many", + "invalid-characters", + "html-like", + "invalid-format", + "invalid-link", + "invalid-host", + "invalid-email", + "reserved", + "blocked-word", + ]) + assert.ok(seen.has(code), `no fixture produces ${code}`); +}); + +test("reserved names cover every public route and the names listed in ADR 0002", () => { + const reserved = new Set(PORTFOLIO_RULES.username.reserved); + for (const name of [ + "www", "api", "app", "admin", "mail", "docs", "blog", "login", "support", + "help", "procoderji", "satyam", "u", "learn", "practice", "account", + "dashboard", "games", "guides", "jobs", "about", "privacy", "terms", + "disclaimer", "copyright", "courses", + ]) + assert.ok(reserved.has(name), name); +}); + +test("reserved names and blocked words are well-formed usernames in lowercase", () => { + const pattern = new RegExp(PORTFOLIO_RULES.username.pattern); + for (const name of PORTFOLIO_RULES.username.reserved) + assert.ok(pattern.test(name), `reserved name ${name} can never be claimed anyway`); + for (const word of PORTFOLIO_RULES.username.blockedWords) + assert.ok(/^[a-z0-9]+$/.test(word), `blocked word ${word} must be lowercase letters and digits`); +}); + +test("every limit in the rules is a positive whole number", () => { + for (const [key, max] of Object.entries(PORTFOLIO_RULES.maxLength)) + assert.ok(Number.isInteger(max) && max > 0, key); + for (const [key, max] of Object.entries(PORTFOLIO_RULES.maxItems)) + assert.ok(Number.isInteger(max) && max > 0, key); +}); + +test("validating does not modify the input", () => { + const input = { name: "Riya", skills: ["a"], links: { github: "https://github.com/riya" } }; + const copy = structuredClone(input); + validatePortfolio(input, "publish"); + assert.deepEqual(input, copy); +}); + +test("a huge value of the wrong type is rejected quickly", () => { + const started = Date.now(); + assert.deepEqual(keys(validatePortfolio({ name: "x".repeat(2_000_000) }, "draft")), ["name:too-long"]); + assert.deepEqual(keys(validatePortfolio({ links: { github: "https://" + "a".repeat(2_000_000) } }, "draft")), ["links.github:too-long"]); + assert.ok(Date.now() - started < 1_000); +}); diff --git a/src/features/portfolio/rules.ts b/src/features/portfolio/rules.ts new file mode 100644 index 0000000..e6e5db4 --- /dev/null +++ b/src/features/portfolio/rules.ts @@ -0,0 +1,30 @@ +import rulesData from "../../../shared/portfolio/rules.json" with { type: "json" }; + +/** + * The portfolio rules shared with the backend. Numbers, reserved names and patterns live in + * `shared/portfolio/rules.json`; the backend reads the same file. + */ +export interface PortfolioRules { + version: number; + requiredForPublish: readonly string[]; + maxItems: Readonly>; + maxLength: Readonly>; + multilineFields: readonly string[]; + text: { controlCharacters: string; htmlLike: string }; + link: { + scheme: string; + forbiddenCharacters: string; + hostPattern: string; + hostRestrictions: Readonly>; + }; + emailPattern: string; + username: { + minLength: number; + maxLength: number; + pattern: string; + reserved: readonly string[]; + blockedWords: readonly string[]; + }; +} + +export const PORTFOLIO_RULES: PortfolioRules = rulesData; diff --git a/src/features/portfolio/types.ts b/src/features/portfolio/types.ts new file mode 100644 index 0000000..8b80a3d --- /dev/null +++ b/src/features/portfolio/types.ts @@ -0,0 +1,65 @@ +export type PortfolioMode = "draft" | "publish"; + +export type PortfolioProblemCode = + | "required" + | "invalid-type" + | "unknown-field" + | "too-short" + | "too-long" + | "too-many" + | "invalid-characters" + | "html-like" + | "invalid-format" + | "invalid-link" + | "invalid-host" + | "invalid-email" + | "reserved" + | "blocked-word"; + +/** A validation problem at a field path such as `projects[0].link`. */ +export interface PortfolioProblem { + path: string; + code: PortfolioProblemCode; +} + +export interface PortfolioProject { + title: string; + link?: string; + technologies?: string[]; + description?: string; +} + +export interface PortfolioEducation { + institution: string; + degree?: string; + period?: string; + details?: string; +} + +export interface PortfolioExperience { + organization: string; + role: string; + period?: string; + description?: string; +} + +export interface PortfolioLinks { + github?: string; + linkedin?: string; + website?: string; + /** An email address. The page builds the `mailto:` link; students never store one. */ + email?: string; +} + +/** A valid portfolio. A draft may omit `name` and `headline`; a published one may not. */ +export interface PortfolioData { + name?: string; + headline?: string; + about?: string; + skills?: string[]; + projects?: PortfolioProject[]; + education?: PortfolioEducation[]; + experience?: PortfolioExperience[]; + achievements?: string[]; + links?: PortfolioLinks; +} diff --git a/src/features/portfolio/validation.ts b/src/features/portfolio/validation.ts new file mode 100644 index 0000000..7c26a62 --- /dev/null +++ b/src/features/portfolio/validation.ts @@ -0,0 +1,276 @@ +import { PORTFOLIO_RULES as RULES } from "./rules.ts"; +import type { + PortfolioMode, + PortfolioProblem, + PortfolioProblemCode, +} from "./types.ts"; + +/* + * Portfolio validation. The .NET validator in + * backend/src/ProCoderJi.Api/Features/Portfolio/ implements the same rules, and both run + * against shared/portfolio/fixtures.json. Keep the two in step: when a check changes, add a + * fixture first. The rules avoid URL parsers and locale-aware string functions on purpose, + * because those behave differently in JavaScript and .NET. + */ + +const CONTROL = new RegExp(RULES.text.controlCharacters); +const HTML_LIKE = new RegExp(RULES.text.htmlLike); +const LINK_FORBIDDEN = new RegExp(RULES.link.forbiddenCharacters); +const HOST = new RegExp(RULES.link.hostPattern); +const EMAIL = new RegExp(RULES.emailPattern); +const USERNAME = new RegExp(RULES.username.pattern); +const RESERVED = new Set(RULES.username.reserved); +const MULTILINE = new Set(RULES.multilineFields); +const BLANK = /^[ \t\r\n]*$/; +const LINE_BREAK = /[\r\n]/; +const AUTHORITY_END = /[/?#]/; + +type Problems = PortfolioProblem[]; +type Value = unknown; +type ItemCheck = (path: string, item: Value) => void; + +function add(problems: Problems, path: string, code: PortfolioProblemCode) { + problems.push({ path, code }); +} + +function limit(table: Readonly>, key: string): number { + const value = table[key]; + if (value === undefined) throw new Error(`Missing portfolio rule: ${key}`); + return value; +} + +/** Lower-cases A-Z only, so other characters such as the Kelvin sign are never folded. */ +function asciiLower(text: string): string { + return text.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32)); +} + +/** False when the text has a lone surrogate half, which no encoding can represent. */ +function isWellFormed(text: string): boolean { + for (let i = 0; i < text.length; i++) { + const unit = text.charCodeAt(i); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = text.charCodeAt(i + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) return false; + i++; + } else if (unit >= 0xdc00 && unit <= 0xdfff) return false; + } + return true; +} + +function isObject(value: Value): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function get(object: Record, key: string): Value { + return Object.hasOwn(object, key) ? object[key] : undefined; +} + +/** + * Reports unknown field names. Returns false, after reporting `invalid-characters` for the + * object itself, when a field name is not well-formed text; the caller then skips that object. + */ +function checkUnknownFields( + problems: Problems, + path: string, + object: Record, + allowed: readonly string[], +): boolean { + const keys = Object.keys(object); + if (keys.some((key) => !isWellFormed(key))) { + add(problems, path || "$", "invalid-characters"); + return false; + } + for (const key of keys) + if (!allowed.includes(key)) + add(problems, path ? `${path}.${key}` : key, "unknown-field"); + return true; +} + +function checkText( + problems: Problems, + path: string, + value: Value, + key: string, + required: boolean, +) { + if (value === undefined) { + if (required) add(problems, path, "required"); + return; + } + if (typeof value !== "string") return add(problems, path, "invalid-type"); + if (!isWellFormed(value)) return add(problems, path, "invalid-characters"); + // Length comes before the blank check, so a field of only spaces cannot exceed its limit. + if (value.length > limit(RULES.maxLength, key)) return add(problems, path, "too-long"); + if (BLANK.test(value)) { + if (required) add(problems, path, "required"); + return; + } + if (CONTROL.test(value) || (!MULTILINE.has(key) && LINE_BREAK.test(value))) + return add(problems, path, "invalid-characters"); + if (HTML_LIKE.test(value)) add(problems, path, "html-like"); +} + +/** An optional https link. `hostKey` selects the host restriction, such as `links.github`. */ +function checkLink( + problems: Problems, + path: string, + value: Value, + hostKey?: string, +) { + if (value === undefined) return; + if (typeof value !== "string") return add(problems, path, "invalid-type"); + if (!isWellFormed(value)) return add(problems, path, "invalid-characters"); + if (value.length > limit(RULES.maxLength, "link")) return add(problems, path, "too-long"); + if (BLANK.test(value)) return; + const scheme = RULES.link.scheme; + if ( + LINK_FORBIDDEN.test(value) || + asciiLower(value.slice(0, scheme.length)) !== scheme + ) + return add(problems, path, "invalid-link"); + const rest = value.slice(scheme.length); + const end = rest.search(AUTHORITY_END); + const authority = end === -1 ? rest : rest.slice(0, end); + // The host pattern allows only letters, digits, hyphens and dots, so credentials + // (`user@host`), ports (`host:8443`), IP literals and empty hosts all fail it. + const host = asciiLower(authority); + if (!HOST.test(host)) return add(problems, path, "invalid-link"); + const allowed = hostKey ? RULES.link.hostRestrictions[hostKey] : undefined; + if (allowed && !allowed.some((domain) => host === domain || host.endsWith(`.${domain}`))) + add(problems, path, "invalid-host"); +} + +function checkEmail(problems: Problems, path: string, value: Value) { + if (value === undefined) return; + if (typeof value !== "string") return add(problems, path, "invalid-type"); + if (!isWellFormed(value)) return add(problems, path, "invalid-characters"); + if (value.length > limit(RULES.maxLength, "email")) return add(problems, path, "too-long"); + if (BLANK.test(value)) return; + if (!EMAIL.test(value)) add(problems, path, "invalid-email"); +} + +function checkList( + problems: Problems, + path: string, + value: Value, + key: string, + checkItem: ItemCheck, +) { + if (value === undefined) return; + if (!Array.isArray(value)) return add(problems, path, "invalid-type"); + if (value.length > limit(RULES.maxItems, key)) return add(problems, path, "too-many"); + value.forEach((item, index) => checkItem(`${path}[${index}]`, item)); +} + +function checkObjectItem( + problems: Problems, + path: string, + item: Value, + allowed: readonly string[], + checkFields: (object: Record) => void, +) { + if (!isObject(item)) return add(problems, path, "invalid-type"); + if (checkUnknownFields(problems, path, item, allowed)) checkFields(item); +} + +/** Checks a username for the public address `/u/`. */ +export function validateUsername(value: unknown): PortfolioProblem[] { + const problems: Problems = []; + const path = "username"; + const rules = RULES.username; + if (typeof value !== "string") return [{ path, code: "invalid-type" }]; + if (!isWellFormed(value)) return [{ path, code: "invalid-characters" }]; + if (BLANK.test(value)) return [{ path, code: "required" }]; + if (value.length < rules.minLength) return [{ path, code: "too-short" }]; + if (value.length > rules.maxLength) return [{ path, code: "too-long" }]; + if (!USERNAME.test(value)) return [{ path, code: "invalid-format" }]; + if (RESERVED.has(value)) return [{ path, code: "reserved" }]; + const squashed = value.replaceAll("-", ""); + if (rules.blockedWords.some((word) => squashed.includes(word))) + add(problems, path, "blocked-word"); + return problems; +} + +/** + * Checks untrusted portfolio data. A draft only has to be well-formed; publishing also needs + * the fields listed in `requiredForPublish`. An empty result means the data is valid. + */ +export function validatePortfolio( + value: unknown, + mode: PortfolioMode, +): PortfolioProblem[] { + const problems: Problems = []; + if (!isObject(value)) return [{ path: "$", code: "invalid-type" }]; + const rootOk = checkUnknownFields(problems, "", value, [ + "name", + "headline", + "about", + "skills", + "projects", + "education", + "experience", + "achievements", + "links", + ]); + if (!rootOk) return problems; + + const requiredOnPublish = (key: string) => + mode === "publish" && RULES.requiredForPublish.includes(key); + checkText(problems, "name", get(value, "name"), "name", requiredOnPublish("name")); + checkText(problems, "headline", get(value, "headline"), "headline", requiredOnPublish("headline")); + checkText(problems, "about", get(value, "about"), "about", false); + + checkList(problems, "skills", get(value, "skills"), "skills", (path, item) => + checkText(problems, path, item, "skills.item", true), + ); + + checkList(problems, "projects", get(value, "projects"), "projects", (path, item) => + checkObjectItem(problems, path, item, ["title", "link", "technologies", "description"], (project) => { + checkText(problems, `${path}.title`, get(project, "title"), "projects.title", true); + checkLink(problems, `${path}.link`, get(project, "link")); + checkList(problems, `${path}.technologies`, get(project, "technologies"), "projects.technologies", (techPath, tech) => + checkText(problems, techPath, tech, "projects.technologies.item", true), + ); + checkText(problems, `${path}.description`, get(project, "description"), "projects.description", false); + }), + ); + + checkList(problems, "education", get(value, "education"), "education", (path, item) => + checkObjectItem(problems, path, item, ["institution", "degree", "period", "details"], (entry) => { + checkText(problems, `${path}.institution`, get(entry, "institution"), "education.institution", true); + checkText(problems, `${path}.degree`, get(entry, "degree"), "education.degree", false); + checkText(problems, `${path}.period`, get(entry, "period"), "education.period", false); + checkText(problems, `${path}.details`, get(entry, "details"), "education.details", false); + }), + ); + + checkList(problems, "experience", get(value, "experience"), "experience", (path, item) => + checkObjectItem(problems, path, item, ["organization", "role", "period", "description"], (entry) => { + checkText(problems, `${path}.organization`, get(entry, "organization"), "experience.organization", true); + checkText(problems, `${path}.role`, get(entry, "role"), "experience.role", true); + checkText(problems, `${path}.period`, get(entry, "period"), "experience.period", false); + checkText(problems, `${path}.description`, get(entry, "description"), "experience.description", false); + }), + ); + + checkList(problems, "achievements", get(value, "achievements"), "achievements", (path, item) => + checkText(problems, path, item, "achievements.item", true), + ); + + const links = get(value, "links"); + if (links !== undefined) { + if (!isObject(links)) add(problems, "links", "invalid-type"); + else if (checkUnknownFields(problems, "links", links, ["github", "linkedin", "website", "email"])) { + checkLink(problems, "links.github", get(links, "github"), "links.github"); + checkLink(problems, "links.linkedin", get(links, "linkedin"), "links.linkedin"); + checkLink(problems, "links.website", get(links, "website")); + checkEmail(problems, "links.email", get(links, "email")); + } + } + return problems; +} + +/** The stable `path:code` form used by the shared fixtures and for messages. */ +export function problemKey(problem: PortfolioProblem): string { + return `${problem.path}:${problem.code}`; +}