Fix PAN generation: random prefix within range, Mastercard series option

Two bugs in generateBrandPan:
1. For networks with multiple prefix rules, always picked the same rule
   (Mastercard always 2-series, AmEx always 37, Discover always 6011)
2. Always used the start of the range as the prefix, so Mastercard
   generated 51xxxxx or 2221xxxxxx every time instead of any value
   in 51-55 or 2221-2720

Fix both: pick a random prefix rule and a random prefix within start..end.

Add a "Mastercard series" arg to PAN Generate so callers can explicitly
request 5-series (51-55), 2-series (2221-2720), or leave it random.
The curated sample path is unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
J8k3 2026-05-18 14:18:22 -04:00
parent eba7e37185
commit 0da4c99a0c
3 changed files with 33 additions and 17 deletions

View File

@ -6,6 +6,7 @@
import OperationError from "../errors/OperationError.mjs";
const PAN_BRANDS = ["Visa", "Mastercard", "American Express", "Discover"];
const MASTERCARD_SERIES = ["Any", "5-series (51-55)", "2-series (2221-2720)"];
// ── Card classification tables ────────────────────────────────────────────────
@ -264,26 +265,33 @@ function fillerDigits(length) {
*
* @param {string} brand
* @param {number} requestedLength
* @param {string} mastercardSeries - "Any", "5-series (51-55)", or "2-series (2221-2720)"
* @returns {{pan: string, prefixDescription: string}}
*/
function generateBrandPan(brand, requestedLength) {
function generateBrandPan(brand, requestedLength, mastercardSeries = "Any") {
const config = PAN_BRAND_RULES[brand];
if (!config) {
throw new OperationError("Unsupported payment network.");
}
const length = config.lengths.includes(requestedLength) ? requestedLength : config.lengths[0];
let selectedRule = config.prefixes[0];
const eligibleRules = config.prefixes.filter(r => r.lengths.includes(length));
if (brand === "Mastercard" && length === 16) {
selectedRule = config.prefixes[1];
} else if (brand === "American Express") {
selectedRule = config.prefixes[1];
} else if (brand === "Discover") {
let selectedRule;
if (brand === "Mastercard") {
if (mastercardSeries === "5-series (51-55)") {
selectedRule = config.prefixes[0];
} else if (mastercardSeries === "2-series (2221-2720)") {
selectedRule = config.prefixes[1];
} else {
selectedRule = eligibleRules[Math.floor(Math.random() * eligibleRules.length)];
}
} else {
selectedRule = eligibleRules[Math.floor(Math.random() * eligibleRules.length)];
}
const prefix = String(selectedRule.start);
const prefixValue = selectedRule.start + Math.floor(Math.random() * (selectedRule.end - selectedRule.start + 1));
const prefix = String(prefixValue);
const bodyLength = length - 1;
const body = `${prefix}${fillerDigits(bodyLength - prefix.length)}`.substring(0, bodyLength);
@ -299,9 +307,10 @@ function generateBrandPan(brand, requestedLength) {
* @param {string} brand
* @param {string} mode
* @param {number} length
* @param {string} mastercardSeries
* @returns {Object}
*/
function generateTestPan(brand, mode, length) {
function generateTestPan(brand, mode, length, mastercardSeries = "Any") {
const config = PAN_BRAND_RULES[brand];
if (!config) {
throw new OperationError("Unsupported payment network.");
@ -318,7 +327,7 @@ function generateTestPan(brand, mode, length) {
};
}
const generated = generateBrandPan(brand, Number(length) || config.lengths[0]);
const generated = generateBrandPan(brand, Number(length) || config.lengths[0], mastercardSeries);
const parsed = parsePan(generated.pan);
return {
brand,
@ -332,6 +341,7 @@ function generateTestPan(brand, mode, length) {
export {
PAN_BRANDS,
MASTERCARD_SERIES,
generateTestPan,
isLuhnValid,
parsePan,

View File

@ -4,7 +4,7 @@
*/
import Operation from "../Operation.mjs";
import { PAN_BRANDS, generateTestPan } from "../lib/Pan.mjs";
import { PAN_BRANDS, MASTERCARD_SERIES, generateTestPan } from "../lib/Pan.mjs";
/**
* Generate test PAN operation.
@ -24,7 +24,7 @@ class GenerateTestPAN extends Operation {
{
name: "Visa curated sample",
input: "",
args: ["Visa", "Curated sample", 16, true]
args: ["Visa", "Curated sample", 16, "Any", true]
}
];
this.infoURL = "https://en.wikipedia.org/wiki/Payment_card_number";
@ -51,6 +51,12 @@ class GenerateTestPAN extends Operation {
max: 19,
comment: "Used only in generated mode. Networks that do not support the requested length fall back to their first supported length."
},
{
name: "Mastercard series",
type: "option",
value: MASTERCARD_SERIES,
comment: "Applies only when Network is Mastercard in generated mode. '5-series' restricts to the 5155 range. '2-series' restricts to 22212720. 'Any' picks randomly between both ranges."
},
{
name: "Output as JSON",
type: "boolean",
@ -66,8 +72,8 @@ class GenerateTestPAN extends Operation {
* @returns {string}
*/
run(input, args) {
const [brand, mode, length, outputJson] = args;
const result = generateTestPan(brand, mode, length);
const [brand, mode, length, mastercardSeries, outputJson] = args;
const result = generateTestPan(brand, mode, length, mastercardSeries);
return outputJson ? JSON.stringify(result, null, 4) : result.pan;
}
}

View File

@ -416,7 +416,7 @@ TestRegister.addTests([
recipeConfig: [
{
op: "PAN Generate",
args: ["Visa", "Curated sample", 16, true]
args: ["Visa", "Curated sample", 16, "Any", true]
}
]
},
@ -427,7 +427,7 @@ TestRegister.addTests([
recipeConfig: [
{
op: "PAN Generate",
args: ["American Express", "Curated sample", 15, false]
args: ["American Express", "Curated sample", 15, "Any", false]
}
]
},