ms102-study

MS-102 Exam Prep

CI License: MIT Next.js Tests

A local study and practice-exam application for Exam MS-102: Microsoft 365 Administrator, built from a raw PDF question dump that is extracted, normalized, classified against Microsoft’s official skills outline, and — where checked — verified against Microsoft Learn.

Everything runs offline in the browser. No backend, no accounts, no telemetry, no network calls at runtime.

Before publishing this repository, read NOTICE.md. The question content is derived from a third-party PDF whose copyright status is unresolved. The code is MIT-licensed; the content is not covered and cannot be. NOTICE.md explains how to publish the tooling while withholding the derived content.

Replace OWNER/REPO in the CI badge above once the remote exists.

Quick start

npm install
npm run dev

Open http://localhost:3000. The generated question bank is committed, so the Python pipeline is only needed to regenerate it.

npm run verify

Runs lint, type check, unit tests and a production build.


Deploying to GitHub Pages

.github/workflows/pages.yml builds a static export and publishes it. Enable it under Settings → Pages → Source → GitHub Actions, then push to main.

A GitHub Pages site is public, even when the repository is private. Publishing this app puts all 283 questions and 404 exhibit images on the open internet. Read NOTICE.md first — that is precisely the exposure it describes. Pages for private repositories also requires a paid GitHub plan; on the free tier the repository must be public.

To try the app without publishing anything, build and serve it locally instead:

npm run build && npm start

Three things are needed to make a Next app work on a Pages project site, all handled by npm run build:pages:

Problem Handled by
Pages hosts static files only output: 'export' via NEXT_OUTPUT=export
Site is served from /<repo>/, not the domain root basePath/assetPrefix from NEXT_PUBLIC_BASE_PATH
Jekyll silently deletes _next/, so no CSS or JS loads .nojekyll, written by scripts/prepare-pages.mjs

Exhibit images need one extra step. They render with plain <img>, and Next’s basePath does not rewrite a raw src attribute — so all 404 of them would 404 on a sub-path. assetPath() in src/lib/assets.ts prefixes them, and tests/assets.test.ts covers it.

scripts/prepare-pages.mjs also flattens Next 16’s client prefetch payloads. The export writes them as dashboard/__next.dashboard/__PAGE__.txt while the browser requests dashboard/__next.dashboard.__PAGE__.txt; a dev server resolves both, a static host does not.

To check the export locally before pushing:

NEXT_PUBLIC_BASE_PATH=/your-repo-name npm run build:pages
npx serve out          # then browse the site

Honest status of the content

Read this before trusting any answer in the app.

  Count
Questions imported 283 (from 276 PDF pages)
Available in training mode 283 — the whole bank
Available in exam mode 162 — the automatically scorable subset
Graphic answer areas transcribed into interactive controls 7 of 128
Questions with exhibit artwork reproduced from the PDF 164 (306 images)
Image-only questions whose answer-key graphic was recovered 128
Verified against Microsoft Learn 28
Corrected — the source PDF’s answer was wrong 5
Ambiguous — product behaviour has since broadened 1
Outdated — the feature the question tests has been retired 1
Not yet checked against documentation 248
Product names modernized to current Microsoft branding 99

Terminology modernization

99 questions used retired Microsoft branding. Product names are updated to current terminology at build time from data/terminology.json, whose rename map comes from Microsoft’s own Entra rename glossary and Defender XDR overview.

Main renames applied: Azure AD → Microsoft Entra ID · Azure AD tenant → Microsoft Entra tenant · Azure AD Connect → Microsoft Entra Connect · Azure AD Identity Protection → Microsoft Entra ID Protection · Microsoft 365 Defender → Microsoft Defender XDR · Microsoft 365 Defender portal → Microsoft Defender portal · Microsoft Purview compliance portal → Microsoft Purview portal · Microsoft Endpoint Manager → Microsoft Intune.

Nineteen questions additionally carry a deprecation advisory shown inline with a documentation link, covering the retired Purview compliance portal, classic Content Search (retired 31 Aug 2025), the retired MSOnline and Azure AD PowerShell modules, and the retired Azure Information Protection unified labeling client.

Guarantees, each enforced by a unit test in tests/terminology.test.ts:

Four questions additionally carry a deprecation advisory (retired MSOnline / Azure AD PowerShell modules, retired Azure Information Protection unified labeling client) shown inline with a documentation link, rather than having their text silently rewritten.

Verify the pass at any time:

python scripts/check_terminology.py

Source answer conflicts found

Three groups of questions share an identical prompt but are given contradictory answers by the source PDF. All three were resolved against current Microsoft documentation:

Topic Source said Documentation says
DNS record to verify a domain TXT / MX / CNAME / CNAME TXT (MX only as fallback); CNAME is never used
Role to review Identity Protection risky users Security Reader / Security Admin / Reports Reader Reports Reader has no ID Protection access at all
Portal to create a mail-enabled contact Entra / Exchange / M365 admin center Exchange admin center is the documented path

Answer keys corrupted by the community-vote widget

The source PDF sometimes typesets the community-vote percentage on the same line as the answer:

Correct Answer: D                              A (31%)

The original parser read that trailing A as a second answer, silently turning 24 single-choice questions into multi-answer ones that could never be answered correctly. The parser now strips vote fragments and keeps only the first whitespace-separated group. Two regression tests guard it: no question may carry multiple answers without a multi-select cue in its prompt, and no answer key may repeat a letter.

A fifth correction was found outside those groups: MS102-0020 asked which tool compares Safe Links configuration to Microsoft’s recommended settings. The source answered Microsoft Secure Score; the documented tool is the configuration analyzer, which compares threat policies against the Standard and Strict baselines and can apply the recommendation.


Requirements

Install and run

npm install
npm run dev

Then open http://localhost:3000. The generated question bank is committed under src/data/question-bank.json, so the app runs without the Python pipeline.

For a production build:

npm run build && npm start

Verify everything

npm run verify

Runs lint, type check, unit tests, and a production build in sequence.

End-to-end tests are separate because they need a browser:

npx playwright install chromium chromium-headless-shell && npm run test:e2e

If that download is slow or blocked, run against a browser already on the machine instead — no download required:

PW_CHANNEL=msedge npm run test:e2e

On PowerShell: $env:PW_CHANNEL="msedge"; npm run test:e2e. chrome also works. The E2E suite starts its own production server on port 3200, or reuses one already running there.


Regenerating the question bank

The pipeline is three deterministic Python stages. Re-run the whole thing with:

npm run data:build
Stage Script Output
Extract text scripts/extract_pdf.py data/questions.raw.json, data/extraction-report.json
Classify scripts/classify.py data/questions.classified.json
Extract artwork scripts/extract_exhibits.py public/exhibits/*.png, data/exhibits.json
Merge + modernize + validate scripts/build_bank.py data/question-bank.json, src/data/question-bank.json
Report scripts/write_report.py docs/extraction-report.md

How exhibit images are classified

Every embedded image is assigned to the question whose header most recently precedes it in reading order, then split into two groups. The Correct Answer: label is rendered to the left of the answer graphic rather than above it, so its y-coordinate falls inside the image’s vertical span — classification therefore compares the image’s bottom edge against the label’s top:

public/exhibits/ holds 418 PNGs, about 29 MB. Images are deduplicated by content, so a graphic shared between questions is stored once.

Terminology modernization happens inside the merge stage, driven by data/terminology.json. scripts/check_terminology.py audits the result independently.

The source PDF is expected at ../MS-102_Questions_Answers.pdf relative to the project root.

Transcribing a graphic answer area into interactive controls

data/interactive.json turns a HOTSPOT/DRAG-DROP graphic into real controls. Read the question’s answer-key image in public/exhibits/, then add an entry:

"MS102-0010": {
  "kind": "yes-no",
  "instructions": "For each of the following statements, select Yes if the statement is true...",
  "replacesExhibitIndexes": [1],
  "segments": [
    { "label": "On July 15, 2023, Admin1 can reset the password of a user.",
      "options": ["Yes", "No"], "correct": "Yes" }
  ]
}

Run npm run data:build. The question becomes scorable, joins the exam pool, and is graded per segment — every segment must match for the question to count as correct, with a per-segment verdict shown either way. The build fails if a segment’s correct value is not one of its own options, and validateBank re-checks it at runtime.

Adding a verification

data/verifications.json is the only hand-maintained content file. Everything else is generated.

  1. Add an entry keyed by question id:

    "MS102-0042": {
      "status": "Verified",
      "verifiedAnswer": ["B"],
      "verificationMethod": "web-fetch",
      "verifiedOn": "2026-08-10",
      "explanation": "Why the correct answer is correct.",
      "optionExplanations": { "A": "Why A is wrong.", "B": "Correct. ..." },
      "documentation": [
        { "title": "Page title", "url": "https://learn.microsoft.com/...", "lastUpdated": "2026-05-08" }
      ],
      "flags": []
    }
    
  2. Run npm run data:build.

The build fails rather than emitting bad data if:

Questions with no entry stay Needs review. There is no way to mark something verified by accident.


Architecture

scripts/            Python extraction, classification and bank-build pipeline
data/               Generated artefacts + hand-maintained verification & terminology maps
src/lib/            Pure domain logic — answers, filters, scoring, session, validation, storage
src/components/     Shared UI; QuestionParts.tsx enforces the answer-masking boundary
src/app/            Next.js App Router pages
tests/              Vitest unit tests
e2e/                Playwright end-to-end tests
Route Purpose
/ Quiz launcher — mode, topics, order, question count
/quiz Session runner for both modes; ?q=<id> opens one question in training mode
/dashboard Bank health, accuracy, weakest objectives, recent activity
/review Incorrect / bookmarked / low-confidence / spaced / data-quality queues
/bank Searchable admin table, provenance, JSON + CSV export

Training mode vs exam mode

  Training Exam
Feedback Immediately after each answer Withheld until submission
Rationale — why right, why each distractor is wrong Yes Yes, on the review screen
Microsoft Learn reference links Yes No — withheld, including on the review screen
Question pool All 283 155 — only questions that can be scored automatically
Timer No Yes, 90 minutes
Question count Configurable 50, domain-weighted
Order Configurable Always randomized
Exhibit artwork Yes Yes
Answer-key graphic for image-only questions On demand n/a — excluded from exams
Notes and confidence rating Yes No

The link rule lives in one place — showsDocumentation() in src/lib/session.ts — and is asserted end-to-end in e2e/practice-exam.spec.ts. In exam mode the review screen shows a short note explaining that links are available in training mode instead of silently omitting them.

The rationale panel renders only answer reasoning. Case-study background appears once, in a collapsible panel above the prompt, and is never repeated inside the explanation.

Question metadata — id, verification status, question type, objective — is intentionally not shown on the question itself; it is study-irrelevant chrome that competes with the question. It remains visible in the question-bank and review tables, and the “not independently verified” warning still appears with the rationale after submitting, where it actually affects a decision.

How answers stay hidden until submission

maskQuestion() in src/lib/answers.ts returns a MaskedQuestion — a type that structurally has no isCorrect field and no explanations. The pre-submission OptionPicker component only accepts a MaskedQuestion, so correctness data cannot reach the DOM before a submit even by mistake. A unit test serialises a masked question and asserts the string contains no correctness signal.

Persistence

Progress, bookmarks, notes, confidence ratings, theme and exam history live in localStorage under ms102-study-state-v1. Nothing leaves the machine; there is no backend and no telemetry.

Accessibility


Security posture

The question bank is derived from an untrusted third-party PDF, so the untrusted input is treated as data throughout.

Source PDF — scanned for active content: no JavaScript (/JS, /JavaScript), no embedded file attachments, no /OpenAction, /Launch, /XFA or /SubmitForm. It contains one ordinary link annotation to the source website. Nothing in the PDF is executed; only text and image streams are read.

Extracted images — all 404 PNGs verified: correct magic bytes, IEND present, zero bytes appended after IEND (no polyglot files), and only IHDR/pHYs/iCCP/IDAT/IEND chunks — no tEXt, zTXt, iTXt or eXIf metadata chunks that could carry a payload.

Rendering — the app makes no network requests at runtime, uses no eval/new Function, and has a single dangerouslySetInnerHTML: a static, non-interpolated theme script in layout.tsx. Question text is rendered as React children, so it is escaped. The two sinks React does not make safe are constrained instead:

Persisted statelocalStorage is user-editable and survives across app versions, so loadState validates its shape, not just its presence, and drops malformed entries individually. A try/catch around JSON.parse is not sufficient on its own: a structurally wrong but syntactically valid document parses cleanly and then throws later during render, where nothing catches it.

Regex safety — the extraction and classification patterns run over text from the untrusted PDF, so they are checked for catastrophic backtracking. Three flagged patterns were benchmarked against adversarial input (n=20,000) and measured linear at ~7 ms; they are allowlisted with that justification rather than silently ignored.

Run the scans any time:

npm run security:scan

That runs two checks. security_scan.py inspects the generated bank for markup/script payloads, off-domain citation URLs, unexpected image paths, and that every referenced image exists and is a real PNG. check_regex_safety.py flags nested-quantifier and unbounded-wildcard patterns. Both exit 1 on any finding.

Dependenciesnpm audit reports 0 vulnerabilities. sharp is pinned forward with an override even though the app never invokes it (exhibit artwork uses plain <img>, not next/image).

Toolchain versions

Everything is on its latest release except two, which are held back because the lint toolchain genuinely breaks on them — verified, not assumed:

Package On Latest Why held
eslint 9.39.5 10.8.1 eslint-plugin-react bundled in eslint-config-next 16 declares peer <=9 and throws in detectReactVersion under 10
typescript 6.0.3 7.0.2 TS 7 typechecks this project cleanly, but typescript-eslint refuses to load against it, breaking npm run lint (issue 10940)

Revisit both when eslint-config-next ships support. Everything else is current: Next 16.3.0, React 19.2.8, Vitest 4.1.10, Vite 8.2.1, jsdom 30, Playwright 1.62.1, @types/node 26.

Known limitations