Skip to content

Commit

Permalink
Move isRealJSONParse to its own file (#1829)
Browse files Browse the repository at this point in the history
This enables the logic to be tested.

Issue: none

## Test plan:

`yarn test`

Author: benchristel

Reviewers: mark-fitzgerald, handeyeco, Myranae

Required Reviewers:

Approved By: mark-fitzgerald

Checks: ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x), ✅ gerald

Pull Request URL: #1829
  • Loading branch information
benchristel authored Nov 6, 2024
1 parent 8aaf296 commit f6b66b0
Show file tree
Hide file tree
Showing 4 changed files with 114 additions and 86 deletions.
5 changes: 5 additions & 0 deletions .changeset/sharp-pianos-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus": patch
---

Internal: Refactor parsePerseusItem and add tests
17 changes: 17 additions & 0 deletions packages/perseus/src/util/is-real-json-parse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {isRealJSONParse} from "./is-real-json-parse";

describe("isRealJSONParse", () => {
it("returns false given a function that messes with itemData", () => {
function fakeJSONParse(json: string) {
const parsed = JSON.parse(json);
parsed.data.assessmentItem.item.itemData = "";
return parsed;
}

expect(isRealJSONParse(fakeJSONParse)).toBe(false);
});

it("returns true given the native JSON.parse function", () => {
expect(isRealJSONParse(JSON.parse)).toBe(true);
});
});
88 changes: 88 additions & 0 deletions packages/perseus/src/util/is-real-json-parse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import Util from "../util";

const deepEq = Util.deepEq;

export function isRealJSONParse(jsonParse: typeof JSON.parse): boolean {
const randomPhrase = buildRandomPhrase();
const randomHintPhrase = buildRandomPhrase();
const randomString = buildRandomString();
const testingObject = JSON.stringify({
answerArea: {
calculator: false,
chi2Table: false,
financialCalculatorMonthlyPayment: false,
financialCalculatorTimeToPayOff: false,
financialCalculatorTotalAmount: false,
periodicTable: false,
periodicTableWithKey: false,
tTable: false,
zTable: false,
},
hints: [randomHintPhrase, `=${Math.floor(Math.random() * 50) + 1}`],
itemDataVersion: {major: 0, minor: 1},
question: {
content: `${randomPhrase}`,
images: {},
widgets: {
expression1: {
alignment: "default",
graded: false,
options: {
answerForms: [
{
considered: "wrong",
form: false,
key: 0,
simplify: false,
value: `${randomString}`,
},
],
ariaLabel: "Answer",
buttonSets: ["basic"],
functions: ["f", "g", "h"],
static: true,
times: false,
visibleLabel: "Answer",
},
static: true,
type: "expression",
version: {major: 1, minor: 0},
},
},
},
});
const testJSON = buildTestData(testingObject.replace(/"/g, '\\"'));
const parsedTestJSON = jsonParse(testJSON);
const parsedTestItemData: string =
parsedTestJSON.data.assessmentItem.item.itemData;
return deepEq(parsedTestItemData, testingObject);
}

function buildRandomString(capitalize: boolean = false) {
let randomString: string = "";
const randomLength = Math.floor(Math.random() * 8) + 3;
for (let i = 0; i < randomLength; i++) {
const randomLetter = String.fromCharCode(
97 + Math.floor(Math.random() * 26),
);
randomString +=
capitalize && i === 0 ? randomLetter.toUpperCase() : randomLetter;
}
return randomString;
}

function buildRandomPhrase() {
const phrases: string[] = [];
const randomLength = Math.floor(Math.random() * 10) + 5;
for (let i = 0; i < randomLength; i++) {
phrases.push(buildRandomString(i === 0));
}
const modifierStart = ["**", "$"];
const modifierEnd = ["**", "$"];
const modifierIndex = Math.floor(Math.random() * modifierStart.length);
return `${modifierStart[modifierIndex]}${phrases.join(" ")}${modifierEnd[modifierIndex]}`;
}

function buildTestData(testObject: string) {
return `{"data":{"assessmentItem":{"__typename":"AssessmentItemOrError","error":null,"item":{"__typename":"AssessmentItem","id":"x890b3c70f3e8f4a6","itemData":"${testObject}","problemType":"Type 1","sha":"c7284a3ad65214b4e62bccce236d92f7f5d35941"}}}}`;
}
90 changes: 4 additions & 86 deletions packages/perseus/src/util/parse-perseus-json.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import Util from "../util";
import {isRealJSONParse} from "./is-real-json-parse";

import type {PerseusItem} from "../perseus-types";

const deepEq = Util.deepEq;

/**
* Helper to parse PerseusItem JSON
* Why not just use JSON.parse? We want:
Expand All @@ -13,90 +11,10 @@ const deepEq = Util.deepEq;
* @returns {PerseusItem} the parsed PerseusItem object
*/
export function parsePerseusItem(json: string): PerseusItem {
const randomPhrase = buildRandomPhrase();
const randomHintPhrase = buildRandomPhrase();
const randomString = buildRandomString();
const testingObject = JSON.stringify({
answerArea: {
calculator: false,
chi2Table: false,
financialCalculatorMonthlyPayment: false,
financialCalculatorTimeToPayOff: false,
financialCalculatorTotalAmount: false,
periodicTable: false,
periodicTableWithKey: false,
tTable: false,
zTable: false,
},
hints: [randomHintPhrase, `=${Math.floor(Math.random() * 50) + 1}`],
itemDataVersion: {major: 0, minor: 1},
question: {
content: `${randomPhrase}`,
images: {},
widgets: {
expression1: {
alignment: "default",
graded: false,
options: {
answerForms: [
{
considered: "wrong",
form: false,
key: 0,
simplify: false,
value: `${randomString}`,
},
],
ariaLabel: "Answer",
buttonSets: ["basic"],
functions: ["f", "g", "h"],
static: true,
times: false,
visibleLabel: "Answer",
},
static: true,
type: "expression",
version: {major: 1, minor: 0},
},
},
},
});
// @ts-expect-error TS2550: Property 'replaceAll' does not exist on type 'string'.
const testJSON = buildTestData(testingObject.replaceAll('"', '\\"'));
const parsedJSON = JSON.parse(testJSON);
const parsedItemData: string = parsedJSON.data.assessmentItem.item.itemData;
const isNotCheating = deepEq(parsedItemData, testingObject);
if (isNotCheating) {
// Try to block a cheating vector which relies on monkey-patching
// JSON.parse
if (isRealJSONParse(JSON.parse)) {
return JSON.parse(json);
}
throw new Error("Something went wrong.");
}

function buildRandomString(capitalize: boolean = false) {
let randomString: string = "";
const randomLength = Math.floor(Math.random() * 8) + 3;
for (let i = 0; i < randomLength; i++) {
const randomLetter = String.fromCharCode(
97 + Math.floor(Math.random() * 26),
);
randomString +=
capitalize && i === 0 ? randomLetter.toUpperCase() : randomLetter;
}
return randomString;
}

function buildRandomPhrase() {
const phrases: string[] = [];
const randomLength = Math.floor(Math.random() * 10) + 5;
for (let i = 0; i < randomLength; i++) {
phrases.push(buildRandomString(i === 0));
}
const modifierStart = ["**", "$"];
const modifierEnd = ["**", "$"];
const modifierIndex = Math.floor(Math.random() * modifierStart.length);
return `${modifierStart[modifierIndex]}${phrases.join(" ")}${modifierEnd[modifierIndex]}`;
}

function buildTestData(testObject: string) {
return `{"data":{"assessmentItem":{"__typename":"AssessmentItemOrError","error":null,"item":{"__typename":"AssessmentItem","id":"x890b3c70f3e8f4a6","itemData":"${testObject}","problemType":"Type 1","sha":"c7284a3ad65214b4e62bccce236d92f7f5d35941"}}}}`;
}

0 comments on commit f6b66b0

Please sign in to comment.