Skip to content

test(frontend): cover PII masking edge cases - #2177

Open
Nife-tanny wants to merge 2 commits into
LabsCrypt:mainfrom
Nife-tanny:test/2146-pii-mask-edge-cases
Open

Nife-tanny wants to merge 2 commits into
LabsCrypt:mainfrom
Nife-tanny:test/2146-pii-mask-edge-cases

Conversation

@Nife-tanny

Copy link
Copy Markdown
Contributor

Summary

Adds deterministic, table-driven tests for frontend/src/app/utils/piiMask.ts (maskRecipient for email / phone / name, and maskAddress). They cover success, short-identifier, Unicode, already-masked, failure/error-contract and boundary paths. Adds a CI step so the suite actually runs. The tests exposed three small masking bugs, which are fixed minimally (see Bug fixes).

The existing 15 assertions are kept unchanged. Suite: 152 passing + 2 it.todo (previously 15).

Closes #2146

Bug fixes in piiMask.ts (minimal; ASCII output unchanged)

  1. Lone surrogates / garbage output. The code indexed by UTF-16 code unit (value[0], slice(-1), .length), so astral characters were cut in half. For example, maskRecipient("😀", "name") returned "\uD83D***\uDE00", and "Bob😀" ended in a lone low surrogate. All four masking paths now split with Array.from (code points). A "character" is now a Unicode code point. Combining marks and ZWJ sequences count per code point, so output never contains a lone surrogate.
  2. maskAddress revealed every character at the threshold. A 10-character input passed the < 10 guard and came back as first 6 + "..." + last 4, which is all 10 characters. The guard is now <= 10, so at least one character is always hidden. Real 56-character Stellar G…/C… addresses are unaffected.
  3. Wrong-type input threw TypeError. Numbers, booleans, objects and symbols crashed some paths (e.g. .indexOf/.slice is not a function) and returned inconsistent values on others (e.g. [] as phone → "****"). Both functions now return "***" for any non-string, the same as for empty/nullish input. I added short JSDoc so this never-throws contract is documented.

Call sites: maskRecipient/maskAddress are not imported anywhere in frontend/src outside this file and its test, so no runtime behavior changes. The 113 non-Unicode/non-wrong-type tests pass against both the original and the fixed implementation. That confirms ASCII output is byte-for-byte unchanged.

Test cases by category

Category email phone name maskAddress
Success plus-tags, co.uk, subdomain, mixed case E.164, spaced, (415) 555-0199 multi-word, hyphen/apostrophe, upper case Stellar G… account, C… contract, lower case
Short identifiers local part of length 0–4 reveals ≤1 char; output identical for lengths 1/2/3/20 (length not leaked) lengths 1–6 around the 4-char threshold; loop over 1–16 asserts only the last 2 chars ever appear "", 1, 2, 3 chars; output fixed-width for any length ≥2 loop over lengths 0–20: ≤10 → ***, >10 always hides ≥1 char; explicit 10/11 boundary
Unicode accented (precomposed + combining), CJK, Arabic, emoji in local and domain, astral CJK 𠮷, ZWJ family Arabic-Indic digits, emoji at the 2-char tail, emoji around the threshold accented, combining mark, CJK, Arabic, astral CJK, emoji, skin-tone modifier, ZWJ family emoji, Cyrillic, mixed ASCII + emoji
Already masked own output idempotent; ***, ***@*** never reveal more +xx...****34, **** idempotent J***e, * idempotent; *** → ***** own output idempotent
Failure / error contract no @, whitespace-only, @, a@, no dot in domain, empty label, multiple @, @@ non-numeric input (documents that phones aren't validated, only masked) — —
Wrong types (all fields) null, undefined, 0, number, NaN, booleans, object, arrays, symbol → "***", never throws; unknown field type → "***" ← ← same wrong-type table
Boundaries leading/trailing whitespace (not trimmed), 10k-char input whitespace, 10k-char input whitespace, 10k-char input whitespace, 10k-char input

Every Unicode case asserts both the exact output and that it contains no lone surrogate.

Not applicable:

  • Options / reveal-count edge values: neither function takes options; the reveal counts are fixed.
  • Authorization paths: piiMask.ts has no role or permission logic. The admin/operator gate for unmasking lives in src/app/api/recipients/[id]/reveal/route.ts, which doesn't use piiMask, so it's out of scope here.

Follow-up (marked it.todo, not changed here)

Two existing assertions show every character of very short values. Only the length is hidden by the fixed-width ***:

  • maskRecipient("Jo", "name") → "J***o" (also e.g. "李娜" → "李***娜", a common name length in CJK)
  • maskRecipient("a@b.com", "email") → "a***@b***.com"

These are existing documented behavior, so I left them for maintainers to decide on. They're marked with it.todo in the test file.

CI

The frontend job in ci.yml is currently a stub (echo "Frontend lint, typecheck, tests, and build passed", since d077546). That means no frontend tests run in CI today. Restoring the whole job would turn CI red because of the pre-existing failures listed below. So this PR adds Node 22 setup, npm ci, and a step that runs npx jest src/app/utils/piiMask.test.ts. That way these tests are enforced without blocking on unrelated breakage. The full job can replace this once the suite is green.

Testing done

Run locally in frontend/ (Node 24, engines: >=22):

Check Result
npx jest src/app/utils/piiMask.test.ts ×5, plus --randomize and a run with TZ=Asia/Tokyo LC_ALL=ar_EG.UTF-8 ✅ 152 passed, 2 todo every run
New tests against the original piiMask.ts 39 fail (the bugs above), 113 pass (ASCII behavior unchanged)
npx jest (full suite) 500 passed, 14 failed / 7 suites. Identical failures to main (main: 363 passed, the same 14 failed)
npx prettier --check on changed files (incl. ci.yml) ✅
npx eslint on changed files ✅
npm run typecheck 14 errors, identical to main, none in changed files
npm run build compiles ✅, then fails at its type-check step on the same 14 pre-existing errors

Pre-existing failures on main (unrelated, not touched)

  • Jest: reveal.route.test.ts, StepFinalSignature.test.tsx, session.test.ts, useToastStore.test.ts import vitest, which isn't installed. StepFinalSignature.onchain.test.tsx can't resolve ../../utils/soroban. soroban.networkSigner.test.tsx fails with NETWORK_PASSPHRASES is not defined. LoanRepaymentForm.payoff.test.tsx has 10 failures.
  • tsc / build: WalletProvider.tsx (NETWORK_PASSPHRASES undefined), duplicate extensionFee in useApi.ts, plus type errors in the test files above.
  • npm run lint (prettier --check .): 8 unformatted files, e.g. useApi.ts, CollateralActionModal.tsx, e2e/accessibility.spec.ts.

Checklist

  • I have read the CONTRIBUTING.md document.
  • My code follows the code style of this project.
  • I have added tests to cover my changes.
  • All new and existing tests passed. (all new tests pass; the existing failures listed above are pre-existing on main)
  • I have updated the documentation accordingly. (JSDoc on both functions)
  • I have verified the changes locally.

🤖 Generated with Claude Code

Nife-tanny and others added 2 commits October 22, 2026 13:17
Add table-driven tests for maskRecipient (email/phone/name) and
maskAddress covering realistic values, short identifiers around each
reveal threshold, Unicode (accented, CJK, RTL, astral, emoji ZWJ and
skin-tone sequences, combining marks), already-masked input, malformed
and wrong-type input, whitespace, mixed case and very long input.

Fix the masking bugs the tests exposed:
- index by code point so astral characters are never split into lone
  surrogate halves
- maskAddress no longer reveals every character of a 10-char input
- non-string input returns "***" instead of throwing

Run the piiMask suite in the frontend CI job, which is currently a stub.

Closes LabsCrypt#2146

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…edge-cases

# Conflicts:
#	.github/workflows/ci.yml

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[test] Cover frontend PII masking edge cases

1 participant