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.

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.

The source setting is not optional. If it is left on Deploy from a branch, GitHub ignores the workflow entirely and runs Jekyll over the repository root instead — which publishes this README rendered as HTML rather than the app. The giveaway is a page of prose with no styling, a <title> ending in the repository name, and no _next/ assets in the served HTML.

A custom domain needs DNS first. Setting one in Settings → Pages → Custom domain (or committing a CNAME file) makes GitHub redirect the working *.github.io URL to that hostname. If no DNS record points it at <owner>.github.io, the redirect leads nowhere and the site is unreachable at both addresses. Add the DNS record, confirm it resolves, then set the domain.

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 160 (299 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 109

Terminology modernization

109 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:

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

A further 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.

The source PDF also typesets its community-vote percentages on the same line as the answer key (Correct Answer: D A (31%)). The extractor strips those fragments, and two regression tests guard the result: no question may carry multiple answers without a multi-select cue in its prompt, and no answer key may repeat a letter.


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 a sequence of deterministic 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 src/data/question-bank.json
Report scripts/write_report.py docs/extraction-report.md

Only the finished bank is committed. The intermediates (questions.raw.json, questions.classified.json, extraction-report.json) and the generated report are build outputs and are git-ignored — they are reproduced by the command above.

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 404 PNGs, about 28 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/               Hand-maintained inputs — verifications, terminology, taxonomy, interactive
src/data/           The generated question bank the app imports
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 162 — 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 state — localStorage 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.

Dependencies — npm 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). .github/dependabot.yml opens weekly PRs for npm packages and GitHub Actions, so advisories surface without waiting for someone to run an audit.

OWASP coverage

The app is static, offline, has no backend, no accounts and no credentials, so much of the Top 10 has no surface here. What applies is enforced, and asserted by tests/security.test.ts:

Category Position
A01 Broken access control No auth surface. Answer disclosure is the analogous control and is enforced structurally by MaskedQuestion.
A02 Cryptographic failures No secrets, no credentials, no PII. localStorage holds study progress only, and never leaves the device.
A03 Injection React escapes all rendered text. The two sinks it does not make safe — citation href and exhibit src — are constrained by the build and re-checked at runtime. A CSP is declared.
A05 Security misconfiguration CSP and Referrer-Policy set; no blanket lint suppressions; no env files committed.
A06 Vulnerable components npm audit in CI at --audit-level=high, plus Dependabot.
A08 Integrity failures Workflows run least-privilege, never use pull_request_target, and actions/checkout uses persist-credentials: false so the job token is not left in .git/config.
A04, A07, A09, A10 No server, session, or outbound request to attack.

Content Security Policy. GitHub Pages cannot set response headers, so the policy ships as a meta tag. Verified in a browser: cross-origin fetch and remote <script> injection are both blocked, while the app remains fully functional.

Two limits are inherent to the meta form and worth knowing rather than assuming covered:

Held-back dependencies

Two majors are pinned because the lint toolchain does not yet work on them. Dependabot is configured to skip both so it does not reopen the same PR; the reason is recorded alongside each in package.json.

Package Why held
eslint eslint-plugin-react, bundled in eslint-config-next 16, declares peer <=9 and throws in detectReactVersion under 10
typescript 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. Exact versions live in package.json.

Known limitations