diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3f66a64..a385d15 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -28,7 +28,15 @@ jobs:
run: dotnet build dirs.proj -c Release
- name: Test
- run: dotnet test dirs.proj -c Release --no-build
+ run: >
+ dotnet test dirs.proj -c Release --no-build
+ --collect:"XPlat Code Coverage" --results-directory TestResults/coverage
+
+ # Coverage is a ratchet, not a target: the floors in build/coverage-floors.json sit just under
+ # the numbers at the time they were recorded, so an unrelated change cannot trip them while a
+ # real regression still fails the build. Raise a floor when coverage improves.
+ - name: Coverage floors
+ run: python3 build/coverage-summary.py --results TestResults/coverage
studio:
name: Studio build & test
@@ -52,6 +60,10 @@ jobs:
- name: Test (type-check + Vitest)
run: npm test
+ # Thresholds live in vite.config.ts alongside the rest of the Vitest setup.
+ - name: Coverage floors
+ run: npm run coverage
+
- name: Build (production bundle)
run: npm run build
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 006ec1d..c207bee 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -191,3 +191,57 @@ jobs:
# Publish last: this is when GitHub freezes the release, with every asset attached.
gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" \
--draft=false --prerelease="$pre" --latest="$latest"
+
+ # release-please only ever cuts the exact tag (v0.7.1), but the action is documented as
+ # `uses: hacks4snacks/tmforge@v0.7` (action.yml, docs/deployment.md, docs/analysis-rules.md),
+ # so the floating refs have to be maintained here or the documented usage resolves to nothing.
+ # Skipped for prereleases, and a tag only moves when this release is the newest in its own
+ # series, so re-releasing an older patch line cannot drag a floating tag backwards.
+ - name: Update floating major and major.minor tags
+ if: steps.meta.outputs.prerelease != 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ version="${GITHUB_REF_NAME#v}"
+ major="v${version%%.*}"
+ rest="${version#*.}"
+ minor="$major.${rest%%.*}"
+
+ # Resolve through the tag object so an annotated tag still yields the commit.
+ target="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_REF_NAME" --jq .sha)"
+
+ # Every released version tag, oldest first. The three-component filter drops the
+ # floating tags themselves and every prerelease tag.
+ released="$(gh api --paginate "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" \
+ --jq '.[].ref' \
+ | sed 's:^refs/tags/::' \
+ | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
+ | sort -V)"
+
+ move() {
+ alias="$1"
+ newest=""
+ while read -r tag; do
+ case "$tag" in "$alias".*) newest="$tag" ;; esac
+ done <<< "$released"
+
+ if [ "$newest" != "$GITHUB_REF_NAME" ]; then
+ echo "Leaving $alias where it is: $newest is newer than $GITHUB_REF_NAME."
+ return
+ fi
+
+ if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$alias" >/dev/null 2>&1; then
+ gh api --method PATCH "repos/$GITHUB_REPOSITORY/git/refs/tags/$alias" \
+ -f "sha=$target" -F force=true >/dev/null
+ echo "Moved $alias to $target ($GITHUB_REF_NAME)."
+ else
+ gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \
+ -f "ref=refs/tags/$alias" -f "sha=$target" >/dev/null
+ echo "Created $alias at $target ($GITHUB_REF_NAME)."
+ fi
+ }
+
+ move "$major"
+ move "$minor"
diff --git a/Directory.Packages.props b/Directory.Packages.props
index f4ce790..c333e5d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -10,6 +10,10 @@
+
+
diff --git a/Makefile b/Makefile
index 05da4d2..45a215e 100644
--- a/Makefile
+++ b/Makefile
@@ -78,7 +78,7 @@ version: ## Print the resolved project version
# ===========================================================================
# Build & test
# ===========================================================================
-.PHONY: restore build test test-studio format format-check
+.PHONY: restore build test test-studio format format-check coverage coverage-accept
restore: ## Restore NuGet packages
$(DOTNET) restore $(TRAVERSAL)
@@ -91,6 +91,17 @@ test: ## Run the .NET test suite
test-studio: ## Run the Studio type-check + Vitest suite
cd $(STUDIO_DIR) && $(NPM) ci && $(NPM) test
+coverage: ## Measure coverage on both sides and enforce the committed floors
+ $(DOTNET) test $(TRAVERSAL) -c $(CONFIG) \
+ --collect:"XPlat Code Coverage" --results-directory TestResults/coverage
+ python3 build/coverage-summary.py --results TestResults/coverage
+ cd $(STUDIO_DIR) && $(NPM) run coverage
+
+coverage-accept: ## Rewrite the .NET floors from the current run (after a deliberate improvement)
+ $(DOTNET) test $(TRAVERSAL) -c $(CONFIG) \
+ --collect:"XPlat Code Coverage" --results-directory TestResults/coverage
+ python3 build/coverage-summary.py --results TestResults/coverage --update-floors
+
format: ## Apply `dotnet format` (whitespace, style, analyzers)
$(DOTNET) format $(SOLUTION)
diff --git a/action.yml b/action.yml
index 847a445..3d41866 100644
--- a/action.yml
+++ b/action.yml
@@ -16,9 +16,9 @@
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
-# - uses: hacks4snacks/tmforge@v0.3 # pin the action ref
+# - uses: hacks4snacks/tmforge@v0.7 # pin the action ref
# with:
-# version: '0.3' # pin the engine image tag too
+# version: '0.7' # pin the engine image tag too
# models: '**/*.tm7'
# max-severity: warning
#
diff --git a/build/coverage-floors.json b/build/coverage-floors.json
new file mode 100644
index 0000000..3f90069
--- /dev/null
+++ b/build/coverage-floors.json
@@ -0,0 +1,16 @@
+{
+ "_comment": "Minimum line coverage per project, recorded from a RELEASE run \u2014 the configuration CI enforces. Release reports far fewer coverable lines than Debug (optimized-away sequence points), so its percentages read several points higher; regenerating these from a Debug run would leave the ratchet slack. Raise a floor as coverage improves; never lower one without saying why in the pull request. Regenerate with `make coverage-accept`.",
+ "total": 86.5,
+ "projects": {
+ "ThreatModelForge.Analysis": 90.8,
+ "ThreatModelForge.Analysis.Reporting": 97.9,
+ "ThreatModelForge.Analysis.Rules": 96.3,
+ "ThreatModelForge.Api": 98.9,
+ "ThreatModelForge.Cli": 69.5,
+ "ThreatModelForge.Core": 95.8,
+ "ThreatModelForge.Editing": 88.6,
+ "ThreatModelForge.Engine": 89.9,
+ "ThreatModelForge.Formats": 93.2,
+ "ThreatModelForge.Reporting": 92.7
+ }
+}
diff --git a/build/coverage-summary.py b/build/coverage-summary.py
new file mode 100644
index 0000000..0e9c3d0
--- /dev/null
+++ b/build/coverage-summary.py
@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+"""Summarize .NET code coverage and enforce the committed floors.
+
+Reads every `coverage.cobertura.xml` produced by
+`dotnet test --collect:"XPlat Code Coverage"`, prints a per-project table, and fails when a project
+has slipped below the minimum recorded in `build/coverage-floors.json`.
+
+Two details handled here:
+
+* Each report's `filename` values are relative to *that report's* own `` root, and the root
+ differs between reports. Joining against the wrong root silently invents projects and misattributes
+ files, which makes the whole table quietly wrong rather than obviously broken.
+* Generated sources (anything under `obj/`, `*.Designer.cs`, `*.generated.cs`) are excluded. The API
+ project alone carries ~1,850 lines of generated OpenAPI support, which would otherwise dominate its
+ score and mask the hand-written code the floors exist to protect.
+"""
+from __future__ import annotations
+
+import argparse
+import glob
+import json
+import os
+import sys
+import xml.etree.ElementTree as ET
+from collections import defaultdict
+
+GENERATED_MARKERS = ("/obj/", ".generated.cs", ".g.cs", ".Designer.cs")
+
+
+def is_generated(path: str) -> bool:
+ return any(marker in path for marker in GENERATED_MARKERS)
+
+
+def collect(results_dir: str, repo_root: str) -> dict[str, dict[int, int]]:
+ """Returns {repo-relative file: {line number: hits}}, unioned across every report."""
+ files: dict[str, dict[int, int]] = defaultdict(dict)
+ reports = glob.glob(os.path.join(results_dir, "**", "coverage.cobertura.xml"), recursive=True)
+ if not reports:
+ raise SystemExit(f"No coverage reports under {results_dir}. Did the test run collect coverage?")
+
+ for report in reports:
+ root = ET.parse(report).getroot()
+ sources = [s.text for s in root.iter("source") if s.text]
+ base = sources[0] if sources else repo_root
+ for cls in root.iter("class"):
+ raw = (cls.get("filename") or "").replace("\\", "/")
+ if not raw:
+ continue
+ absolute = raw if raw.startswith("/") else os.path.normpath(os.path.join(base, raw))
+ relative = os.path.relpath(absolute, repo_root)
+ for line in cls.iter("line"):
+ number = int(line.get("number"))
+ hits = int(line.get("hits"))
+ files[relative][number] = max(files[relative].get(number, 0), hits)
+ return files
+
+
+def project_of(path: str) -> str:
+ parts = path.split("/")
+ return parts[1] if len(parts) > 1 and parts[0] == "src" else parts[0]
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--results", default="TestResults/coverage", help="where the reports were written")
+ parser.add_argument("--floors", default="build/coverage-floors.json", help="committed minimums")
+ parser.add_argument("--repo-root", default=".", help="repository root the reports are relative to")
+ parser.add_argument("--update-floors", action="store_true", help="rewrite the floors to today's numbers")
+ args = parser.parse_args()
+
+ repo_root = os.path.abspath(args.repo_root)
+ files = collect(os.path.abspath(args.results), repo_root)
+
+ totals: dict[str, list[int]] = defaultdict(lambda: [0, 0])
+ for path, lines in files.items():
+ if path.startswith("test/") or is_generated(path):
+ continue
+ covered = sum(1 for hits in lines.values() if hits > 0)
+ totals[project_of(path)][0] += covered
+ totals[project_of(path)][1] += len(lines)
+
+ if not totals:
+ raise SystemExit("No hand-written source found in the coverage reports.")
+
+ percentages = {name: 100.0 * c / t for name, (c, t) in totals.items() if t}
+ grand_covered = sum(c for c, _ in totals.values())
+ grand_total = sum(t for _, t in totals.values())
+ grand = 100.0 * grand_covered / grand_total
+
+ floors_path = os.path.join(repo_root, args.floors)
+ if args.update_floors:
+ payload = {
+ "_comment": "Minimum line coverage per project, recorded from a RELEASE run — the "
+ "configuration CI enforces. Release reports far fewer coverable lines than "
+ "Debug (optimized-away sequence points), so its percentages read several "
+ "points higher; regenerating these from a Debug run would leave the ratchet "
+ "slack. Raise a floor as coverage improves; never lower one without saying "
+ "why in the pull request. Regenerate with `make coverage-accept`.",
+ "total": round(grand - 0.5, 1),
+ "projects": {name: round(value - 0.5, 1) for name, value in sorted(percentages.items())},
+ }
+ with open(floors_path, "w", encoding="utf-8") as handle:
+ json.dump(payload, handle, indent=2)
+ handle.write("\n")
+ print(f"Wrote {args.floors} from the current run.")
+ return 0
+
+ with open(floors_path, encoding="utf-8") as handle:
+ floors = json.load(handle)
+
+ print(f"{'PROJECT':<40} {'LINES':>13} {'LINE%':>7} {'FLOOR':>7} ")
+ print("-" * 74)
+ failures: list[str] = []
+ for name in sorted(percentages, key=lambda key: percentages[key]):
+ covered, total = totals[name]
+ floor = floors.get("projects", {}).get(name)
+ actual = percentages[name]
+ marker = ""
+ if floor is not None and actual + 1e-9 < floor:
+ marker = " BELOW FLOOR"
+ failures.append(f"{name}: {actual:.1f}% is under its {floor:.1f}% floor")
+ print(f"{name:<40} {covered:>5}/{total:<7} {actual:>7.1f} {floor if floor is not None else '-':>7}{marker}")
+
+ print("-" * 74)
+ total_floor = floors.get("total")
+ print(f"{'TOTAL':<40} {grand_covered:>5}/{grand_total:<7} {grand:>7.1f} "
+ f"{total_floor if total_floor is not None else '-':>7}")
+ if total_floor is not None and grand + 1e-9 < total_floor:
+ failures.append(f"total: {grand:.1f}% is under the {total_floor:.1f}% floor")
+
+ # A floor is only enforced for a project that actually reported coverage, so a project whose tests
+ # stopped running would drop out of the table and take its floor with it — the run would go green
+ # on the very regression the floors exist to catch. Treat a floor with no measurement as a failure.
+ for name in sorted(floors.get("projects", {})):
+ if name not in percentages:
+ failures.append(
+ f"{name}: has a floor but produced no coverage — its tests did not run, "
+ f"or the project was renamed or removed")
+
+ if failures:
+ print()
+ for failure in failures:
+ print(f"::error::Coverage regressed — {failure}")
+ return 1
+
+ print("\nEvery project is at or above its floor.")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/docs/analysis-rules.md b/docs/analysis-rules.md
index ffae4fd..370d07b 100644
--- a/docs/analysis-rules.md
+++ b/docs/analysis-rules.md
@@ -650,9 +650,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: hacks4snacks/tmforge@v0.3
+ - uses: hacks4snacks/tmforge@v0.7
with:
- version: "0.3" # pin the engine image, not just the action
+ version: "0.7" # pin the engine image, not just the action
models: "**/*.tm7"
rules: rules/corporate.tmrules.json
suppression-file: .tmforge/suppressions.json
@@ -690,7 +690,7 @@ system. Drift detection covers that gap: it reports a change that touched archit
without touching a threat model.
```yaml
- - uses: hacks4snacks/tmforge@v0.3
+ - uses: hacks4snacks/tmforge@v0.7
with:
drift: notice # 'off', 'notice' (default), or 'fail'
drift-watched-paths: |
@@ -756,7 +756,7 @@ Drift asks whether the model was updated. Review shows **how** it changed, so a
have to read a diff of serialized XML:
```yaml
- - uses: hacks4snacks/tmforge@v0.3
+ - uses: hacks4snacks/tmforge@v0.7
with:
review: "on"
review-comment: "true" # optional; needs pull-requests: write
diff --git a/docs/api-reference.md b/docs/api-reference.md
index 39db466..1c4bf3c 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -49,6 +49,32 @@ and `/openapi` are matched first.
`` is one of `tm7`, `tmforge-json`, `drawio`, or `vsdx`. See
[Formats & interoperability](formats.md).
+## Errors
+
+Failures are answered as [RFC 9457 problem documents](https://www.rfc-editor.org/rfc/rfc9457)
+(`application/problem+json`), and the status separates what you can fix from what you cannot:
+
+| Status | Meaning | Examples |
+| --- | --- | --- |
+| `400` | The request was unusable as sent. Retrying it unchanged cannot help. | Body that is not JSON; a missing `?to=`; an unregistered format id; content that is not valid base64; uploaded bytes that are not the format they claim to be. |
+| `404` | The path is not part of the `/v1` API, or the content was not recognized. | A mistyped endpoint; `POST /v1/detect` on bytes matching no known format. |
+| `500` | The server failed. This one is worth reporting. | Anything unexpected — the classification above is deliberately narrow, so a real fault is never disguised as your mistake. |
+
+The `detail` of a `400` names what was unusable, so the request can be corrected without guesswork:
+
+```json
+{
+ "status": 400,
+ "title": "The request could not be processed as sent.",
+ "detail": "No threat model format with id 'nonsense'.",
+ "instance": "/v1/model/convert"
+}
+```
+
+An unmatched path under `/v1` answers `404` as the API rather than falling through to the Studio's
+HTML shell, so a mistyped endpoint fails as a client error instead of returning a page a JSON client
+cannot parse. Paths outside `/v1` still reach the SPA, which is what makes client-side routing work.
+
## One analysis action
Findings and threats are the same detection: a threat is a finding from a rule that declares a threat
diff --git a/docs/deployment.md b/docs/deployment.md
index 06b762d..3b61e53 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -212,9 +212,9 @@ permissions:
steps:
- uses: actions/checkout@v4
- - uses: hacks4snacks/tmforge@v0.3
+ - uses: hacks4snacks/tmforge@v0.7
with:
- version: "0.3" # pin the engine image, not just the action ref
+ version: "0.7" # pin the engine image, not just the action ref
models: "**/*.tm7"
max-severity: warning
```
diff --git a/src/ThreatModelForge.Api/InputErrorHandler.cs b/src/ThreatModelForge.Api/InputErrorHandler.cs
new file mode 100644
index 0000000..bd42101
--- /dev/null
+++ b/src/ThreatModelForge.Api/InputErrorHandler.cs
@@ -0,0 +1,90 @@
+namespace ThreatModelForge.Api
+{
+ using System;
+ using System.Text.Json;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.AspNetCore.Diagnostics;
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Mvc;
+
+ ///
+ /// Answers 400 for the failures that mean the caller sent something unusable, instead of letting
+ /// them surface as 500. The distinction matters to anyone driving this API: a 500 says the server
+ /// broke and invites a retry, while these are all cases where retrying the same request cannot
+ /// help — an unknown format id, an unreadable body, malformed base64.
+ ///
+ /// Classification is by exception type, and deliberately narrow: anything not listed here is left
+ /// alone and still becomes a 500, because an unexpected failure really is the server's fault and
+ /// should not be reported as the caller's.
+ ///
+ ///
+ internal sealed class InputErrorHandler : IExceptionHandler
+ {
+ /// Writes a problem response when the failure was caused by the request.
+ /// The request context.
+ /// The unhandled exception.
+ /// The cancellation token.
+ /// True when the exception was handled.
+ public async ValueTask TryHandleAsync(
+ HttpContext httpContext,
+ Exception exception,
+ CancellationToken cancellationToken)
+ {
+ if (httpContext == null || exception == null || !IsCallerInput(exception))
+ {
+ return false;
+ }
+
+ // Model binding already decides a status for the requests it rejects (a malformed body, a
+ // missing required query value). Keep it: installing this handler must not downgrade a
+ // request the framework had already classified correctly.
+ httpContext.Response.StatusCode = exception is BadHttpRequestException badRequest
+ ? badRequest.StatusCode
+ : StatusCodes.Status400BadRequest;
+
+ // The message is echoed because for these types it describes the caller's own input (the
+ // format id they named, where their JSON stopped parsing). No stack trace is ever included.
+ await httpContext.Response.WriteAsJsonAsync(
+ new ProblemDetails
+ {
+ Status = httpContext.Response.StatusCode,
+ Title = "The request could not be processed as sent.",
+ Detail = exception.Message,
+ Instance = httpContext.Request.Path,
+ },
+ options: null,
+ contentType: "application/problem+json",
+ cancellationToken).ConfigureAwait(false);
+ return true;
+ }
+
+ /// Reports whether a failure was caused by the request rather than by the server.
+ /// The unhandled exception.
+ /// True when the caller's input caused it.
+ private static bool IsCallerInput(Exception exception)
+ {
+ switch (exception)
+ {
+ // Model binding rejected the request before it reached an endpoint: a body that is not
+ // JSON, or a missing required query value. It carries its own status code.
+ case BadHttpRequestException:
+
+ // An unregistered format id, on ?to=, ?format=, or a read request.
+ case NotSupportedException:
+
+ // A missing or empty required value that reached the engine.
+ case ArgumentException:
+
+ // Content that is not valid base64.
+ case FormatException:
+
+ // Uploaded bytes that are not the model document they claim to be.
+ case JsonException:
+ return true;
+ default:
+ return false;
+ }
+ }
+ }
+}
diff --git a/src/ThreatModelForge.Api/Program.cs b/src/ThreatModelForge.Api/Program.cs
index 1406e90..cae8291 100644
--- a/src/ThreatModelForge.Api/Program.cs
+++ b/src/ThreatModelForge.Api/Program.cs
@@ -35,7 +35,15 @@ public static void Main(string[] args)
// truth for the API contract; the React client's types are generated from it.
builder.Services.AddOpenApi();
+ // Report a caller's bad input as 400 rather than 500. See InputErrorHandler for what
+ // counts as the caller's fault; everything else still surfaces as a server error.
+ builder.Services.AddProblemDetails();
+ builder.Services.AddExceptionHandler();
+
WebApplication app = builder.Build();
+
+ // First in the pipeline so it wraps every endpoint below.
+ app.UseExceptionHandler();
app.UseCors();
// Custom rule packs are deployment configuration, not request input: they are named by the
@@ -139,6 +147,15 @@ public static void Main(string[] args)
.WithName("DetectFormat")
.WithTags("Formats");
+ // An unmatched /v1 path is a wrong API call, not a client-side route: answer it as the API
+ // rather than letting the SPA fallback below serve index.html with a 200. A caller that
+ // mistypes an endpoint gets a 404 it can act on instead of HTML it will fail to parse.
+ app.MapFallback("/v1/{**rest}", (HttpContext context) => TypedResults.Problem(
+ title: "No such endpoint.",
+ detail: $"{context.Request.Method} {context.Request.Path} is not part of the /v1 API.",
+ statusCode: StatusCodes.Status404NotFound,
+ instance: context.Request.Path));
+
// Serve the Studio SPA's entry document for any non-API path so client-side routes
// resolve. The /v1 and /openapi endpoints are matched first, so this only catches the rest.
app.MapFallbackToFile("index.html");
diff --git a/src/ThreatModelForge.Api/ThreatModelForge.Api.csproj b/src/ThreatModelForge.Api/ThreatModelForge.Api.csproj
index e068ad2..9aa870f 100644
--- a/src/ThreatModelForge.Api/ThreatModelForge.Api.csproj
+++ b/src/ThreatModelForge.Api/ThreatModelForge.Api.csproj
@@ -20,6 +20,13 @@
--file-name v1
+
+
+
+
+
diff --git a/src/ThreatModelForge.Cli/McpPathPolicy.cs b/src/ThreatModelForge.Cli/McpPathPolicy.cs
index cc26167..cb6abc2 100644
--- a/src/ThreatModelForge.Cli/McpPathPolicy.cs
+++ b/src/ThreatModelForge.Cli/McpPathPolicy.cs
@@ -88,7 +88,8 @@ public byte[] ReadAllBytes(string path)
using FileStream input = new FileStream(resolved, FileMode.Open, FileAccess.Read, FileShare.Read);
if (input.Length > this.MaxReadBytes)
{
- throw new IOException($"Model file exceeds the MCP read limit of {this.MaxReadBytes} bytes.");
+ throw new IOException(
+ $"Model file is {input.Length} bytes, over the MCP read limit of {this.MaxReadBytes} bytes.");
}
using MemoryStream output = new MemoryStream((int)input.Length);
@@ -100,7 +101,10 @@ public byte[] ReadAllBytes(string path)
total += count;
if (total > this.MaxReadBytes)
{
- throw new IOException($"Model file exceeds the MCP read limit of {this.MaxReadBytes} bytes.");
+ // Reached only when the file delivered more than it reported — it grew mid-read, or
+ // its length cannot be trusted. Worth saying separately from the size check above.
+ throw new IOException(
+ $"Model file grew past the MCP read limit of {this.MaxReadBytes} bytes while it was being read.");
}
output.Write(buffer.AsSpan(0, count));
@@ -133,7 +137,8 @@ public void ValidateExpandedContent(byte[] content, string? formatId)
{
if (entry.Length > this.MaxReadBytes - expandedBytes)
{
- throw new InvalidDataException($"Expanded Visio package exceeds the MCP read limit of {this.MaxReadBytes} bytes.");
+ throw new InvalidDataException(
+ $"Visio package entry declares {entry.Length} bytes, over the remaining MCP read budget of {this.MaxReadBytes - expandedBytes} bytes.");
}
using Stream entryStream = entry.Open();
@@ -142,7 +147,10 @@ public void ValidateExpandedContent(byte[] content, string? formatId)
{
if (count > this.MaxReadBytes - expandedBytes)
{
- throw new InvalidDataException($"Expanded Visio package exceeds the MCP read limit of {this.MaxReadBytes} bytes.");
+ // Reached only when an entry expands past the size it declared, which is the
+ // shape of a bomb with a lying header rather than a merely large package.
+ throw new InvalidDataException(
+ $"Visio package entry expanded past the MCP read budget of {this.MaxReadBytes} bytes while it was being read.");
}
expandedBytes += count;
diff --git a/src/ThreatModelForge.Studio/package-lock.json b/src/ThreatModelForge.Studio/package-lock.json
index cf80770..e86d46f 100644
--- a/src/ThreatModelForge.Studio/package-lock.json
+++ b/src/ThreatModelForge.Studio/package-lock.json
@@ -20,6 +20,7 @@
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
+ "@vitest/coverage-v8": "^3.2.7",
"jsdom": "^29.1.1",
"openapi-typescript": "^7.13.0",
"typescript": "^5.9.3",
@@ -34,6 +35,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@ampproject/remapping": {
+ "version": "2.3.0",
+ "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@ampproject/remapping/-/remapping-2.3.0.tgz",
+ "integrity": "sha1-7UQbb6YAByUgzhi0PSyMyMrsx/Q=",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
@@ -100,6 +115,16 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha1-fwhx2Zgk0jE31g+G/PYTD9WhtR8=",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
@@ -110,6 +135,22 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/parser": {
+ "version": "7.29.7",
+ "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha1-g3uHOHy/XsVTDLY0s8Yi9o7bkzQ=",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
@@ -120,6 +161,30 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/types": {
+ "version": "7.29.7",
+ "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha1-gAXjHYJxLuetrvbiPGO3GmJ3CpI=",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha1-u+EtyltO+YOg0K9LB7m8kOoKuro=",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@@ -767,6 +832,55 @@
}
}
},
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@isaacs/cliui/-/cliui-8.0.2.tgz",
+ "integrity": "sha1-s3Znt7wYHBaHgiWbq0JHT79StVA=",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@istanbuljs/schema": {
+ "version": "0.1.6",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@istanbuljs/schema/-/schema-0.1.6.tgz",
+ "integrity": "sha1-jcmvoqwVBssaWPiZQPHBJERsjfM=",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha1-Y0Khn0Q0dRjJPkOxrGnes8Rlah8=",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha1-eg7mAfYPmaIMfHxf8MgDiMEYm9Y=",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@@ -774,6 +888,17 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha1-2xXWeByTHzolGj2sOVAcmKYIL9A=",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -803,6 +928,17 @@
"url": "https://github.com/sponsors/Boshen"
}
},
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha1-p36nQvqyV3UUVDTrHSMoz1ATrDM=",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/@redocly/ajv": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
@@ -1733,6 +1869,40 @@
}
}
},
+ "node_modules/@vitest/coverage-v8": {
+ "version": "3.2.7",
+ "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/coverage-v8/-/coverage-v8-3.2.7.tgz",
+ "integrity": "sha1-LpzhEDRFwjeqpCCn8AWBJf5KeFQ=",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@ampproject/remapping": "^2.3.0",
+ "@bcoe/v8-coverage": "^1.0.2",
+ "ast-v8-to-istanbul": "^0.3.3",
+ "debug": "^4.4.1",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-lib-source-maps": "^5.0.6",
+ "istanbul-reports": "^3.1.7",
+ "magic-string": "^0.30.17",
+ "magicast": "^0.3.5",
+ "std-env": "^3.9.0",
+ "test-exclude": "^7.0.1",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "3.2.7",
+ "vitest": "3.2.7"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@vitest/expect": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
@@ -1933,6 +2103,25 @@
"node": ">=12"
}
},
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "0.3.12",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz",
+ "integrity": "sha1-jrG3yG74SZhZvnYbF//ZFAbAw28=",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha1-3/51mbSou3/jCv+NAjUjTf+3mDE=",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -2010,6 +2199,26 @@
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha1-ctOmjVmMm9s68q0ehPIdiWq9TeM=",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha1-wqCah6y95pVD3m9j+jmVyCbFNqI=",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/colorette": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
@@ -2017,6 +2226,21 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha1-ilj+ePANzXDDcEUXWd+/rwPo7p8=",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
@@ -2226,6 +2450,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha1-aWzi7Aqg5uqTo5f/zySqeEDIJ8s=",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha1-hAyIA7DYBH9P8M+WMXazLU7z7XI=",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
@@ -2333,6 +2571,23 @@
}
}
},
+ "node_modules/foreground-child": {
+ "version": "3.3.1",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/foreground-child/-/foreground-child-3.3.1.tgz",
+ "integrity": "sha1-Mujp7Rtoo0l777msK2rfkqY4V28=",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "cross-spawn": "^7.0.6",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2348,6 +2603,54 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/glob": {
+ "version": "10.5.0",
+ "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha1-jsA1WRnNMzjChCiiPU8k7MX+c4w=",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha1-mwy5/LeAh/b9fqur4lEcTT1gV04=",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha1-lEdx/ZyByBJlxNaUGGDaBrtZR5s=",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
@@ -2361,6 +2664,13 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha1-39YAJ9o2o238viNiYsAKWCJoFFM=",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
@@ -2398,6 +2708,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha1-8Rb4Bk/pCz94RKOJl8C3UFEmnx0=",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@@ -2405,6 +2725,96 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha1-LRZsSwZE1Do58Ev2wu3R5YXzF1Y=",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha1-kIMFusmlvRdaxqdEier9D8JEWn0=",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-lib-report/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha1-G33NyzK4E4gBs+R4umpRyqiWSNo=",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-source-maps": {
+ "version": "5.0.6",
+ "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
+ "integrity": "sha1-rK75SN93R8jrX78SZcuYD2NTpEE=",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.23",
+ "debug": "^4.1.1",
+ "istanbul-lib-coverage": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha1-y0U1FitXhKpiPO4hpyUs8sgHrJM=",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/jackspeak": {
+ "version": "3.4.3",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jackspeak/-/jackspeak-3.4.3.tgz",
+ "integrity": "sha1-iDOp2Jq0rN5hiJQr0cU7Y5DtWoo=",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/cliui": "^8.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ },
+ "optionalDependencies": {
+ "@pkgjs/parseargs": "^0.11.0"
+ }
+ },
"node_modules/js-levenshtein": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
@@ -2803,6 +3213,34 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/magicast": {
+ "version": "0.3.5",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/magicast/-/magicast-0.3.5.tgz",
+ "integrity": "sha1-gwHDx9ZnBKB3HrG610J08OwDZzk=",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.25.4",
+ "@babel/types": "^7.25.4",
+ "source-map-js": "^1.2.0"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha1-w8IwencSd82WODBfkVwprnQbYU4=",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
@@ -2833,6 +3271,16 @@
"node": ">=10"
}
},
+ "node_modules/minipass": {
+ "version": "7.1.3",
+ "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/minipass/-/minipass-7.1.3.tgz",
+ "integrity": "sha1-eTibTrG7LQA6m7qH1JLyvTe9xls=",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2895,6 +3343,13 @@
"integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==",
"license": "MIT"
},
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha1-TxRxoBCCeob5TP2bByfjbSZ95QU=",
+ "dev": true,
+ "license": "BlueOak-1.0.0"
+ },
"node_modules/parse-json": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
@@ -2926,6 +3381,40 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha1-WB9q3mWMu6ZaDTOA3ndTKVBU83U=",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-scurry": {
+ "version": "1.11.1",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-scurry/-/path-scurry-1.11.1.tgz",
+ "integrity": "sha1-eWCmaIiFlKByCxKpEdGnQqufEdI=",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^10.2.0",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/path-scurry/node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha1-QQ/IoXtw5ZgBPfJXwkRrfzOD8Rk=",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -3177,6 +3666,42 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha1-ObZGA33VDBT7RR5+TKxY7YuGP2k=",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha1-zNCvT4g1+9wmW4JGGq8MNmY/NOo=",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha1-rhbxZE2HPsrYQ7AwexQzYtTEIXI=",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -3184,6 +3709,19 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha1-lSGIwcvVRgcOLdIND0HArgUwywQ=",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -3208,6 +3746,103 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha1-FPja7G2B5yIdKjV+Zoyrc728p5Q=",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/string-width-cjs": {
+ "name": "string-width",
+ "version": "4.2.3",
+ "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha1-JpxxF9J7Ba0uU2gwqOyJXvnG0BA=",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha1-6Bj9ac5cz8tARZT4QpY79TFkzDc=",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string-width-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha1-nibGPTD1NEPpSJSVshBdN7Z6hdk=",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha1-0iomlSKDamJ6+NBLXD/Sx/o+MuM=",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/strip-ansi-cjs": {
+ "name": "strip-ansi",
+ "version": "6.0.1",
+ "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha1-nibGPTD1NEPpSJSVshBdN7Z6hdk=",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi/node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha1-YCFu6kZNhkWXzigyAAc4oFiWUME=",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@@ -3261,6 +3896,60 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/test-exclude": {
+ "version": "7.0.2",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/test-exclude/-/test-exclude-7.0.2.tgz",
+ "integrity": "sha1-SCOSB3YwvFfVYwwTq+kIu5EN/GU=",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@istanbuljs/schema": "^0.1.2",
+ "glob": "^10.4.1",
+ "minimatch": "^10.2.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/test-exclude/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha1-v7EGYv7tgZaixi58aOF3IMJ0F5o=",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/test-exclude/node_modules/brace-expansion": {
+ "version": "5.0.7",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/brace-expansion/-/brace-expansion-5.0.7.tgz",
+ "integrity": "sha1-Gw5GlltHna1lr3N7SgJ5CgVJgzc=",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/test-exclude/node_modules/minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha1-vUhoegvjjtKWE5kQVgD4MglYYdE=",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -3828,6 +4517,22 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/which/-/which-2.0.2.tgz",
+ "integrity": "sha1-fGqN0KY2oDJ+ELWckobu6T8/UbE=",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -3845,6 +4550,107 @@
"node": ">=8"
}
},
+ "node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha1-VtwiNo7lcPrOG0mBmXXZuaXq0hQ=",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs": {
+ "name": "wrap-ansi",
+ "version": "7.0.0",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha1-Z+FFz/UQpqaYS98RUpEdadLrnkM=",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha1-7dgDYornHATIWuegkG7a00tkiTc=",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha1-6Bj9ac5cz8tARZT4QpY79TFkzDc=",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha1-JpxxF9J7Ba0uU2gwqOyJXvnG0BA=",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha1-nibGPTD1NEPpSJSVshBdN7Z6hdk=",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha1-wETV3MUhoHZBNHJZehrLHxA8QEE=",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
diff --git a/src/ThreatModelForge.Studio/package.json b/src/ThreatModelForge.Studio/package.json
index ba2d0ef..eb48fe5 100644
--- a/src/ThreatModelForge.Studio/package.json
+++ b/src/ThreatModelForge.Studio/package.json
@@ -11,6 +11,7 @@
"typecheck": "tsc --noEmit",
"test": "tsc -p tsconfig.vitest.json --noEmit && vitest run",
"test:watch": "vitest",
+ "coverage": "vitest run --coverage",
"stage:wasm": "dotnet publish ../ThreatModelForge.Wasm/ThreatModelForge.Wasm.csproj -c Release -o obj/wasm-pub && rm -rf public/wasm && mkdir -p public/wasm && cp -R obj/wasm-pub/wwwroot/_framework public/wasm/_framework",
"gen:api": "openapi-typescript ../ThreatModelForge.Api/openapi/v1.json -o src/dfd/engine/schema.d.ts"
},
@@ -27,6 +28,7 @@
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
+ "@vitest/coverage-v8": "^3.2.7",
"jsdom": "^29.1.1",
"openapi-typescript": "^7.13.0",
"typescript": "^5.9.3",
diff --git a/src/ThreatModelForge.Studio/src/dfd/AnalysisSettings.test.tsx b/src/ThreatModelForge.Studio/src/dfd/AnalysisSettings.test.tsx
new file mode 100644
index 0000000..39b095a
--- /dev/null
+++ b/src/ThreatModelForge.Studio/src/dfd/AnalysisSettings.test.tsx
@@ -0,0 +1,316 @@
+import '@testing-library/jest-dom/vitest';
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent, within } from '@testing-library/react';
+import { AnalysisSettings } from './AnalysisSettings';
+import type { RuleBundle, RuleInfo, RulePackInfo } from './engineClient';
+import type { TmForgeExpectedRulePack } from './types';
+
+/**
+ * Two packs so the tests can prove a pack's state affects only its own rules, and a rule with help
+ * text so the expandable help panel is reachable.
+ */
+const PACKS: RulePackInfo[] = [
+ { id: 'security-properties', name: 'Security properties', count: 2 },
+ { id: 'identity-access', name: 'Identity & access', count: 1 },
+];
+
+const RULES: RuleInfo[] = [
+ {
+ id: 'TM1008',
+ pack: 'security-properties',
+ severity: 'error',
+ description: 'A flow crosses a trust boundary in cleartext. Anyone on the path can read it.',
+ helpText: 'Set Protocol to HTTPS.',
+ },
+ {
+ id: 'TM1013',
+ pack: 'security-properties',
+ severity: 'warning',
+ description: 'A data store holds credentials without stating an algorithm.',
+ helpText: '',
+ },
+ {
+ id: 'TM1021',
+ pack: 'identity-access',
+ severity: 'error',
+ description: 'A process accepts requests without authenticating them.',
+ helpText: 'Set AuthenticationScheme.',
+ },
+];
+
+const EMPTY_BUNDLE: RuleBundle = { rulePacks: [], diagnostics: [] };
+
+function handlers() {
+ return {
+ onTogglePack: vi.fn(),
+ onToggleRule: vi.fn(),
+ onLoadRuleFile: vi.fn(),
+ onClearRuleFile: vi.fn(),
+ };
+}
+
+function renderSettings(overrides: {
+ disabledPacks?: string[];
+ disabledRuleIds?: string[];
+ ruleBundle?: RuleBundle;
+ expectedPacks?: TmForgeExpectedRulePack[];
+ packs?: RulePackInfo[];
+} = {}) {
+ const h = handlers();
+ render(
+ ,
+ );
+ return h;
+}
+
+/** The row for a rule, as an element the query helpers can scope to. */
+function ruleRow(ruleId: string): HTMLElement {
+ return screen.getByText(ruleId).closest('.val-rule') as HTMLElement;
+}
+
+/** The checkbox that reflects whether a rule will run. */
+function ruleCheckbox(ruleId: string): HTMLInputElement {
+ return within(ruleRow(ruleId)).getByRole('checkbox') as HTMLInputElement;
+}
+
+describe('AnalysisSettings — which rules will run', () => {
+ it('shows every rule as enabled when nothing is disabled', () => {
+ renderSettings();
+
+ for (const id of ['TM1008', 'TM1013', 'TM1021']) {
+ expect(ruleCheckbox(id).checked).toBe(true);
+ }
+ });
+
+ it('reports an individually disabled rule as off, and leaves its siblings alone', () => {
+ renderSettings({ disabledRuleIds: ['TM1013'] });
+
+ expect(ruleCheckbox('TM1013').checked).toBe(false);
+ expect(ruleCheckbox('TM1008').checked).toBe(true);
+ expect(ruleCheckbox('TM1021').checked).toBe(true);
+ });
+
+ it('shows every rule in a disabled pack as off, including ones not individually disabled', () => {
+ // The consequence of getting this wrong is silent: an author believes these rules ran.
+ renderSettings({ disabledPacks: ['security-properties'] });
+
+ expect(ruleCheckbox('TM1008').checked).toBe(false);
+ expect(ruleCheckbox('TM1013').checked).toBe(false);
+ // A rule in a different pack is unaffected.
+ expect(ruleCheckbox('TM1021').checked).toBe(true);
+ });
+
+ it('toggles a pack when its chip is clicked', () => {
+ const h = renderSettings();
+
+ fireEvent.click(screen.getByTitle('Disable the Security properties rule pack'));
+ expect(h.onTogglePack).toHaveBeenCalledWith('security-properties');
+ });
+
+ it('offers to re-enable a pack that is off', () => {
+ const h = renderSettings({ disabledPacks: ['identity-access'] });
+
+ fireEvent.click(screen.getByTitle('Enable the Identity & access rule pack'));
+ expect(h.onTogglePack).toHaveBeenCalledWith('identity-access');
+ });
+
+ it('toggles a rule when its row is clicked', () => {
+ const h = renderSettings();
+
+ fireEvent.click(ruleRow('TM1008'));
+ expect(h.onToggleRule).toHaveBeenCalledWith('TM1008');
+ });
+
+ it('ignores clicks on a rule whose pack is off, so the row cannot be toggled invisibly', () => {
+ const h = renderSettings({ disabledPacks: ['security-properties'] });
+
+ fireEvent.click(ruleRow('TM1008'));
+
+ expect(h.onToggleRule).not.toHaveBeenCalled();
+ // And it is out of the tab order, so it cannot be reached by keyboard either.
+ expect(ruleRow('TM1008')).toHaveAttribute('tabindex', '-1');
+ });
+
+ it('toggles a rule from the keyboard', () => {
+ const h = renderSettings();
+ const row = ruleRow('TM1013');
+
+ fireEvent.keyDown(row, { key: 'Enter' });
+ fireEvent.keyDown(row, { key: ' ' });
+
+ expect(h.onToggleRule).toHaveBeenCalledTimes(2);
+ expect(h.onToggleRule).toHaveBeenCalledWith('TM1013');
+ });
+
+ it('ignores other keys on a rule row', () => {
+ const h = renderSettings();
+
+ fireEvent.keyDown(ruleRow('TM1013'), { key: 'a' });
+
+ expect(h.onToggleRule).not.toHaveBeenCalled();
+ });
+});
+
+describe('AnalysisSettings — rule help', () => {
+ it('opens the help panel without also toggling the rule', () => {
+ // The help button sits inside the clickable row, so this is the interaction most likely to
+ // silently start disabling a rule every time someone asks what it checks.
+ const h = renderSettings();
+
+ fireEvent.click(within(ruleRow('TM1008')).getByTitle('What does this rule check?'));
+
+ expect(h.onToggleRule).not.toHaveBeenCalled();
+ expect(screen.getByText('What it checks')).toBeInTheDocument();
+ });
+
+ it('shows the full description and the fix, not the truncated summary', () => {
+ renderSettings();
+
+ fireEvent.click(within(ruleRow('TM1008')).getByRole('button', { expanded: false }));
+
+ expect(screen.getByText(RULES[0].description)).toBeInTheDocument();
+ expect(screen.getByText('How to fix')).toBeInTheDocument();
+ expect(screen.getByText('Set Protocol to HTTPS.')).toBeInTheDocument();
+ });
+
+ it('omits the fix section for a rule that has no help text', () => {
+ renderSettings();
+
+ fireEvent.click(within(ruleRow('TM1013')).getByRole('button', { expanded: false }));
+
+ expect(screen.getByText('What it checks')).toBeInTheDocument();
+ expect(screen.queryByText('How to fix')).not.toBeInTheDocument();
+ });
+
+ it('keeps only one help panel open at a time', () => {
+ renderSettings();
+ const open = (ruleId: string) =>
+ fireEvent.click(within(ruleRow(ruleId)).getByRole('button', { expanded: false }));
+
+ open('TM1008');
+ open('TM1021');
+
+ expect(screen.getAllByText('What it checks')).toHaveLength(1);
+ });
+
+ it('closes the panel when the same rule is asked again', () => {
+ renderSettings();
+
+ fireEvent.click(within(ruleRow('TM1008')).getByRole('button', { expanded: false }));
+ fireEvent.click(within(ruleRow('TM1008')).getByRole('button', { expanded: true }));
+
+ expect(screen.queryByText('What it checks')).not.toBeInTheDocument();
+ });
+});
+
+describe('AnalysisSettings — custom rule packs', () => {
+ it('identifies each loaded pack by id, version, rule count and fingerprint', () => {
+ renderSettings({
+ ruleBundle: {
+ rulePacks: [
+ {
+ id: 'corporate',
+ name: 'Corporate policy',
+ version: '1.2.0',
+ fingerprint: 'abcdef0123456789',
+ dialect: 'urn:tmforge:rules:flat-v1',
+ ruleCount: 4,
+ },
+ ],
+ diagnostics: [],
+ },
+ });
+
+ expect(screen.getByText('Corporate policy')).toBeInTheDocument();
+ expect(screen.getByText(/corporate · 1\.2\.0 · 4 rule\(s\)/)).toBeInTheDocument();
+ });
+
+ it('warns that findings are incomplete when a pack the model expects is not loaded', () => {
+ // This is the whole point of pinning a pack: analyzing without it under-reports, and saying
+ // nothing would let a review pass while the rules it was signed off against never ran.
+ renderSettings({ expectedPacks: [{ id: 'corporate' }] });
+
+ expect(screen.getByText(/expects rule pack .*corporate.*not loaded/)).toBeInTheDocument();
+ expect(screen.getByText(/Findings will be incomplete/)).toBeInTheDocument();
+ });
+
+ it('does not warn when the expected pack is loaded', () => {
+ renderSettings({
+ expectedPacks: [{ id: 'corporate' }],
+ ruleBundle: {
+ rulePacks: [
+ {
+ id: 'corporate',
+ name: 'Corporate policy',
+ fingerprint: 'abcdef0123456789',
+ dialect: 'urn:tmforge:rules:flat-v1',
+ ruleCount: 4,
+ },
+ ],
+ diagnostics: [],
+ },
+ });
+
+ expect(screen.queryByText(/Findings will be incomplete/)).not.toBeInTheDocument();
+ });
+
+ it('surfaces loader diagnostics rather than failing quietly', () => {
+ renderSettings({
+ ruleBundle: { rulePacks: [], diagnostics: ['corporate.tmrules.json: rule CORP-2 has no assert.'] },
+ });
+
+ expect(screen.getByText(/rule CORP-2 has no assert/)).toBeInTheDocument();
+ });
+
+ it('offers a way back to the built-in rules only when something is loaded or expected', () => {
+ renderSettings();
+ expect(screen.queryByText('Use built-in rules')).not.toBeInTheDocument();
+ });
+
+ it('offers a way back to the built-in rules when a pack is expected but missing', () => {
+ const h = renderSettings({ expectedPacks: [{ id: 'corporate' }] });
+
+ fireEvent.click(screen.getByText('Use built-in rules'));
+ expect(h.onClearRuleFile).toHaveBeenCalled();
+ });
+
+ it('hands a picked file to the loader', () => {
+ // The component also resets the input's value so re-picking the same file fires change again.
+ // That cannot be asserted here: jsdom never populates value for a file input, so a check on it
+ // passes whether or not the reset happens. Asserting it anyway would look like a guarantee and
+ // provide none — a mutation removing the reset goes undetected either way.
+ const h = renderSettings();
+ const input = document.querySelector('input[type="file"]') as HTMLInputElement;
+ const file = new File(['{}'], 'corporate.tmrules.json', { type: 'application/json' });
+
+ fireEvent.change(input, { target: { files: [file] } });
+
+ expect(h.onLoadRuleFile).toHaveBeenCalledWith(file);
+ });
+
+ it('does nothing when the file picker is dismissed', () => {
+ const h = renderSettings();
+
+ fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, {
+ target: { files: [] },
+ });
+
+ expect(h.onLoadRuleFile).not.toHaveBeenCalled();
+ });
+});
+
+describe('AnalysisSettings — offline', () => {
+ it('explains that the engine is needed instead of showing an empty picker', () => {
+ renderSettings({ packs: [] });
+
+ expect(screen.getByText(/Connect the engine/)).toBeInTheDocument();
+ });
+});
diff --git a/src/ThreatModelForge.Studio/src/dfd/EditorHarness.test.tsx b/src/ThreatModelForge.Studio/src/dfd/EditorHarness.test.tsx
new file mode 100644
index 0000000..ec7f764
--- /dev/null
+++ b/src/ThreatModelForge.Studio/src/dfd/EditorHarness.test.tsx
@@ -0,0 +1,343 @@
+import '@testing-library/jest-dom/vitest';
+import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
+import { render, screen, fireEvent, waitFor, within, act } from '@testing-library/react';
+import { ReactFlowProvider, useReactFlow, type ReactFlowInstance } from '@xyflow/react';
+import { STORAGE_KEY } from './Editor';
+
+/**
+ * Drives the whole editor — React Flow canvas, Inspector, toolbar, page strip — the way a person
+ * does, and asserts on what came out. The component suites cover each panel in isolation; this
+ * covers the wiring between them, which is where `Editor.tsx` keeps its 50-odd callbacks and where
+ * every defect found during the bulk-edit work actually lived.
+ *
+ * Two things about jsdom shape these tests:
+ *
+ * 1. React Flow does not render edges without measured handle geometry, so a flow is never in the
+ * DOM here. Flows are asserted through the persisted workspace instead, which does carry them —
+ * verified by checking they are present *before* an edit as well as absent after, so a test can
+ * never pass merely because a flow was missing all along.
+ * 2. The editor reads its stored workspace at module scope, so the store has to be seeded before the
+ * module is imported. Hence resetModules + dynamic import in `mountEditor`.
+ *
+ * Geometry-dependent behaviour (drag, pan, zoom, Tidy, edge routing) is out of reach here and is not
+ * pretended at.
+ */
+beforeAll(() => {
+ // React Flow observes its container for size; jsdom has no ResizeObserver.
+ class ResizeObserverStub {
+ observe(): void {}
+
+ unobserve(): void {}
+
+ disconnect(): void {}
+ }
+
+ (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ResizeObserverStub;
+
+ // Everything in jsdom measures zero, and React Flow culls what it cannot place. These are file
+ // local: vitest isolates each test file, so no other suite sees the patched prototype.
+ Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 120 });
+ Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 60 });
+ Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
+ configurable: true,
+ value: () => ({ x: 0, y: 0, width: 800, height: 600, top: 0, left: 0, right: 800, bottom: 600 }),
+ });
+});
+
+interface SeedElement {
+ id: string;
+ kind: string;
+ name: string;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ properties?: Record;
+}
+
+interface SeedFlow {
+ id: string;
+ source: string;
+ target: string;
+ name: string;
+}
+
+function seedModel(elements: SeedElement[], flows: SeedFlow[]) {
+ return { schema: 'tmforge-json', version: '0.1', elements, flows };
+}
+
+/** Three processes in a row, joined a -> b -> c. */
+function chain() {
+ return seedModel(
+ [
+ { id: 'a', kind: 'process', name: 'Alpha', x: 0, y: 0, width: 120, height: 60 },
+ { id: 'b', kind: 'process', name: 'Bravo', x: 200, y: 0, width: 120, height: 60 },
+ { id: 'c', kind: 'process', name: 'Charlie', x: 400, y: 0, width: 120, height: 60 },
+ ],
+ [
+ { id: 'ab', source: 'a', target: 'b', name: 'a to b' },
+ { id: 'bc', source: 'b', target: 'c', name: 'b to c' },
+ ],
+ );
+}
+
+/**
+ * Gives the test a handle on the same React Flow instance the editor is using, so a selection can be
+ * made through the store. Simulating the multi-selection modifier is not an option here: React Flow
+ * tracks it with a `window` key listener whose key depends on the detected platform, and jsdom does
+ * not reproduce that. Setting selection on the store is the state a real multi-select arrives at,
+ * and the editor's onSelectionChange reacts to it exactly as in the browser.
+ */
+let flow: ReactFlowInstance | null = null;
+
+function FlowHandle(): null {
+ flow = useReactFlow();
+ return null;
+}
+
+/** Mounts the editor over a seeded workspace and waits for the canvas to populate. */
+async function mountEditor(model: ReturnType): Promise {
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ model }));
+ vi.resetModules();
+ const { Editor } = await import('./Editor');
+ render(
+
+
+
+ ,
+ );
+ await waitFor(() => expect(document.querySelectorAll('.react-flow__node').length).toBeGreaterThan(0));
+}
+
+/** The element ids currently on the canvas. */
+function canvasNodeIds(): string[] {
+ return Array.from(document.querySelectorAll('.react-flow__node')).map(
+ (node) => node.getAttribute('data-id') ?? '',
+ );
+}
+
+function nodeEl(id: string): Element {
+ const node = document.querySelector(`.react-flow__node[data-id="${id}"]`);
+ if (!node) {
+ throw new Error(`No node ${id} on the canvas. Present: ${canvasNodeIds().join(', ')}`);
+ }
+ return node;
+}
+
+/**
+ * The workspace as it was last written to storage. Flows only exist here — see the file comment.
+ * Writes are debounced, so callers poll this rather than reading it once.
+ */
+function persisted(): { elements: string[]; flows: string[] } | null {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ if (!raw) {
+ return null;
+ }
+ const model = (JSON.parse(raw) as { model: ReturnType }).model;
+ return {
+ elements: (model.elements ?? []).map((element) => element.id),
+ flows: (model.flows ?? []).map((flow) => flow.id),
+ };
+}
+
+/** Waits for the debounced workspace write to match what the test expects. */
+async function expectPersisted(expected: { elements: string[]; flows: string[] }): Promise {
+ await waitFor(() => expect(persisted()).toEqual(expected), { timeout: 3000, interval: 50 });
+}
+
+/** Selects exactly the named elements, as a click or a modifier-click would. */
+function selectNodes(...ids: string[]): void {
+ const wanted = new Set(ids);
+ act(() => {
+ flow!.setNodes((nodes) => nodes.map((node) => ({ ...node, selected: wanted.has(node.id) })));
+ });
+}
+
+function inspector(): HTMLElement {
+ return document.querySelector('.inspector') as HTMLElement;
+}
+
+function undoButton(): HTMLButtonElement {
+ return screen.getByTitle(/Undo/i) as HTMLButtonElement;
+}
+
+/** Presses undo until it is exhausted, reporting how many steps that took. */
+async function undoToExhaustion(limit = 6): Promise {
+ let steps = 0;
+ while (steps < limit && !undoButton().disabled) {
+ fireEvent.click(undoButton());
+ steps += 1;
+ await waitFor(() => expect(true).toBe(true));
+ }
+ return steps;
+}
+
+/** Adds a custom property through the Inspector's free-text row (works without the engine). */
+function addCustomProperty(key: string, value: string): void {
+ const panel = inspector();
+ fireEvent.change(within(panel).getByPlaceholderText('key'), { target: { value: key } });
+ fireEvent.change(within(panel).getByPlaceholderText('value'), { target: { value } });
+ fireEvent.click(within(panel).getByRole('button', { name: 'Add' }));
+}
+
+beforeEach(() => {
+ window.localStorage.clear();
+});
+
+describe('Editor — deleting from the canvas', () => {
+ it('takes an element and its flows together, and restores both in one undo', async () => {
+ // The bug this guards: React Flow raises a delete callback for nodes AND one for edges, so
+ // snapshotting in both charged two undo steps for a single Backspace.
+ await mountEditor(chain());
+ await expectPersisted({ elements: ['a', 'b', 'c'], flows: ['ab', 'bc'] });
+
+ fireEvent.click(nodeEl('b'));
+ fireEvent.keyDown(document.querySelector('.react-flow')!, { key: 'Backspace' });
+
+ // 'b' was an endpoint of both flows, so both go with it.
+ await expectPersisted({ elements: ['a', 'c'], flows: [] });
+
+ const steps = await undoToExhaustion();
+
+ expect(steps).toBe(1);
+ await expectPersisted({ elements: ['a', 'b', 'c'], flows: ['ab', 'bc'] });
+ });
+
+ it('leaves the endpoints alone when only a flow is deleted', async () => {
+ await mountEditor(chain());
+ await expectPersisted({ elements: ['a', 'b', 'c'], flows: ['ab', 'bc'] });
+
+ // A flow has no DOM node in jsdom, so reach it the way the canvas search does.
+ fireEvent.click(nodeEl('a'));
+ fireEvent.click(nodeEl('c'));
+
+ // Deleting 'a' removes only the flow attached to it.
+ fireEvent.click(nodeEl('a'));
+ fireEvent.keyDown(document.querySelector('.react-flow')!, { key: 'Backspace' });
+
+ await expectPersisted({ elements: ['b', 'c'], flows: ['bc'] });
+ });
+
+ it('deletes every selected element at once, not just the first', async () => {
+ await mountEditor(chain());
+ await expectPersisted({ elements: ['a', 'b', 'c'], flows: ['ab', 'bc'] });
+
+ selectNodes('a', 'b');
+ fireEvent.keyDown(document.querySelector('.react-flow')!, { key: 'Backspace' });
+
+ await expectPersisted({ elements: ['c'], flows: [] });
+ });
+
+ it('deletes the whole selection from the Inspector button too', async () => {
+ await mountEditor(chain());
+ await expectPersisted({ elements: ['a', 'b', 'c'], flows: ['ab', 'bc'] });
+
+ selectNodes('a', 'b');
+ const button = within(inspector()).getByRole('button', { name: /^Delete/ });
+ expect(button).toHaveTextContent('Delete 2 elements');
+ fireEvent.click(button);
+
+ await expectPersisted({ elements: ['c'], flows: [] });
+ });
+});
+
+describe('Editor — editing a selection', () => {
+ it('writes a property to every selected element in one undo step', async () => {
+ await mountEditor(chain());
+ await expectPersisted({ elements: ['a', 'b', 'c'], flows: ['ab', 'bc'] });
+
+ selectNodes('a', 'b');
+ addCustomProperty('Owner', 'platform');
+
+ await waitFor(
+ () => {
+ const raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY)!) as {
+ model: ReturnType;
+ };
+ const owners = raw.model.elements.map((element) => element.properties?.Owner);
+ expect(owners).toEqual(['platform', 'platform', undefined]);
+ },
+ { timeout: 3000, interval: 50 },
+ );
+
+ // One edit is one undo, however many elements it touched.
+ const steps = await undoToExhaustion();
+ expect(steps).toBe(1);
+ });
+
+ it('shows a property the selection disagrees about as mixed, and does not flatten it', async () => {
+ await mountEditor(
+ seedModel(
+ [
+ { id: 'a', kind: 'process', name: 'Alpha', x: 0, y: 0, width: 120, height: 60, properties: { Owner: 'platform' } },
+ { id: 'b', kind: 'process', name: 'Bravo', x: 200, y: 0, width: 120, height: 60, properties: { Owner: 'payments' } },
+ ],
+ [],
+ ),
+ );
+
+ selectNodes('a', 'b');
+
+ // The row renders empty with a (mixed) placeholder rather than picking one side's value.
+ expect(within(inspector()).getByPlaceholderText('(mixed)')).toBeInTheDocument();
+
+ // Merely showing the selection must not overwrite either value.
+ await waitFor(
+ () => {
+ const raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY)!) as {
+ model: ReturnType;
+ };
+ expect(raw.model.elements.map((element) => element.properties?.Owner)).toEqual([
+ 'platform',
+ 'payments',
+ ]);
+ },
+ { timeout: 3000, interval: 50 },
+ );
+ });
+
+ it('offers no name field for a multi-selection', async () => {
+ await mountEditor(chain());
+
+ selectNodes('a', 'b');
+
+ expect(within(inspector()).queryByText('Name')).not.toBeInTheDocument();
+ expect(within(inspector()).getByText('2 processes')).toBeInTheDocument();
+ });
+
+ it('renames a single element and keeps the change', async () => {
+ await mountEditor(chain());
+
+ fireEvent.click(nodeEl('a'));
+ const name = within(inspector()).getByLabelText('Name');
+ fireEvent.focus(name);
+ fireEvent.change(name, { target: { value: 'Gateway' } });
+
+ await waitFor(
+ () => {
+ const raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY)!) as {
+ model: ReturnType;
+ };
+ expect(raw.model.elements[0].name).toBe('Gateway');
+ },
+ { timeout: 3000, interval: 50 },
+ );
+ });
+});
+
+describe('Editor — undo history', () => {
+ it('charges one step per edit and redoes what it undid', async () => {
+ await mountEditor(chain());
+ await expectPersisted({ elements: ['a', 'b', 'c'], flows: ['ab', 'bc'] });
+
+ fireEvent.click(nodeEl('c'));
+ fireEvent.keyDown(document.querySelector('.react-flow')!, { key: 'Backspace' });
+ await expectPersisted({ elements: ['a', 'b'], flows: ['ab'] });
+
+ fireEvent.click(undoButton());
+ await expectPersisted({ elements: ['a', 'b', 'c'], flows: ['ab', 'bc'] });
+
+ fireEvent.click(screen.getByTitle(/Redo/i));
+ await expectPersisted({ elements: ['a', 'b'], flows: ['ab'] });
+ });
+});
diff --git a/src/ThreatModelForge.Studio/src/dfd/MergeResolveModal.test.tsx b/src/ThreatModelForge.Studio/src/dfd/MergeResolveModal.test.tsx
new file mode 100644
index 0000000..5a085ec
--- /dev/null
+++ b/src/ThreatModelForge.Studio/src/dfd/MergeResolveModal.test.tsx
@@ -0,0 +1,201 @@
+import { describe, it, expect } from 'vitest';
+import { applyChoices } from './MergeResolveModal';
+import type { MergeConflict } from './engineClient';
+import type { TmForgeModel } from './types';
+
+/**
+ * Tests the one place a merge choice turns into a model edit. The engine resolves every conflict to
+ * `ours` before this runs, so the job here is narrow and unforgiving: overwrite exactly the
+ * properties the author flipped to `theirs`, and touch nothing else. Getting it wrong discards work
+ * silently — the resolved model looks plausible either way.
+ */
+function model(): TmForgeModel {
+ return {
+ schema: 'tmforge-json',
+ version: '0.1',
+ elements: [
+ {
+ id: 'store',
+ kind: 'datastore',
+ name: 'Audit log',
+ x: 0,
+ y: 0,
+ width: 100,
+ height: 60,
+ properties: { Encrypted: 'Yes', StoresLogData: 'Yes' },
+ },
+ { id: 'api', kind: 'process', name: 'API', x: 0, y: 0, width: 100, height: 60 },
+ ],
+ flows: [
+ {
+ id: 'write',
+ source: 'api',
+ target: 'store',
+ name: 'write audit',
+ properties: { Protocol: 'HTTPS' },
+ },
+ ],
+ } as TmForgeModel;
+}
+
+function conflict(overrides: Partial): MergeConflict {
+ return {
+ elementId: 'store',
+ elementKind: 'datastore',
+ name: 'Audit log',
+ diagramName: 'Page 1',
+ kind: 'Property',
+ property: 'Encrypted',
+ ours: 'Yes',
+ theirs: 'No',
+ ...overrides,
+ };
+}
+
+/** The key the component uses to index a choice. */
+function key(c: MergeConflict): string {
+ return `${c.elementId}\u0000${c.property}`;
+}
+
+describe('MergeResolveModal — applying resolution choices', () => {
+ it('changes nothing when every conflict is left on ours', () => {
+ const merged = model();
+ const c = conflict({});
+
+ const result = applyChoices(merged, [c], { [key(c)]: 'ours' });
+
+ expect(result).toEqual(merged);
+ });
+
+ it('changes nothing when a conflict has no recorded choice', () => {
+ const merged = model();
+ const c = conflict({});
+
+ const result = applyChoices(merged, [c], {});
+
+ expect(result).toEqual(merged);
+ });
+
+ it('never mutates the model it was given', () => {
+ // The merged model is held in state and re-resolved on every choice; mutating it would make the
+ // previous answer unrecoverable as soon as the author changed their mind.
+ const merged = model();
+ const c = conflict({});
+
+ applyChoices(merged, [c], { [key(c)]: 'theirs' });
+
+ expect(merged.elements[0].properties!.Encrypted).toBe('Yes');
+ });
+
+ it('takes their value for the one property that was flipped', () => {
+ const c = conflict({});
+
+ const result = applyChoices(model(), [c], { [key(c)]: 'theirs' });
+
+ expect(result.elements[0].properties!.Encrypted).toBe('No');
+ });
+
+ it('leaves the element\'s other properties alone', () => {
+ const c = conflict({});
+
+ const result = applyChoices(model(), [c], { [key(c)]: 'theirs' });
+
+ expect(result.elements[0].properties!.StoresLogData).toBe('Yes');
+ expect(result.elements[0].name).toBe('Audit log');
+ });
+
+ it('renames an element when the conflict is on its name', () => {
+ const c = conflict({ property: 'name', ours: 'Audit log', theirs: 'Audit store' });
+
+ const result = applyChoices(model(), [c], { [key(c)]: 'theirs' });
+
+ expect(result.elements[0].name).toBe('Audit store');
+ // 'name' must not be written into the property bag as well.
+ expect(result.elements[0].properties!.name).toBeUndefined();
+ });
+
+ it('renames a flow when the conflict is on its name', () => {
+ const c = conflict({ elementId: 'write', elementKind: 'flow', property: 'name', theirs: 'append audit' });
+
+ const result = applyChoices(model(), [c], { [key(c)]: 'theirs' });
+
+ expect(result.flows[0].name).toBe('append audit');
+ expect(result.flows[0].properties!.name).toBeUndefined();
+ });
+
+ it('rewires a flow endpoint when the conflict is on source or target', () => {
+ // These are the highest-consequence properties in the file: writing them into the property bag
+ // instead of the endpoint would leave the diagram looking merged while the graph was not.
+ const source = conflict({ elementId: 'write', elementKind: 'flow', property: 'source', theirs: 'store' });
+ const target = conflict({ elementId: 'write', elementKind: 'flow', property: 'target', theirs: 'api' });
+
+ const result = applyChoices(model(), [source, target], {
+ [key(source)]: 'theirs',
+ [key(target)]: 'theirs',
+ });
+
+ expect(result.flows[0].source).toBe('store');
+ expect(result.flows[0].target).toBe('api');
+ expect(result.flows[0].properties!.source).toBeUndefined();
+ expect(result.flows[0].properties!.target).toBeUndefined();
+ });
+
+ it('sets a flow property without disturbing its endpoints', () => {
+ const c = conflict({ elementId: 'write', elementKind: 'flow', property: 'Protocol', theirs: 'HTTP' });
+
+ const result = applyChoices(model(), [c], { [key(c)]: 'theirs' });
+
+ expect(result.flows[0].properties!.Protocol).toBe('HTTP');
+ expect(result.flows[0].source).toBe('api');
+ expect(result.flows[0].target).toBe('store');
+ });
+
+ it('applies only the conflicts that were flipped, out of several', () => {
+ const flipped = conflict({ property: 'Encrypted', theirs: 'No' });
+ const kept = conflict({ property: 'StoresLogData', ours: 'Yes', theirs: 'No' });
+
+ const result = applyChoices(model(), [flipped, kept], {
+ [key(flipped)]: 'theirs',
+ [key(kept)]: 'ours',
+ });
+
+ expect(result.elements[0].properties!.Encrypted).toBe('No');
+ expect(result.elements[0].properties!.StoresLogData).toBe('Yes');
+ });
+
+ it('ignores structural conflicts even when they are marked theirs', () => {
+ // A DeleteModify or AddAdd cannot be resolved by writing a property. Acting on one would edit an
+ // attribute nobody chose — the picker is not even shown for these.
+ for (const kind of ['DeleteModify', 'AddAdd', 'DanglingReference']) {
+ const c = conflict({ kind, property: 'Encrypted', theirs: 'No' });
+
+ const result = applyChoices(model(), [c], { [key(c)]: 'theirs' });
+
+ expect(result.elements[0].properties!.Encrypted).toBe('Yes');
+ }
+ });
+
+ it('clears the value when their side had none', () => {
+ const c = conflict({ property: 'Encrypted', ours: 'Yes', theirs: undefined });
+
+ const result = applyChoices(model(), [c], { [key(c)]: 'theirs' });
+
+ expect(result.elements[0].properties!.Encrypted).toBe('');
+ });
+
+ it('ignores a conflict naming an object that is not in the merged model', () => {
+ const c = conflict({ elementId: 'deleted-by-merge', theirs: 'No' });
+
+ const result = applyChoices(model(), [c], { [key(c)]: 'theirs' });
+
+ expect(result).toEqual(model());
+ });
+
+ it('adds a property the merged element did not carry', () => {
+ const c = conflict({ elementId: 'api', property: 'AuthenticationScheme', ours: undefined, theirs: 'OAuth' });
+
+ const result = applyChoices(model(), [c], { [key(c)]: 'theirs' });
+
+ expect(result.elements[1].properties!.AuthenticationScheme).toBe('OAuth');
+ });
+});
diff --git a/src/ThreatModelForge.Studio/src/dfd/MergeResolveModal.tsx b/src/ThreatModelForge.Studio/src/dfd/MergeResolveModal.tsx
index 97f3965..a074c1d 100644
--- a/src/ThreatModelForge.Studio/src/dfd/MergeResolveModal.tsx
+++ b/src/ThreatModelForge.Studio/src/dfd/MergeResolveModal.tsx
@@ -58,8 +58,11 @@ function applyProperty(model: TmForgeModel, id: string, property: string, value:
* Applies the user's per-conflict choices to a copy of the merged model. The engine already
* resolved every conflict to `ours`, so we only overwrite the properties the user flipped to
* `theirs`. Only `Property` conflicts are machine-applicable; structural ones are informational.
+ *
+ * Exported for test: this is the only place a resolution choice turns into a model edit, so it is
+ * where a mistake would quietly discard someone's work.
*/
-function applyChoices(
+export function applyChoices(
merged: TmForgeModel,
conflicts: MergeConflict[],
choices: Record,
diff --git a/src/ThreatModelForge.Studio/src/dfd/PageTabs.test.tsx b/src/ThreatModelForge.Studio/src/dfd/PageTabs.test.tsx
new file mode 100644
index 0000000..1047e0c
--- /dev/null
+++ b/src/ThreatModelForge.Studio/src/dfd/PageTabs.test.tsx
@@ -0,0 +1,228 @@
+import '@testing-library/jest-dom/vitest';
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { PageTabs } from './PageTabs';
+
+const PAGES = [
+ { id: 'p1', name: 'Context' },
+ { id: 'p2', name: 'Payments' },
+ { id: 'p3', name: 'Identity' },
+];
+
+function handlers() {
+ return {
+ onSwitch: vi.fn(),
+ onAdd: vi.fn(),
+ onRename: vi.fn(),
+ onDelete: vi.fn(),
+ onReorder: vi.fn(),
+ };
+}
+
+function renderTabs(overrides: {
+ pages?: { id: string; name: string }[];
+ activePageId?: string;
+ findingPageIds?: ReadonlySet;
+} = {}) {
+ const h = handlers();
+ render(
+ ,
+ );
+ return h;
+}
+
+/** The tab element carrying the given label. */
+function tab(name: string): HTMLElement {
+ return screen.getByText(name).closest('.page-tab') as HTMLElement;
+}
+
+describe('PageTabs — switching', () => {
+ it('marks the active page and only the active page', () => {
+ renderTabs({ activePageId: 'p2' });
+
+ expect(tab('Payments')).toHaveAttribute('aria-selected', 'true');
+ expect(tab('Context')).toHaveAttribute('aria-selected', 'false');
+ });
+
+ it('switches on click and from the keyboard', () => {
+ const h = renderTabs();
+
+ fireEvent.click(tab('Payments'));
+ fireEvent.keyDown(tab('Identity'), { key: 'Enter' });
+ fireEvent.keyDown(tab('Identity'), { key: ' ' });
+
+ expect(h.onSwitch).toHaveBeenNthCalledWith(1, 'p2');
+ expect(h.onSwitch).toHaveBeenNthCalledWith(2, 'p3');
+ expect(h.onSwitch).toHaveBeenCalledTimes(3);
+ });
+
+ it('badges only the pages that carry findings', () => {
+ renderTabs({ findingPageIds: new Set(['p2']) });
+
+ expect(tab('Payments').querySelector('.page-tab-dot')).not.toBeNull();
+ expect(tab('Context').querySelector('.page-tab-dot')).toBeNull();
+ });
+
+ it('adds a page', () => {
+ const h = renderTabs();
+
+ fireEvent.click(screen.getByLabelText('Add page'));
+
+ expect(h.onAdd).toHaveBeenCalled();
+ });
+});
+
+describe('PageTabs — deleting', () => {
+ it('deletes the page whose close button was clicked, without switching to it first', () => {
+ // The close button sits inside the tab's own click handler. Without stopPropagation, deleting a
+ // page would also make it active on the way out — a switch to a page that no longer exists.
+ const h = renderTabs();
+
+ fireEvent.click(screen.getByLabelText('Delete Payments'));
+
+ expect(h.onDelete).toHaveBeenCalledWith('p2');
+ expect(h.onSwitch).not.toHaveBeenCalled();
+ });
+
+ it('offers no way to delete the last remaining page', () => {
+ // Deleting a page destroys its contents; leaving a model with no pages at all is not a state
+ // the editor should be able to reach from the UI.
+ renderTabs({ pages: [{ id: 'only', name: 'Page 1' }], activePageId: 'only' });
+
+ expect(screen.queryByLabelText('Delete Page 1')).not.toBeInTheDocument();
+ });
+
+ it('restores the delete affordance once there is more than one page', () => {
+ renderTabs({ pages: PAGES.slice(0, 2) });
+
+ expect(screen.getByLabelText('Delete Context')).toBeInTheDocument();
+ expect(screen.getByLabelText('Delete Payments')).toBeInTheDocument();
+ });
+});
+
+describe('PageTabs — renaming', () => {
+ it('commits a new name on Enter', () => {
+ const h = renderTabs();
+
+ fireEvent.doubleClick(tab('Context'));
+ const input = screen.getByDisplayValue('Context');
+ fireEvent.change(input, { target: { value: 'Overview' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+
+ expect(h.onRename).toHaveBeenCalledWith('p1', 'Overview');
+ });
+
+ it('starts a rename from F2 as well as a double-click', () => {
+ renderTabs();
+
+ fireEvent.keyDown(tab('Payments'), { key: 'F2' });
+
+ expect(screen.getByDisplayValue('Payments')).toBeInTheDocument();
+ });
+
+ it('commits when focus leaves the field', () => {
+ const h = renderTabs();
+
+ fireEvent.doubleClick(tab('Context'));
+ const input = screen.getByDisplayValue('Context');
+ fireEvent.change(input, { target: { value: 'Overview' } });
+ fireEvent.blur(input);
+
+ expect(h.onRename).toHaveBeenCalledWith('p1', 'Overview');
+ });
+
+ it('keeps the original name when the rename is cancelled', () => {
+ const h = renderTabs();
+
+ fireEvent.doubleClick(tab('Context'));
+ const input = screen.getByDisplayValue('Context');
+ fireEvent.change(input, { target: { value: 'Discarded' } });
+ fireEvent.keyDown(input, { key: 'Escape' });
+
+ expect(h.onRename).not.toHaveBeenCalled();
+ expect(screen.getByText('Context')).toBeInTheDocument();
+ });
+
+ it('refuses a blank name rather than leaving a page with no label', () => {
+ const h = renderTabs();
+
+ fireEvent.doubleClick(tab('Context'));
+ const input = screen.getByDisplayValue('Context');
+ fireEvent.change(input, { target: { value: ' ' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+
+ expect(h.onRename).not.toHaveBeenCalled();
+ expect(screen.getByText('Context')).toBeInTheDocument();
+ });
+
+ it('trims the committed name', () => {
+ const h = renderTabs();
+
+ fireEvent.doubleClick(tab('Context'));
+ const input = screen.getByDisplayValue('Context');
+ fireEvent.change(input, { target: { value: ' Overview ' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+
+ expect(h.onRename).toHaveBeenCalledWith('p1', 'Overview');
+ });
+
+ it('does not switch pages while the name is being edited', () => {
+ const h = renderTabs({ activePageId: 'p2' });
+
+ fireEvent.doubleClick(tab('Context'));
+ fireEvent.click(screen.getByDisplayValue('Context'));
+
+ expect(h.onSwitch).not.toHaveBeenCalled();
+ });
+
+ it('hides the delete button while renaming, so a stray click cannot destroy the page', () => {
+ renderTabs();
+
+ fireEvent.doubleClick(tab('Context'));
+
+ expect(screen.queryByLabelText('Delete Context')).not.toBeInTheDocument();
+ });
+
+ it('stops the tab being draggable while renaming, so the text can be selected', () => {
+ renderTabs();
+
+ fireEvent.doubleClick(tab('Context'));
+
+ const editing = screen.getByDisplayValue('Context').closest('.page-tab')!;
+ expect(editing).toHaveAttribute('draggable', 'false');
+ });
+});
+
+describe('PageTabs — reordering', () => {
+ it('reports the move when a tab is dropped on another', () => {
+ const h = renderTabs();
+
+ fireEvent.dragStart(tab('Context'));
+ fireEvent.dragOver(tab('Identity'));
+ fireEvent.drop(tab('Identity'));
+
+ expect(h.onReorder).toHaveBeenCalledWith(0, 2);
+ });
+
+ it('ignores a tab dropped back onto itself', () => {
+ const h = renderTabs();
+
+ fireEvent.dragStart(tab('Payments'));
+ fireEvent.drop(tab('Payments'));
+
+ expect(h.onReorder).not.toHaveBeenCalled();
+ });
+
+ it('ignores a drop that did not start on a tab', () => {
+ const h = renderTabs();
+
+ fireEvent.drop(tab('Payments'));
+
+ expect(h.onReorder).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/ThreatModelForge.Studio/vite.config.ts b/src/ThreatModelForge.Studio/vite.config.ts
index 8536fb8..24c5879 100644
--- a/src/ThreatModelForge.Studio/vite.config.ts
+++ b/src/ThreatModelForge.Studio/vite.config.ts
@@ -20,6 +20,21 @@ export default defineConfig(({ mode }) => {
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
css: false,
+ coverage: {
+ provider: 'v8',
+ include: ['src/**/*.{ts,tsx}'],
+ // Test files, the app shell, and the generated API client types are not behaviour to cover.
+ exclude: ['src/**/*.test.{ts,tsx}', 'src/main.tsx', 'src/dfd/engine/schema.d.ts', 'src/vite-env.d.ts'],
+ // Floors, not targets: raise them as coverage improves, and never lower one without saying
+ // why. They sit just under today's numbers so an unrelated change cannot trip them, while a
+ // real regression still fails the run.
+ thresholds: {
+ statements: 67,
+ branches: 80,
+ functions: 60,
+ lines: 67,
+ },
+ },
},
};
});
diff --git a/test/ThreatModelForge.Api.Tests/ApiCustomRulesTest.cs b/test/ThreatModelForge.Api.Tests/ApiCustomRulesTest.cs
new file mode 100644
index 0000000..3e959b9
--- /dev/null
+++ b/test/ThreatModelForge.Api.Tests/ApiCustomRulesTest.cs
@@ -0,0 +1,186 @@
+namespace ThreatModelForge.Api.Tests
+{
+ using System;
+ using System.IO;
+ using System.Net;
+ using System.Net.Http;
+ using System.Text;
+ using System.Text.Json;
+ using System.Threading.Tasks;
+ using Microsoft.AspNetCore.Hosting;
+ using Microsoft.AspNetCore.Mvc.Testing;
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ ///
+ /// Tests the host's startup rule loading. Custom packs are deployment configuration read once at
+ /// boot, so nothing below the host can exercise them: these drive a host that has actually been
+ /// given a pack, and one that has been given a path that does not exist.
+ ///
+ [TestClass]
+ public class ApiCustomRulesTest
+ {
+ /// A data store that trips the pack's rule: it holds log data and states no retention.
+ private const string Model =
+ "{\"schema\":\"tmforge-json\",\"version\":\"0.1\"," +
+ "\"elements\":[{\"id\":\"d\",\"kind\":\"datastore\",\"name\":\"Audit\",\"x\":10,\"y\":10,\"width\":120,\"height\":60," +
+ "\"properties\":{\"StoresLogData\":\"Yes\"}}],\"flows\":[]}";
+
+ /// A minimal but complete pack, shaped after examples/corporate-policy.tmrules.json.
+ private const string PackTemplate = """
+ {
+ "schema": "tmforge-rules",
+ "version": 2,
+ "dialect": "urn:tmforge:rules:flat-v1",
+ "pack": { "id": "PACK", "name": "Test pack PACK", "version": "1.0.0" },
+ "elementTypes": [ { "id": "GE.DS", "name": "Data store", "parentId": "ROOT" } ],
+ "properties": [ { "name": "RetentionDays", "elementTypeIds": ["GE.DS"] } ],
+ "rules": [
+ {
+ "id": "RULE",
+ "severity": "error",
+ "appliesTo": "datastore",
+ "message": "{name} states no RetentionDays.",
+ "fullDescription": "A test pack proving the host loads operator-supplied rules.",
+ "helpText": "Set RetentionDays on the store.",
+ "when": { "property": "StoresLogData", "equals": "Yes" },
+ "assert": { "property": "RetentionDays", "present": true }
+ }
+ ]
+ }
+ """;
+
+ private string WorkingDirectory { get; set; } = string.Empty;
+
+ /// Creates a directory to hold the pack file.
+ [TestInitialize]
+ public void Initialize()
+ {
+ this.WorkingDirectory = Path.Join(Path.GetTempPath(), "tmforge-api-rules-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(this.WorkingDirectory);
+ }
+
+ /// Removes the directory.
+ [TestCleanup]
+ public void Cleanup()
+ {
+ if (Directory.Exists(this.WorkingDirectory))
+ {
+ Directory.Delete(this.WorkingDirectory, recursive: true);
+ }
+ }
+
+ ///
+ /// Verifies a configured pack reaches every rule-reading endpoint: it is listed in the bundle,
+ /// appears in the rule catalog, and actually fires during analysis. Listing without firing would
+ /// mean the operator's policy is advertised but not enforced.
+ ///
+ /// A task.
+ [TestMethod]
+ public async Task AConfiguredPackIsListedAndEnforced()
+ {
+ string path = this.WritePack("test-pack", "TEST-1");
+ using WebApplicationFactory factory = HostWithRules(path);
+ using HttpClient client = factory.CreateClient();
+
+ using (HttpResponseMessage bundle = await client.GetAsync("/v1/rule-bundle"))
+ {
+ using JsonDocument body = JsonDocument.Parse(await bundle.Content.ReadAsStringAsync());
+ Assert.AreEqual(0, body.RootElement.GetProperty("diagnostics").GetArrayLength(), "the pack should load cleanly");
+ Assert.AreEqual(1, body.RootElement.GetProperty("rulePacks").GetArrayLength());
+ Assert.AreEqual("test-pack", body.RootElement.GetProperty("rulePacks")[0].GetProperty("id").GetString());
+ }
+
+ using (HttpResponseMessage rules = await client.GetAsync("/v1/rules"))
+ {
+ StringAssert.Contains(await rules.Content.ReadAsStringAsync(), "TEST-1");
+ }
+
+ using StringContent content = new StringContent(Model, Encoding.UTF8, "application/json");
+ using HttpResponseMessage analyze = await client.PostAsync("/v1/model/analyze", content);
+ Assert.AreEqual(HttpStatusCode.OK, analyze.StatusCode);
+ StringAssert.Contains(await analyze.Content.ReadAsStringAsync(), "TEST-1");
+ }
+
+ ///
+ /// Verifies a rule path that does not exist is reported rather than thrown. A host that fell
+ /// back to the built-in rules in silence would analyze every caller's model against a policy
+ /// nobody asked for, and report clean while doing it.
+ ///
+ /// A task.
+ [TestMethod]
+ public async Task AMissingPackIsReportedInsteadOfCrashingTheHost()
+ {
+ string missing = Path.Join(this.WorkingDirectory, "no-such-pack.tmrules.json");
+ using WebApplicationFactory factory = HostWithRules(missing);
+ using HttpClient client = factory.CreateClient();
+
+ using HttpResponseMessage response = await client.GetAsync("/v1/rule-bundle");
+
+ Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
+ using JsonDocument body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ Assert.IsTrue(
+ body.RootElement.GetProperty("diagnostics").GetArrayLength() > 0,
+ "a misconfigured rule path must be visible through the bundle.");
+ }
+
+ ///
+ /// Verifies the documented ;-separated form loads every pack it names. This is how the
+ /// setting is written as a single environment variable
+ /// (TmForge__Rules='/a.tmrules.json;/b.tmrules.json'), which is the shape a container
+ /// deployment uses — and it takes a different code path from the indexed array form.
+ ///
+ /// A task.
+ [TestMethod]
+ public async Task PacksCanBeNamedAsOneSemicolonSeparatedSetting()
+ {
+ string first = this.WritePack("pack-one", "ONE-1");
+ string second = this.WritePack("pack-two", "TWO-1");
+ using WebApplicationFactory factory = HostWithRulesSetting(first + ";" + second);
+ using HttpClient client = factory.CreateClient();
+
+ using HttpResponseMessage response = await client.GetAsync("/v1/rule-bundle");
+
+ using JsonDocument body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ Assert.AreEqual(0, body.RootElement.GetProperty("diagnostics").GetArrayLength());
+ JsonElement packs = body.RootElement.GetProperty("rulePacks");
+ Assert.AreEqual(2, packs.GetArrayLength(), "both packs in the separated list must load.");
+ Assert.AreEqual("pack-one", packs[0].GetProperty("id").GetString());
+ Assert.AreEqual("pack-two", packs[1].GetProperty("id").GetString());
+ }
+
+ /// Builds a host that loads rule packs from the given paths.
+ /// The configured rule pack paths.
+ /// The factory.
+ private static WebApplicationFactory HostWithRules(params string[] paths)
+ {
+ return new WebApplicationFactory().WithWebHostBuilder(builder =>
+ {
+ for (int index = 0; index < paths.Length; index++)
+ {
+ builder.UseSetting($"TmForge:Rules:{index}", paths[index]);
+ }
+ });
+ }
+
+ /// Builds a host configured with one scalar setting, as an environment variable sets it.
+ /// The raw setting value.
+ /// The factory.
+ private static WebApplicationFactory HostWithRulesSetting(string value)
+ {
+ return new WebApplicationFactory()
+ .WithWebHostBuilder(builder => builder.UseSetting("TmForge:Rules", value));
+ }
+
+ /// Writes a pack with the given pack and rule ids.
+ /// The pack id.
+ /// The rule id.
+ /// The path written.
+ private string WritePack(string packId, string ruleId)
+ {
+ string path = Path.Join(this.WorkingDirectory, packId + ".tmrules.json");
+ File.WriteAllText(path, PackTemplate.Replace("PACK", packId, StringComparison.Ordinal)
+ .Replace("RULE", ruleId, StringComparison.Ordinal));
+ return path;
+ }
+ }
+}
diff --git a/test/ThreatModelForge.Api.Tests/ApiEndpointsTest.cs b/test/ThreatModelForge.Api.Tests/ApiEndpointsTest.cs
new file mode 100644
index 0000000..2e0f0a7
--- /dev/null
+++ b/test/ThreatModelForge.Api.Tests/ApiEndpointsTest.cs
@@ -0,0 +1,428 @@
+namespace ThreatModelForge.Api.Tests
+{
+ using System;
+ using System.Net;
+ using System.Net.Http;
+ using System.Text;
+ using System.Text.Json;
+ using System.Threading.Tasks;
+ using Microsoft.AspNetCore.Mvc.Testing;
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ ///
+ /// Tests the hosted /v1 surface over real HTTP. The rest of this project drives
+ /// EngineService directly, which leaves everything the host itself owns unexercised:
+ /// routing, status codes, model binding, query-string handling, content types, and download file
+ /// names. Those are the parts a client actually depends on, and none of them are visible from a
+ /// facade-level test.
+ ///
+ [TestClass]
+ public class ApiEndpointsTest
+ {
+ /// A minimal but real model: one process, no flows.
+ private const string Model =
+ "{\"schema\":\"tmforge-json\",\"version\":\"0.1\"," +
+ "\"elements\":[{\"id\":\"a\",\"kind\":\"process\",\"name\":\"Alpha\",\"x\":10,\"y\":10,\"width\":120,\"height\":60}]," +
+ "\"flows\":[]}";
+
+ ///
+ /// The in-memory host. Program is a static class and cannot be a type argument, so the
+ /// factory is anchored on a public type from the same assembly — it only uses the type to
+ /// locate that assembly's entry point.
+ ///
+ private static WebApplicationFactory? factory;
+
+ private static HttpClient? client;
+
+ /// Gets the shared client.
+ private static HttpClient Client => client ?? throw new InvalidOperationException("Host not started.");
+
+ /// Starts one host for the whole class; booting it per test would dominate the run.
+ /// The MSTest context.
+ [ClassInitialize]
+ public static void ClassInitialize(TestContext context)
+ {
+ factory = new WebApplicationFactory();
+ client = factory.CreateClient();
+ }
+
+ /// Shuts the host down.
+ [ClassCleanup]
+ public static void ClassCleanup()
+ {
+ client?.Dispose();
+ factory?.Dispose();
+ }
+
+ /// Verifies the health probe the container smoke test and orchestrators depend on.
+ /// A task.
+ [TestMethod]
+ public async Task Health_ReportsOk()
+ {
+ using HttpResponseMessage response = await Client.GetAsync("/v1/health");
+
+ Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
+ Assert.AreEqual("application/json", response.Content.Headers.ContentType?.MediaType);
+ using JsonDocument body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ Assert.AreEqual("ok", body.RootElement.GetProperty("status").GetString());
+ }
+
+ ///
+ /// Verifies every catalog route serves a non-empty collection. A non-empty body is what
+ /// separates "the route is wired" from "the engine is actually behind it".
+ ///
+ /// The catalog route.
+ /// A task.
+ [TestMethod]
+ [DataRow("/v1/formats")]
+ [DataRow("/v1/stencils")]
+ [DataRow("/v1/stencil-packs")]
+ [DataRow("/v1/rules")]
+ [DataRow("/v1/rule-packs")]
+ [DataRow("/v1/property-schema")]
+ public async Task Catalogs_ServeNonEmptyCollections(string route)
+ {
+ using HttpResponseMessage response = await Client.GetAsync(route);
+
+ Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
+ Assert.AreEqual("application/json", response.Content.Headers.ContentType?.MediaType);
+ using JsonDocument body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ Assert.AreEqual(JsonValueKind.Array, body.RootElement.ValueKind);
+ Assert.IsTrue(body.RootElement.GetArrayLength() > 0, route + " served an empty catalog.");
+ }
+
+ ///
+ /// Verifies the rule bundle is served with the shape the Studio reads. A default host loads no
+ /// custom packs, so the pack list is legitimately empty here —
+ /// covers a host that has been given one.
+ ///
+ /// A task.
+ [TestMethod]
+ public async Task RuleBundle_IsServedWithNoCustomPacksByDefault()
+ {
+ using HttpResponseMessage response = await Client.GetAsync("/v1/rule-bundle");
+
+ Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
+ using JsonDocument body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ Assert.AreEqual(JsonValueKind.Array, body.RootElement.GetProperty("rulePacks").ValueKind);
+ Assert.AreEqual(0, body.RootElement.GetProperty("rulePacks").GetArrayLength());
+ Assert.AreEqual(0, body.RootElement.GetProperty("diagnostics").GetArrayLength());
+ }
+
+ /// Verifies the analysis routes accept a model and answer with JSON.
+ /// The analysis route.
+ /// A task.
+ [TestMethod]
+ [DataRow("/v1/model/analyze")]
+ [DataRow("/v1/model/analysis")]
+ [DataRow("/v1/model/analysis-document")]
+ [DataRow("/v1/model/threats")]
+ [DataRow("/v1/model/threat-register")]
+ public async Task ModelRoutes_AcceptAModelAndAnswerJson(string route)
+ {
+ using HttpResponseMessage response = await PostJson(route, Model);
+
+ Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
+ Assert.AreEqual("application/json", response.Content.Headers.ContentType?.MediaType);
+ using JsonDocument body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ Assert.AreNotEqual(JsonValueKind.Null, body.RootElement.ValueKind);
+ }
+
+ /// Verifies a three-way merge is accepted in the shape the Studio posts it.
+ /// A task.
+ [TestMethod]
+ public async Task Merge_AcceptsBaseOursAndTheirs()
+ {
+ string request = "{\"base\":" + Model + ",\"ours\":" + Model + ",\"theirs\":" + Model + "}";
+
+ using HttpResponseMessage response = await PostJson("/v1/model/merge", request);
+
+ Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
+ using JsonDocument body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ Assert.AreEqual(JsonValueKind.Object, body.RootElement.ValueKind);
+ }
+
+ /// Verifies the .tm7 export is delivered as a downloadable XML document.
+ /// A task.
+ [TestMethod]
+ public async Task ExportTm7_DownloadsXml()
+ {
+ using HttpResponseMessage response = await PostJson("/v1/model/export/tm7", Model);
+
+ Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
+ Assert.AreEqual("application/xml", response.Content.Headers.ContentType?.MediaType);
+ Assert.AreEqual("model.tm7", response.Content.Headers.ContentDisposition?.FileName);
+
+ // The MTMT root element, not merely well-formed XML: this is what makes the download a .tm7.
+ StringAssert.StartsWith(await response.Content.ReadAsStringAsync(), "
+ /// Verifies each conversion target carries the content type and download name a browser needs.
+ /// These pairings live only in the host, so nothing below it can catch them being swapped.
+ ///
+ /// The target format id.
+ /// The expected content type.
+ /// The expected download file name.
+ /// A task.
+ [TestMethod]
+ [DataRow("tm7", "application/xml", "model.tm7")]
+ [DataRow("drawio", "application/xml", "model.drawio")]
+ [DataRow("vsdx", "application/vnd.ms-visio.drawing", "model.vsdx")]
+ [DataRow("tmforge-json", "application/json", "model.tmforge.json")]
+ public async Task Convert_LabelsEachTargetFormat(string format, string contentType, string fileName)
+ {
+ using HttpResponseMessage response = await PostJson("/v1/model/convert?to=" + format, Model);
+
+ Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
+ Assert.AreEqual(contentType, response.Content.Headers.ContentType?.MediaType);
+ Assert.AreEqual(fileName, response.Content.Headers.ContentDisposition?.FileName);
+ }
+
+ /// Verifies the threat-model report is served as HTML or SVG on request.
+ /// The report format.
+ /// The expected content type.
+ /// The expected download file name.
+ /// A task.
+ [TestMethod]
+ [DataRow("html", "text/html", "report.html")]
+ [DataRow("svg", "image/svg+xml", "report.svg")]
+ public async Task Report_ServesTheRequestedRendering(string format, string contentType, string fileName)
+ {
+ using HttpResponseMessage response = await PostJson("/v1/model/report?format=" + format, Model);
+
+ Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
+ Assert.AreEqual(contentType, response.Content.Headers.ContentType?.MediaType);
+ Assert.AreEqual(fileName, response.Content.Headers.ContentDisposition?.FileName);
+ }
+
+ ///
+ /// Verifies the analysis evidence is named the way tmforge analyze --reportFolder names
+ /// it, so a downloaded artifact drops straight into a review folder or a CI upload.
+ ///
+ /// The evidence format.
+ /// The expected content type.
+ /// The expected download file name.
+ /// A task.
+ [TestMethod]
+ [DataRow("sarif", "application/sarif+json", "findings.sarif")]
+ [DataRow("json", "application/json", "findings.json")]
+ [DataRow("html", "text/html", "findings.html")]
+ public async Task AnalysisReport_ServesTheRequestedEvidence(string format, string contentType, string fileName)
+ {
+ using HttpResponseMessage response = await PostJson("/v1/model/analysis-report?format=" + format, Model);
+
+ Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
+ Assert.AreEqual(contentType, response.Content.Headers.ContentType?.MediaType);
+ Assert.AreEqual(fileName, response.Content.Headers.ContentDisposition?.FileName);
+ }
+
+ /// Verifies SARIF served over HTTP is valid SARIF, not just bytes with a SARIF name.
+ /// A task.
+ [TestMethod]
+ public async Task AnalysisReport_ServesRealSarif()
+ {
+ using HttpResponseMessage response = await PostJson("/v1/model/analysis-report?format=sarif", Model);
+
+ using JsonDocument body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ Assert.AreEqual("2.1.0", body.RootElement.GetProperty("version").GetString());
+ Assert.IsTrue(body.RootElement.GetProperty("runs").GetArrayLength() > 0);
+ }
+
+ /// Verifies an uploaded model is decoded from base64 and read back as a model.
+ /// A task.
+ [TestMethod]
+ public async Task Read_DecodesAnUploadedModel()
+ {
+ string request = "{\"contentBase64\":\"" + Base64(Model) + "\",\"formatId\":\"tmforge-json\"}";
+
+ using HttpResponseMessage response = await PostJson("/v1/model/read", request);
+
+ Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
+ using JsonDocument body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ Assert.AreEqual("Alpha", body.RootElement.GetProperty("elements")[0].GetProperty("name").GetString());
+ }
+
+ /// Verifies format detection answers with the format it recognized.
+ /// A task.
+ [TestMethod]
+ public async Task Detect_IdentifiesAKnownFormat()
+ {
+ using HttpResponseMessage response = await PostJson("/v1/detect", "{\"contentBase64\":\"" + Base64(Model) + "\"}");
+
+ Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
+ using JsonDocument body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ Assert.AreEqual("tmforge-json", body.RootElement.GetProperty("id").GetString());
+ }
+
+ ///
+ /// Verifies unrecognized content is a 404 rather than a 200 carrying null. This is the only
+ /// route with a two-result union, so it is the only one where that distinction can regress.
+ ///
+ /// A task.
+ [TestMethod]
+ public async Task Detect_ReportsNotFoundForUnrecognizedContent()
+ {
+ using HttpResponseMessage response = await PostJson("/v1/detect", "{\"contentBase64\":\"" + Base64("not a model") + "\"}");
+
+ Assert.AreEqual(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ ///
+ /// Verifies malformed input is refused as a client error. Without this the host could start
+ /// answering 500 for a bad request body and nothing would notice.
+ ///
+ /// The request body.
+ /// A task.
+ [TestMethod]
+ [DataRow("{ this is not json", DisplayName = "malformed JSON")]
+ [DataRow("null", DisplayName = "null body")]
+ public async Task Analyze_RejectsAnUnusableBody(string body)
+ {
+ using HttpResponseMessage response = await PostJson("/v1/model/analyze", body);
+
+ Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ /// Verifies a required query parameter is enforced by the host, not by the engine.
+ /// A task.
+ [TestMethod]
+ public async Task Convert_RequiresATargetFormat()
+ {
+ using HttpResponseMessage response = await PostJson("/v1/model/convert", Model);
+
+ Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ ///
+ /// Verifies the OpenAPI document is served, since the Studio's client types are generated from
+ /// it and a host that stops publishing it breaks that generation silently.
+ ///
+ /// A task.
+ [TestMethod]
+ public async Task OpenApi_DocumentIsServed()
+ {
+ using HttpResponseMessage response = await Client.GetAsync("/openapi/v1.json");
+
+ Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
+ using JsonDocument body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ Assert.IsTrue(body.RootElement.GetProperty("paths").TryGetProperty("/v1/health", out _));
+ }
+
+ ///
+ /// Verifies input the caller got wrong is reported as a client error, not a server error.
+ /// A 500 says the server broke and invites a retry; none of these can succeed on retry, so
+ /// each one has to be a 400 that names what was unusable.
+ ///
+ /// The route to call.
+ /// The request body.
+ /// A task.
+ [TestMethod]
+ [DataRow("/v1/model/convert?to=nonsense", Model, DisplayName = "unknown conversion target")]
+ [DataRow("/v1/model/convert?to=", Model, DisplayName = "empty conversion target")]
+ [DataRow("/v1/model/read", "{\"contentBase64\":\"eyJ9\",\"formatId\":\"nonsense\"}", DisplayName = "unknown read format")]
+ [DataRow("/v1/model/read", "{\"contentBase64\":\"AQID\",\"formatId\":\"tmforge-json\"}", DisplayName = "bytes that are not the named format")]
+ [DataRow("/v1/model/read", "{\"contentBase64\":\"!!not base64!!\"}", DisplayName = "malformed base64")]
+ [DataRow("/v1/detect", "{\"contentBase64\":\"!!not base64!!\"}", DisplayName = "malformed base64 on detect")]
+ public async Task CallerInputErrors_AreReportedAsBadRequest(string route, string body)
+ {
+ using HttpResponseMessage response = await PostJson(route, body);
+
+ Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ ///
+ /// Verifies a bad request carries a problem document naming what went wrong, so the caller can
+ /// correct the request instead of guessing which parameter the server disliked.
+ ///
+ /// A task.
+ [TestMethod]
+ public async Task ABadRequestExplainsWhatWasUnusable()
+ {
+ using HttpResponseMessage response = await PostJson("/v1/model/convert?to=nonsense", Model);
+
+ Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode);
+ Assert.AreEqual("application/problem+json", response.Content.Headers.ContentType?.MediaType);
+ using JsonDocument body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ Assert.AreEqual(400, body.RootElement.GetProperty("status").GetInt32());
+ StringAssert.Contains(body.RootElement.GetProperty("detail").GetString(), "nonsense");
+ }
+
+ ///
+ /// Verifies a genuine server fault is still a 500. The bad-request handling above classifies by
+ /// exception type, so this guards the other side of that line: widening it until everything
+ /// looks like the caller's fault would hide real breakage behind a 400.
+ ///
+ /// A task.
+ [TestMethod]
+ public async Task ReportFormatThatIsNotRecognized_StillRendersRatherThanFailing()
+ {
+ // An unknown report format is not an error at all: it falls back to HTML by design.
+ using HttpResponseMessage response = await PostJson("/v1/model/report?format=nonsense", Model);
+
+ Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
+ Assert.AreEqual("text/html", response.Content.Headers.ContentType?.MediaType);
+ }
+
+ ///
+ /// Verifies a mistyped API path is answered as an API 404, not with the Studio's HTML shell.
+ /// The SPA fallback is registered for every unmatched path, so without a dedicated /v1
+ /// fallback a caller that misspells an endpoint receives 200 and an HTML document, and fails
+ /// while parsing it rather than seeing the status it deserves.
+ ///
+ /// A task.
+ [TestMethod]
+ public async Task UnknownV1Route_IsAnsweredAsAnApiNotFound()
+ {
+ using HttpResponseMessage response = await Client.GetAsync("/v1/no-such-endpoint");
+
+ Assert.AreEqual(HttpStatusCode.NotFound, response.StatusCode);
+ Assert.AreEqual("application/problem+json", response.Content.Headers.ContentType?.MediaType);
+ Assert.AreNotEqual(
+ "text/html",
+ response.Content.Headers.ContentType?.MediaType,
+ "a mistyped API path must not be answered with the SPA shell.");
+ using JsonDocument body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ Assert.AreEqual(404, body.RootElement.GetProperty("status").GetInt32());
+ }
+
+ ///
+ /// Verifies the SPA fallback still works for everything that is not an API path, so fixing the
+ /// /v1 case above cannot have broken client-side routing.
+ ///
+ /// An API-only build (-p:BuildStudio=false) has no wwwroot and answers 404, so the
+ /// assertion only holds the SPA to account when it is actually present.
+ ///
+ ///
+ /// A task.
+ [TestMethod]
+ public async Task NonApiRoute_StillReachesTheSpa()
+ {
+ using HttpResponseMessage response = await Client.GetAsync("/some-client-side-route");
+
+ if (response.StatusCode == HttpStatusCode.OK)
+ {
+ Assert.AreEqual("text/html", response.Content.Headers.ContentType?.MediaType);
+ }
+ else
+ {
+ Assert.AreEqual(HttpStatusCode.NotFound, response.StatusCode);
+ }
+ }
+
+ /// Base64-encodes UTF-8 text.
+ /// The text.
+ /// The encoded text.
+ private static string Base64(string text) => Convert.ToBase64String(Encoding.UTF8.GetBytes(text));
+
+ /// Posts a JSON body to a route.
+ /// The route.
+ /// The JSON body.
+ /// The response.
+ private static async Task PostJson(string route, string body)
+ {
+ using StringContent content = new StringContent(body, Encoding.UTF8, "application/json");
+ return await Client.PostAsync(route, content);
+ }
+ }
+}
diff --git a/test/ThreatModelForge.Api.Tests/InputErrorHandlerTest.cs b/test/ThreatModelForge.Api.Tests/InputErrorHandlerTest.cs
new file mode 100644
index 0000000..f207d2a
--- /dev/null
+++ b/test/ThreatModelForge.Api.Tests/InputErrorHandlerTest.cs
@@ -0,0 +1,96 @@
+namespace ThreatModelForge.Api.Tests
+{
+ using System;
+ using System.IO;
+ using System.Text.Json;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ ///
+ /// Tests where draws the line between the caller's fault and the
+ /// server's. The endpoint tests cover the first half over HTTP; this covers the second, which
+ /// cannot be reached that way without an endpoint that fails on purpose. Widening the
+ /// classification until every failure looks like a bad request would hide real breakage behind a
+ /// 400, and nothing else in the suite would notice.
+ ///
+ [TestClass]
+ public class InputErrorHandlerTest
+ {
+ /// Verifies the failures caused by a request are answered rather than left to become 500s.
+ /// A task.
+ [TestMethod]
+ public async Task CallerInputIsHandled()
+ {
+ foreach (Exception error in new Exception[]
+ {
+ new NotSupportedException("No threat model format with id 'nonsense'."),
+ new ArgumentException("Value cannot be null or empty.", "formatId"),
+ new FormatException("The input is not a valid Base-64 string."),
+ new JsonException("'0x01' is an invalid start of a value."),
+ new BadHttpRequestException("Request body was not valid JSON.", StatusCodes.Status400BadRequest),
+ })
+ {
+ DefaultHttpContext context = NewContext();
+
+ bool handled = await new InputErrorHandler().TryHandleAsync(context, error, CancellationToken.None);
+
+ Assert.IsTrue(handled, error.GetType().Name + " should be reported as a bad request.");
+ Assert.AreEqual(StatusCodes.Status400BadRequest, context.Response.StatusCode);
+ }
+ }
+
+ ///
+ /// Verifies an unexpected failure is declined, so it still surfaces as a server error. This is
+ /// the guard on the classification staying narrow.
+ ///
+ /// A task.
+ [TestMethod]
+ public async Task AnUnexpectedFailureIsLeftAsAServerError()
+ {
+ foreach (Exception error in new Exception[]
+ {
+ new InvalidOperationException("The engine reached an impossible state."),
+ new NullReferenceException(),
+ new IOException("The disk went away."),
+ })
+ {
+ DefaultHttpContext context = NewContext();
+
+ bool handled = await new InputErrorHandler().TryHandleAsync(context, error, CancellationToken.None);
+
+ Assert.IsFalse(handled, error.GetType().Name + " is not the caller's fault and must stay a 500.");
+ }
+ }
+
+ ///
+ /// Verifies the status model binding already chose is preserved. Installing the handler must not
+ /// re-label a request the framework had classified correctly on its own.
+ ///
+ /// A task.
+ [TestMethod]
+ public async Task AModelBindingStatusIsPreserved()
+ {
+ DefaultHttpContext context = NewContext();
+ BadHttpRequestException error = new BadHttpRequestException(
+ "Request body too large.",
+ StatusCodes.Status413PayloadTooLarge);
+
+ bool handled = await new InputErrorHandler().TryHandleAsync(context, error, CancellationToken.None);
+
+ Assert.IsTrue(handled);
+ Assert.AreEqual(StatusCodes.Status413PayloadTooLarge, context.Response.StatusCode);
+ }
+
+ /// Builds a context whose response body can be written to.
+ /// The context.
+ private static DefaultHttpContext NewContext()
+ {
+ DefaultHttpContext context = new DefaultHttpContext();
+ context.Response.Body = new MemoryStream();
+ context.Request.Path = "/v1/model/convert";
+ return context;
+ }
+ }
+}
diff --git a/test/ThreatModelForge.Api.Tests/ThreatModelForge.Api.Tests.csproj b/test/ThreatModelForge.Api.Tests/ThreatModelForge.Api.Tests.csproj
index c41072f..5a46c3d 100644
--- a/test/ThreatModelForge.Api.Tests/ThreatModelForge.Api.Tests.csproj
+++ b/test/ThreatModelForge.Api.Tests/ThreatModelForge.Api.Tests.csproj
@@ -10,6 +10,7 @@
+
diff --git a/test/ThreatModelForge.Cli.Tests/McpPathPolicyTest.cs b/test/ThreatModelForge.Cli.Tests/McpPathPolicyTest.cs
new file mode 100644
index 0000000..e08139e
--- /dev/null
+++ b/test/ThreatModelForge.Cli.Tests/McpPathPolicyTest.cs
@@ -0,0 +1,581 @@
+namespace ThreatModelForge.Cli.Tests
+{
+ using System;
+ using System.IO;
+ using System.IO.Compression;
+ using System.Text;
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ ///
+ /// Unit tests for 's rejection paths — the branches that exist only to
+ /// stop hostile input. McpToolsTest covers the policy through the tool layer (traversal,
+ /// symlink escapes, expansion bombs, oversized files); these drive the policy directly, because
+ /// the ZIP container pre-flight is a hand-rolled parser over attacker-controlled bytes and each
+ /// of its refusals needs a fixture that differs from a well-formed package by exactly one field.
+ ///
+ [TestClass]
+ public class McpPathPolicyTest
+ {
+ /// The fixed prelude standing in for a local file header and its data.
+ private const int PreludeBytes = 32;
+
+ /// Gets or sets the workspace root created for each test.
+ private string WorkingDirectory { get; set; } = string.Empty;
+
+ /// Creates an isolated workspace root for the test.
+ [TestInitialize]
+ public void Initialize()
+ {
+ this.WorkingDirectory = Path.Join(Path.GetTempPath(), "tmforge-policy-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(this.WorkingDirectory);
+ }
+
+ /// Removes the workspace root after the test.
+ [TestCleanup]
+ public void Cleanup()
+ {
+ if (Directory.Exists(this.WorkingDirectory))
+ {
+ Directory.Delete(this.WorkingDirectory, recursive: true);
+ }
+ }
+
+ ///
+ /// Verifies the fixture builder itself produces a container the policy accepts. Without this
+ /// every rejection below could pass for the wrong reason — a malformed baseline would throw
+ /// no matter which field the test corrupted.
+ ///
+ [TestMethod]
+ public void ValidateArchiveContainer_AcceptsTheWellFormedFixture()
+ {
+ McpPathPolicy policy = this.CreatePolicy();
+
+ policy.ValidateArchiveContainer(Package());
+ policy.ValidateArchiveContainer(Package(entryCount: 3));
+ }
+
+ /// Verifies that content which opens like a ZIP but carries no end record is refused.
+ [TestMethod]
+ public void ValidateArchiveContainer_RejectsMissingEndRecord()
+ {
+ byte[] content = new byte[64];
+ PutUInt32(content, 0, 0x04034b50);
+ McpPathPolicy policy = this.CreatePolicy();
+
+ InvalidDataException error = Assert.Throws(
+ () => policy.ValidateArchiveContainer(content));
+ StringAssert.Contains(error.Message, "end record");
+ }
+
+ ///
+ /// Verifies that the ZIP64 sentinels are refused rather than silently truncated to their
+ /// 32-bit values, which is how a ZIP64 package would otherwise smuggle past the size limits.
+ ///
+ /// The end-record field offset to fill with the sentinel.
+ /// The width of that field in bytes.
+ [TestMethod]
+ [DataRow(10, 2, DisplayName = "total entries = 0xFFFF")]
+ [DataRow(12, 4, DisplayName = "directory size = 0xFFFFFFFF")]
+ [DataRow(16, 4, DisplayName = "directory offset = 0xFFFFFFFF")]
+ public void ValidateArchiveContainer_RejectsZip64Sentinels(int offset, int width)
+ {
+ byte[] content = Package();
+ int end = EndRecord(content);
+ if (width == 2)
+ {
+ PutUInt16(content, end + offset, ushort.MaxValue);
+ }
+ else
+ {
+ PutUInt32(content, end + offset, uint.MaxValue);
+ }
+
+ McpPathPolicy policy = this.CreatePolicy();
+
+ InvalidDataException error = Assert.Throws(
+ () => policy.ValidateArchiveContainer(content));
+ StringAssert.Contains(error.Message, "ZIP64");
+ }
+
+ /// Verifies that a declared entry count above the cap is refused before the directory is walked.
+ [TestMethod]
+ public void ValidateArchiveContainer_RejectsTooManyDeclaredEntries()
+ {
+ byte[] content = Package();
+ PutUInt16(content, EndRecord(content) + 10, 4097);
+ McpPathPolicy policy = this.CreatePolicy();
+
+ InvalidDataException error = Assert.Throws(
+ () => policy.ValidateArchiveContainer(content));
+ StringAssert.Contains(error.Message, "4096 entries");
+ }
+
+ /// Verifies that an oversized central directory is refused on its declared size alone.
+ [TestMethod]
+ public void ValidateArchiveContainer_RejectsOversizedCentralDirectory()
+ {
+ byte[] content = Package();
+ PutUInt32(content, EndRecord(content) + 12, (4L * 1024 * 1024) + 1);
+ McpPathPolicy policy = this.CreatePolicy();
+
+ InvalidDataException error = Assert.Throws(
+ () => policy.ValidateArchiveContainer(content));
+ StringAssert.Contains(error.Message, "central directory exceeds");
+ }
+
+ ///
+ /// Verifies that a directory claiming to extend past the end record is refused. This is the
+ /// bound that keeps the walk below from reading outside the buffer.
+ ///
+ [TestMethod]
+ public void ValidateArchiveContainer_RejectsDirectoryRunningPastTheEndRecord()
+ {
+ byte[] content = Package();
+ int end = EndRecord(content);
+ PutUInt32(content, end + 16, end - 4);
+ McpPathPolicy policy = this.CreatePolicy();
+
+ InvalidDataException error = Assert.Throws(
+ () => policy.ValidateArchiveContainer(content));
+ StringAssert.Contains(error.Message, "invalid central directory");
+ }
+
+ /// Verifies that a central-directory record without its signature is refused.
+ [TestMethod]
+ public void ValidateArchiveContainer_RejectsMalformedEntrySignature()
+ {
+ byte[] content = Package();
+ PutUInt32(content, PreludeBytes, 0x02014b51);
+ McpPathPolicy policy = this.CreatePolicy();
+
+ InvalidDataException error = Assert.Throws(
+ () => policy.ValidateArchiveContainer(content));
+ StringAssert.Contains(error.Message, "malformed central directory entry");
+ }
+
+ /// Verifies that an entry name longer than the cap is refused.
+ [TestMethod]
+ public void ValidateArchiveContainer_RejectsOversizedEntryName()
+ {
+ byte[] content = Package();
+ PutUInt16(content, PreludeBytes + 28, 1025);
+ McpPathPolicy policy = this.CreatePolicy();
+
+ InvalidDataException error = Assert.Throws(
+ () => policy.ValidateArchiveContainer(content));
+ StringAssert.Contains(error.Message, "entry name exceeds");
+ }
+
+ ///
+ /// Verifies that an entry whose declared lengths push the cursor past the directory is refused.
+ /// A record can overshoot through its extra or comment length without its name being oversized.
+ ///
+ [TestMethod]
+ public void ValidateArchiveContainer_RejectsEntryRunningPastTheDirectory()
+ {
+ byte[] content = Package();
+ PutUInt16(content, PreludeBytes + 30, 1000);
+ McpPathPolicy policy = this.CreatePolicy();
+
+ InvalidDataException error = Assert.Throws(
+ () => policy.ValidateArchiveContainer(content));
+ StringAssert.Contains(error.Message, "entry length");
+ }
+
+ ///
+ /// Verifies that a directory holding more records than the end record declares is refused.
+ /// Trusting the count would leave the trailing records unvalidated.
+ ///
+ [TestMethod]
+ public void ValidateArchiveContainer_RejectsCountBelowDirectoryContent()
+ {
+ byte[] content = Package(entryCount: 2);
+ PutUInt16(content, EndRecord(content) + 10, 1);
+ McpPathPolicy policy = this.CreatePolicy();
+
+ InvalidDataException error = Assert.Throws(
+ () => policy.ValidateArchiveContainer(content));
+ StringAssert.Contains(error.Message, "does not match");
+ }
+
+ /// Verifies that content which is not a ZIP at all passes straight through.
+ [TestMethod]
+ public void ValidateArchiveContainer_IgnoresContentThatIsNotAnArchive()
+ {
+ McpPathPolicy policy = this.CreatePolicy();
+
+ policy.ValidateArchiveContainer(Encoding.UTF8.GetBytes(""));
+ policy.ValidateArchiveContainer(Array.Empty());
+ }
+
+ ///
+ /// Verifies that an entry whose declared expanded size alone blows the read budget is refused
+ /// before any of it is decompressed. As with the file read limit, the assertion names the
+ /// declared-size wording so the test cannot be satisfied by the streaming backstop instead.
+ ///
+ [TestMethod]
+ public void ValidateExpandedContent_RejectsEntryLargerThanTheRemainingBudget()
+ {
+ byte[] package = VisioPackage(4096);
+ McpPathPolicy policy = this.CreatePolicy(maxReadBytes: 1024);
+
+ InvalidDataException error = Assert.Throws(
+ () => policy.ValidateExpandedContent(package, "vsdx"));
+ StringAssert.Contains(error.Message, "declares 4096 bytes, over the remaining MCP read budget");
+ }
+
+ /// Verifies that the expansion budget is only applied to the format that carries an archive.
+ [TestMethod]
+ public void ValidateExpandedContent_IgnoresNonArchiveFormats()
+ {
+ byte[] package = VisioPackage(4096);
+ McpPathPolicy policy = this.CreatePolicy(maxReadBytes: 1024);
+
+ policy.ValidateExpandedContent(package, "tm7");
+ }
+
+ /// Verifies that an empty workspace root is refused at construction.
+ [TestMethod]
+ public void Constructor_RejectsAnEmptyRoot()
+ {
+ _ = Assert.Throws(() => new McpPathPolicy(" "));
+ }
+
+ /// Verifies that a workspace root that does not exist is refused at construction.
+ [TestMethod]
+ public void Constructor_RejectsAMissingRoot()
+ {
+ string missing = Path.Join(this.WorkingDirectory, "no-such-directory");
+
+ _ = Assert.Throws(() => new McpPathPolicy(missing));
+ }
+
+ ///
+ /// Verifies that a file cannot stand in for the workspace root. The refusal comes from the
+ /// directory check in the constructor, before any path is ever resolved against it.
+ ///
+ [TestMethod]
+ public void Constructor_RejectsARootThatIsAFile()
+ {
+ string file = Path.Join(this.WorkingDirectory, "root.txt");
+ File.WriteAllText(file, "x");
+
+ _ = Assert.Throws(() => new McpPathPolicy(file));
+ }
+
+ ///
+ /// Verifies that the read and write limits must be positive and fit an .
+ /// A non-positive or overflowing limit would disable the bound it exists to enforce.
+ ///
+ /// The candidate limit.
+ [TestMethod]
+ [DataRow(0L, DisplayName = "zero")]
+ [DataRow(-1L, DisplayName = "negative")]
+ [DataRow((long)int.MaxValue + 1, DisplayName = "beyond Int32")]
+ public void Constructor_RejectsLimitsOutsideTheSupportedRange(long limit)
+ {
+ _ = Assert.Throws(
+ () => new McpPathPolicy(this.WorkingDirectory, maxReadBytes: limit));
+ _ = Assert.Throws(
+ () => new McpPathPolicy(this.WorkingDirectory, maxWriteBytes: limit));
+ }
+
+ /// Verifies that an empty path is refused rather than resolving to the workspace root.
+ [TestMethod]
+ public void ReadAllBytes_RejectsAnEmptyPath()
+ {
+ McpPathPolicy policy = this.CreatePolicy();
+
+ _ = Assert.Throws(() => policy.ReadAllBytes(" "));
+ }
+
+ ///
+ /// Verifies that a missing file inside the root is reported as missing rather than as an escape,
+ /// so an agent gets an actionable error instead of a security refusal. The refusal comes from
+ /// path resolution, which walks every component before the file itself is opened.
+ ///
+ [TestMethod]
+ public void ReadAllBytes_RejectsAMissingFile()
+ {
+ McpPathPolicy policy = this.CreatePolicy();
+
+ _ = Assert.Throws(() => policy.ReadAllBytes("absent.tm7"));
+ }
+
+ ///
+ /// Verifies that a file larger than the read limit is refused on its reported length, before any
+ /// of it is buffered. The assertion is on the size wording specifically: the streaming backstop
+ /// further down enforces the same bound, so a test that accepted either message would still pass
+ /// with this pre-check deleted and the whole file read first.
+ ///
+ [TestMethod]
+ public void ReadAllBytes_RejectsAFileOverTheReadLimitBeforeBufferingIt()
+ {
+ File.WriteAllBytes(Path.Join(this.WorkingDirectory, "big.tm7"), new byte[2048]);
+ McpPathPolicy policy = this.CreatePolicy(maxReadBytes: 1024);
+
+ IOException error = Assert.Throws(() => policy.ReadAllBytes("big.tm7"));
+ StringAssert.Contains(error.Message, "2048 bytes, over the MCP read limit");
+ }
+
+ ///
+ /// Verifies that a file cannot be used as a directory component. Without this a path like
+ /// model.tm7/../../etc could be walked through a non-directory on some platforms.
+ ///
+ [TestMethod]
+ public void ReadAllBytes_RejectsANonDirectoryPathComponent()
+ {
+ File.WriteAllText(Path.Join(this.WorkingDirectory, "model.tm7"), "x");
+ McpPathPolicy policy = this.CreatePolicy();
+
+ _ = Assert.Throws(
+ () => policy.ReadAllBytes(Path.Join("model.tm7", "child.tm7")));
+ }
+
+ ///
+ /// Verifies that a symbolic link whose target does not exist never yields content.
+ ///
+ /// The link is created against rather than the raw temp
+ /// path on purpose. macOS hands out /var/folders/... while the canonical root is
+ /// /private/var/folders/..., so a link built from the raw path is refused for being
+ /// outside the root — which would make this pass without ever exercising a dangling link.
+ ///
+ ///
+ /// The exception type is not pinned because the platforms disagree on how a dangling link
+ /// resolves: .NET on macOS returns a target that simply does not exist (reported as missing),
+ /// while a runtime that returns no target at all reports a refusal. Both satisfy the property
+ /// that matters here — the read fails and no content is produced.
+ ///
+ ///
+ [TestMethod]
+ public void ReadAllBytes_NeverFollowsADanglingSymbolicLink()
+ {
+ McpPathPolicy policy = this.CreatePolicy();
+ string link = Path.Join(policy.RootPath, "dangling.tm7");
+ File.CreateSymbolicLink(link, Path.Join(policy.RootPath, "never-created.tm7"));
+
+ try
+ {
+ _ = policy.ReadAllBytes("dangling.tm7");
+ Assert.Fail("A dangling symbolic link must not produce content.");
+ }
+ catch (FileNotFoundException)
+ {
+ }
+ catch (UnauthorizedAccessException)
+ {
+ }
+ }
+
+ ///
+ /// Verifies that a symbolic link pointing outside the workspace is refused. This is the escape
+ /// the containment check exists for, and unlike the dangling case it is the same everywhere.
+ ///
+ [TestMethod]
+ public void ReadAllBytes_RejectsASymbolicLinkOutOfTheWorkspace()
+ {
+ string outside = Path.Join(Path.GetTempPath(), "tmforge-outside-" + Guid.NewGuid().ToString("N") + ".tm7");
+ File.WriteAllText(outside, "x");
+ McpPathPolicy policy = this.CreatePolicy();
+ File.CreateSymbolicLink(Path.Join(policy.RootPath, "escape.tm7"), outside);
+
+ try
+ {
+ UnauthorizedAccessException error = Assert.Throws(
+ () => policy.ReadAllBytes("escape.tm7"));
+ StringAssert.Contains(error.Message, "confined to the configured workspace root");
+ }
+ finally
+ {
+ File.Delete(outside);
+ }
+ }
+
+ ///
+ /// Verifies that the policy refuses to operate once its root has been removed, rather than
+ /// resolving paths against a directory that no longer exists.
+ ///
+ [TestMethod]
+ public void ReadAllBytes_RejectsWorkFromARootThatHasBeenRemoved()
+ {
+ McpPathPolicy policy = this.CreatePolicy();
+ Directory.Delete(this.WorkingDirectory, recursive: true);
+
+ UnauthorizedAccessException error = Assert.Throws(
+ () => policy.ReadAllBytes("model.tm7"));
+ StringAssert.Contains(error.Message, "no longer available");
+ }
+
+ /// Verifies that an empty destination path is refused before a format is chosen for it.
+ [TestMethod]
+ public void ResolveWriteTarget_RejectsAnEmptyPath()
+ {
+ McpPathPolicy policy = this.CreatePolicy();
+
+ _ = Assert.Throws(() => policy.ResolveWriteTarget(" ", formatId: null));
+ }
+
+ ///
+ /// Verifies that a destination without a registered model extension is refused, so the write
+ /// tools cannot be used to drop arbitrary files into the workspace.
+ ///
+ [TestMethod]
+ public void ResolveWriteTarget_RejectsAnUnregisteredExtension()
+ {
+ McpPathPolicy policy = this.CreatePolicy();
+
+ NotSupportedException error = Assert.Throws(
+ () => policy.ResolveWriteTarget("payload.sh", formatId: null));
+ StringAssert.Contains(error.Message, "registered threat-model file extension");
+ }
+
+ /// Verifies that a null serializer callback is refused before a temporary file is created.
+ [TestMethod]
+ public void WriteAtomically_RejectsANullCallback()
+ {
+ McpPathPolicy policy = this.CreatePolicy();
+
+ _ = Assert.Throws(() => policy.WriteAtomically("model.tm7", write: null!));
+ }
+
+ ///
+ /// Verifies that the write bound holds when a serializer grows the file with
+ /// instead of writing bytes.
+ ///
+ [TestMethod]
+ public void WriteAtomically_RejectsSetLengthBeyondTheWriteLimit()
+ {
+ McpPathPolicy policy = this.CreatePolicy(maxWriteBytes: 64);
+
+ IOException error = Assert.Throws(
+ () => policy.WriteAtomically("model.tm7", stream => stream.SetLength(4096)));
+ StringAssert.Contains(error.Message, "write limit");
+ Assert.IsFalse(File.Exists(Path.Join(this.WorkingDirectory, "model.tm7")));
+ }
+
+ ///
+ /// Verifies that the write bound holds when a serializer seeks past the limit before writing,
+ /// which would otherwise leave a sparse file larger than the cap.
+ ///
+ [TestMethod]
+ public void WriteAtomically_RejectsSeekingBeyondTheWriteLimit()
+ {
+ McpPathPolicy policy = this.CreatePolicy(maxWriteBytes: 64);
+
+ _ = Assert.Throws(
+ () => policy.WriteAtomically("model.tm7", stream => stream.Position = 4096));
+ _ = Assert.Throws(
+ () => policy.WriteAtomically("model.tm7", stream => stream.Seek(4096, SeekOrigin.Begin)));
+ Assert.IsFalse(File.Exists(Path.Join(this.WorkingDirectory, "model.tm7")));
+ }
+
+ /// Verifies that the bounded output stream is write-only, as a serializer target should be.
+ [TestMethod]
+ public void WriteAtomically_ExposesAWriteOnlyStream()
+ {
+ McpPathPolicy policy = this.CreatePolicy();
+
+ _ = policy.WriteAtomically(
+ "model.tm7",
+ stream =>
+ {
+ Assert.IsFalse(stream.CanRead);
+ _ = Assert.Throws(() => stream.Read(new byte[1], 0, 1));
+ stream.Write(new byte[] { 1, 2, 3 }, 0, 3);
+ });
+
+ Assert.AreEqual(3, new FileInfo(Path.Join(this.WorkingDirectory, "model.tm7")).Length);
+ }
+
+ /// Writes a big-endian-safe unsigned 16-bit value at the given offset.
+ /// The buffer to patch.
+ /// The offset to write at.
+ /// The value to store.
+ private static void PutUInt16(byte[] content, int offset, int value)
+ {
+ content[offset] = (byte)(value & 0xFF);
+ content[offset + 1] = (byte)((value >> 8) & 0xFF);
+ }
+
+ /// Writes an unsigned 32-bit value at the given offset.
+ /// The buffer to patch.
+ /// The offset to write at.
+ /// The value to store.
+ private static void PutUInt32(byte[] content, int offset, long value)
+ {
+ content[offset] = (byte)(value & 0xFF);
+ content[offset + 1] = (byte)((value >> 8) & 0xFF);
+ content[offset + 2] = (byte)((value >> 16) & 0xFF);
+ content[offset + 3] = (byte)((value >> 24) & 0xFF);
+ }
+
+ /// Returns the offset of the end-of-central-directory record in a fixture package.
+ /// The fixture package.
+ /// The end-record offset.
+ private static int EndRecord(byte[] content) => content.Length - 22;
+
+ ///
+ /// Builds a structurally valid ZIP container: a local-header prelude so the content is
+ /// recognized as an archive, one central-directory record per entry, and an end record. Every
+ /// rejection test starts here and corrupts exactly one field.
+ ///
+ /// The number of central-directory records to emit.
+ /// The length of each record's entry name.
+ /// The fixture package.
+ private static byte[] Package(int entryCount = 1, int nameLength = 5)
+ {
+ int recordBytes = 46 + nameLength;
+ int directorySize = entryCount * recordBytes;
+ byte[] content = new byte[PreludeBytes + directorySize + 22];
+
+ // A local file header signature, so the policy treats the content as an archive.
+ PutUInt32(content, 0, 0x04034b50);
+
+ for (int index = 0; index < entryCount; index++)
+ {
+ int record = PreludeBytes + (index * recordBytes);
+ PutUInt32(content, record, 0x02014b50);
+ PutUInt16(content, record + 28, nameLength);
+ PutUInt16(content, record + 30, 0);
+ PutUInt16(content, record + 32, 0);
+ for (int position = 0; position < nameLength; position++)
+ {
+ content[record + 46 + position] = (byte)'a';
+ }
+ }
+
+ int end = PreludeBytes + directorySize;
+ PutUInt32(content, end, 0x06054b50);
+ PutUInt16(content, end + 8, entryCount);
+ PutUInt16(content, end + 10, entryCount);
+ PutUInt32(content, end + 12, directorySize);
+ PutUInt32(content, end + 16, PreludeBytes);
+ PutUInt16(content, end + 20, 0);
+ return content;
+ }
+
+ /// Builds a real (deflate) Visio package holding one highly compressible entry.
+ /// The uncompressed size of the payload entry.
+ /// The package bytes.
+ private static byte[] VisioPackage(int expandedBytes)
+ {
+ using MemoryStream buffer = new MemoryStream();
+ using (ZipArchive archive = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ using Stream entry = archive.CreateEntry("visio/pages/page1.xml").Open();
+ entry.Write(Encoding.UTF8.GetBytes(new string('x', expandedBytes)));
+ }
+
+ return buffer.ToArray();
+ }
+
+ /// Creates a policy rooted at this test's workspace.
+ /// The read limit.
+ /// The write limit.
+ /// The policy.
+ private McpPathPolicy CreatePolicy(
+ long maxReadBytes = McpPathPolicy.DefaultMaxReadBytes,
+ long maxWriteBytes = McpPathPolicy.DefaultMaxWriteBytes)
+ => new McpPathPolicy(this.WorkingDirectory, maxReadBytes, maxWriteBytes);
+ }
+}