diff --git a/.github/workflows/handbook-build-check.yaml b/.github/workflows/handbook-build-check.yaml index 7877d723..093997db 100644 --- a/.github/workflows/handbook-build-check.yaml +++ b/.github/workflows/handbook-build-check.yaml @@ -23,12 +23,20 @@ on: - "scripts/assemble-handbook-screenshots.sh" - "scripts/assemble-handbook-store-listing.py" - "scripts/templates/store-listing.html.tmpl" + - "scripts/assemble-handbook-legal.py" + - "scripts/build-legal-downloads.sh" + - "scripts/templates/legal-downloads.html.tmpl" - "test/goldens/**" # Store-listing section is a derived export of the Fastlane metadata — # changing it must re-run the generator and re-commit the handbook. - "ios/fastlane/metadata/**" - "ios/fastlane/screenshots/**" - "android/fastlane/metadata/**" + # Legal-downloads section is a derived export of the in-app legal Markdown + # (and the ARB titles) — changing either must re-run the generator and + # re-commit the handbook HTML block. + - "assets/legal/**" + - "assets/languages/**" - "Dockerfile.handbook" - "handbook.nginx.conf" - "handbook.htpasswd" @@ -99,6 +107,23 @@ jobs: exit 1 fi + - name: Verify handbook legal-downloads section is in sync with assets/legal + # The legal-downloads block in docs/handbook/de/index.html is a derived + # export of assets/legal/*.md + the ARB titles. Re-run the (pure-stdlib, + # deterministic) generator; if the working tree changes, the committed + # handbook is stale — someone edited a legal .md or its ARB title without + # re-running the generator. Only the HTML block is gated here; the + # pandoc-produced PDF/DOCX are non-deterministic and intentionally + # git-ignored, so build-legal-downloads.sh is NOT run in this gate. + run: | + set -euo pipefail + python3 scripts/assemble-handbook-legal.py /tmp/legal-out + if ! git diff --quiet docs/handbook/de/index.html; then + echo "::error::docs/handbook/de/index.html legal-downloads block is stale — re-run scripts/assemble-handbook-legal.py and commit." + git diff docs/handbook/de/index.html + exit 1 + fi + - name: Build handbook image (no push) run: docker build -f Dockerfile.handbook -t realunit-handbook:pr-check . @@ -139,4 +164,16 @@ jobs: fi done + # Legal downloads must be present (built by the legal-docs-builder + # stage via pandoc). Same 200/401-vs-404 logic: 404 means the PDF/DOCX + # was not produced. Covers PDF + DOCX and both languages. + for f in legal/privacy_policy_de.pdf legal/privacy_policy_de.docx legal/terms_of_use_en.pdf legal/registration_agreement_de.docx; do + code=$(curl -s -o /dev/null -w '%{http_code}' -u "${HANDBOOK_USER:-x}:${HANDBOOK_PASS:-x}" "http://127.0.0.1:8080/${f}") + if [ "$code" = "404" ]; then + echo "legal download ${f} missing from /usr/share/nginx/html/legal/" >&2 + docker logs handbook + exit 1 + fi + done + docker stop handbook diff --git a/.gitignore b/.gitignore index a8b8014a..ce510890 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,14 @@ docs/handbook/mails/ # directory is only populated transiently for local previews. docs/handbook/screenshots/ +# Built at handbook build time from assets/legal/*.md via pandoc +# (scripts/build-legal-downloads.sh, see Dockerfile.handbook). The PDF/DOCX are +# NON-deterministic (pandoc embeds timestamps/metadata), so — unlike the +# committed legal-downloads HTML block in de/index.html — they are never +# committed and never sync-gated; they exist only inside the image. This +# directory holds the assembly output for local previews only. +docs/handbook/legal/ + # Scratch directories produced when reproducing the handbook CI's # mail-preview generation locally (see docs/handbook/README.md → "E-Mail # Previews → Lokal regenerieren"). Mirrors the names the CI uses so a diff --git a/Dockerfile.handbook b/Dockerfile.handbook index d2343c78..49abbf12 100644 --- a/Dockerfile.handbook +++ b/Dockerfile.handbook @@ -53,6 +53,32 @@ COPY android/fastlane/metadata/ ./android/fastlane/metadata/ COPY docs/handbook/de/index.html ./docs/handbook/de/index.html RUN python3 ./scripts/assemble-handbook-store-listing.py /out && cp ./docs/handbook/de/index.html /out/index.html +# Legal-downloads section: derived export of the in-app legal Markdown +# (assets/legal/*.md). Two separate concerns, by design: +# - assemble-handbook-legal.py rewrites the deterministic block in index.html (committed + sync-gated upstream). +# - build-legal-downloads.sh renders the NON-deterministic PDF/DOCX via pandoc +# (weasyprint PDF engine — no TeX), git-ignored like the screenshots. +# This stage takes the store-listing-rewritten index.html as input so BOTH the +# store-listing and the legal-downloads blocks survive into the final image. +FROM alpine:3.20 AS legal-docs-builder +WORKDIR /work +# font-dejavu (+ a rebuilt fontconfig cache) is required: weasyprint renders via +# Pango, which aborts ("No fonts configured in FontConfig" → "Error producing +# PDF") on a bare Alpine that ships no font files. DejaVu covers the Latin/German +# glyphs the legal texts use. +RUN apk add --no-cache python3 pandoc weasyprint bash font-dejavu \ + && fc-cache -f +COPY scripts/assemble-handbook-legal.py ./scripts/ +COPY scripts/build-legal-downloads.sh ./scripts/ +COPY scripts/templates/legal-downloads.html.tmpl ./scripts/templates/ +COPY assets/legal/ ./assets/legal/ +COPY assets/languages/ ./assets/languages/ +COPY --from=store-listing-builder /out/index.html ./docs/handbook/de/index.html +RUN python3 ./scripts/assemble-handbook-legal.py /out \ + && bash ./scripts/build-legal-downloads.sh /out \ + && cp ./docs/handbook/de/index.html /out/index.html + FROM nginx:1.27.5-alpine COPY docs/handbook/ /usr/share/nginx/html/ @@ -62,6 +88,11 @@ COPY --from=screenshots-builder /out/ /usr/share/nginx/html/screenshots/ COPY --from=store-listing-builder /out/ios/ /usr/share/nginx/html/store/ios/ COPY --from=store-listing-builder /out/android/ /usr/share/nginx/html/store/android/ COPY --from=store-listing-builder /out/index.html /usr/share/nginx/html/de/index.html +# Legal-downloads PDFs/DOCX + the index.html with BOTH the store-listing and the +# legal-downloads blocks rewritten (this copy overwrites the store-listing one +# above, so the legal stage's index.html is the authoritative final version). +COPY --from=legal-docs-builder /out/legal/ /usr/share/nginx/html/legal/ +COPY --from=legal-docs-builder /out/index.html /usr/share/nginx/html/de/index.html COPY handbook.nginx.conf /etc/nginx/conf.d/default.conf COPY handbook.htpasswd /etc/nginx/handbook.htpasswd diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 3246a214..e3f21595 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -28,7 +28,7 @@ "buyExecutedReference": "Ihre Referenz", "buyExecutedTitle": "Vielen Dank.", "buyMinAmount": "Mindestbetrag: ${amount} ${currency}", - "buyPaymentConfirm": "Klicken Sie hier, sobald Sie die Überweisung getätigt haben", + "buyPaymentConfirm": "Zahlungsanweisungen per E-Mail anfordern", "buyPaymentConfirmFailed": "Es gibt ein technisches Problem. Bitte versuchen Sie es später erneut. Falls der Fehler weiterhin besteht, kontaktieren Sie unseren Support.", "buyPaymentConfirmFailedAktionariat": "Es gibt ein technisches Problem. Bitte überprüfen Sie Ihr E-Mail-Postfach, möglicherweise fehlt noch eine Bestätigung Ihrer Blockchain-Adresse. Andernfalls versuchen Sie es später erneut. Falls der Fehler weiterhin besteht, kontaktieren Sie unseren Support.", "buyPaymentInformation": "Zahlungsinformationen", @@ -148,6 +148,8 @@ "legalDisclaimerTitle": "Wichtige rechtliche Hinweise für Investoren & Bestätigung des Wohnsitzes", "legalDisclaimerTitle2": "Weitere rechtliche Hinweise", "legalDisclaimerYes": "Zustimmen", + "legalDocumentLoadFailed": "Dokument konnte nicht geladen werden", + "legalDocumentLoadFailedDescription": "Beim Laden des Dokuments ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", "legalDocuments": "Rechtsdokumente", "location": "Ort", "logout": "Abmelden", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 4b92637c..56e4db7f 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -28,7 +28,7 @@ "buyExecutedReference": "Your reference", "buyExecutedTitle": "Thank you.", "buyMinAmount": "Minimum amount: ${amount} ${currency}", - "buyPaymentConfirm": "Click here once you have made the transfer", + "buyPaymentConfirm": "Request payment instructions by email", "buyPaymentConfirmFailed": "There is a technical problem. Please try again later. If the error persists, contact our support team.", "buyPaymentConfirmFailedAktionariat": "There is a technical problem. Please check your email inbox — you may still need to confirm your blockchain address. Otherwise, please try again later. If the error persists, contact our support team.", "buyPaymentInformation": "Payment information", @@ -148,6 +148,8 @@ "legalDisclaimerTitle": "Important legal notices for investors & confirmation of residence", "legalDisclaimerTitle2": "Further legal notices", "legalDisclaimerYes": "Agree", + "legalDocumentLoadFailed": "Could not load the document", + "legalDocumentLoadFailedDescription": "Something went wrong while loading this document. Please try again.", "legalDocuments": "Legal documents", "location": "Location", "logout": "Logout", diff --git a/docs/handbook/de/index.html b/docs/handbook/de/index.html index f74f68c7..021f2748 100644 --- a/docs/handbook/de/index.html +++ b/docs/handbook/de/index.html @@ -608,6 +608,40 @@ .store-screenshots .src { font-size: 0.85em; } + /* Legal-downloads section (#spec-legal-downloads) — see + scripts/assemble-handbook-legal.py. Reuses the .test card chrome + (border + :target highlight) so each (document, language) entry has the + same direct-link affordance as the screenshot cards. */ + .test.legal-doc .downloads { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + padding: 12px 14px; + } + .test.legal-doc .doc-title { + flex-basis: 100%; + font-size: 13px; + color: var(--ink-2); + } + a.dl-btn { + text-decoration: none; + font-size: 12.5px; + font-weight: 600; + color: var(--brand); + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: 6px; + padding: 5px 12px; + transition: + background 0.15s, + border-color 0.15s, + color 0.15s; + } + a.dl-btn:hover { + background: var(--surface); + border-color: var(--brand); + } @@ -689,6 +723,9 @@
  • SStore-Listing
  • +
  • + LRechtsdokumente +
  • @@ -2411,6 +2448,126 @@

    10" Tablet (2560×1440)

    + + + + diff --git a/handbook.nginx.conf b/handbook.nginx.conf index 8d208f49..3cfa0332 100644 --- a/handbook.nginx.conf +++ b/handbook.nginx.conf @@ -78,6 +78,22 @@ server { return 302 /de/; } + # Legal downloads (/legal/*.pdf, /legal/*.docx) — the derived PDF/DOCX export + # of assets/legal/*.md. Force a download disposition (nginx's default + # mime.types has no .docx mapping; it would otherwise serve as + # application/octet-stream, which downloads but without an explicit + # filename). add_header is all-or-nothing per location, so the server-level + # security headers are restated here. auth_basic is inherited — these stay + # behind the same Basic-Auth gate as the rest of the handbook. + location ~* \.(pdf|docx)$ { + add_header Content-Disposition "attachment" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Cache-Control "private, no-store" always; + try_files $uri =404; + } + location / { try_files $uri $uri/ =404; } diff --git a/lib/packages/repository/wallet_repository.dart b/lib/packages/repository/wallet_repository.dart index 13b9d582..e2ffbce7 100644 --- a/lib/packages/repository/wallet_repository.dart +++ b/lib/packages/repository/wallet_repository.dart @@ -44,6 +44,14 @@ class WalletRepository { Future deleteWallet(int id) => _appDatabase.deleteWallet(id); + /// Full purge for the user-facing delete: removes the encrypted-seed row AND + /// the AES-GCM mnemonic key, so no recoverable seed material remains on + /// device. + Future purgeWallet(int id) async { + await _appDatabase.deleteWalletCompletely(id); + await _secureStorage.deleteMnemonicKey(); + } + Future _decryptWalletInfo(WalletInfo info) async { final key = await _secureStorage.getOrCreateMnemonicKey(); final decryptedSeed = SecureStorage.decryptSeed(key, info.seed); diff --git a/lib/packages/service/dfx/real_unit_sell_payment_info_service.dart b/lib/packages/service/dfx/real_unit_sell_payment_info_service.dart index e6ec146f..87158095 100644 --- a/lib/packages/service/dfx/real_unit_sell_payment_info_service.dart +++ b/lib/packages/service/dfx/real_unit_sell_payment_info_service.dart @@ -113,8 +113,8 @@ class RealUnitSellPaymentInfoService extends DFXAuthService { chainId: paymentInfo.eip7702.domain.chainId, address: paymentInfo.eip7702.delegatorAddress, nonce: paymentInfo.eip7702.userNonce, - r: '0x${authorizationSignature.r.toRadixString(16)}', - s: '0x${authorizationSignature.s.toRadixString(16)}', + r: '0x${authorizationSignature.r.toRadixString(16).padLeft(64, '0')}', + s: '0x${authorizationSignature.s.toRadixString(16).padLeft(64, '0')}', yParity: authorizationSignature.yParity, ), ), diff --git a/lib/packages/service/wallet_service.dart b/lib/packages/service/wallet_service.dart index 02322e93..a0a0fa70 100644 --- a/lib/packages/service/wallet_service.dart +++ b/lib/packages/service/wallet_service.dart @@ -274,7 +274,9 @@ class WalletService { Future deleteCurrentWallet() async { final id = _settingsRepository.currentWalletId!; - await _repository.deleteWallet(id); + // Full purge (seed row + mnemonic key), not an account-only delete — so no + // recoverable seed survives delete. + await _repository.purgeWallet(id); await _settingsRepository.removeCurrentWalletId(); } diff --git a/lib/packages/storage/secure_storage.dart b/lib/packages/storage/secure_storage.dart index 8472bae4..55c78b27 100644 --- a/lib/packages/storage/secure_storage.dart +++ b/lib/packages/storage/secure_storage.dart @@ -165,6 +165,14 @@ class SecureStorage { return key; } + /// Removes the AES-GCM key that decrypts stored seeds. Once gone, any + /// surviving encrypted seed is permanently undecryptable; a fresh key is + /// lazily minted on next creation. + // @no-integration-test: forwards to FlutterSecureStorage (Android Keystore / + // iOS Keychain) over a platform channel; real keystore removal is only + // verifiable on-device — the unit test mocks the plugin. + Future deleteMnemonicKey() => _secureStorage.delete(key: _mnemonicEncryptionKey); + static String encryptSeed(Uint8List key, String plaintext) { final iv = _secureRandomBytes(12); final cipher = GCMBlockCipher(AESEngine()) diff --git a/lib/packages/storage/wallet_storage.dart b/lib/packages/storage/wallet_storage.dart index 5eb8c83a..81317d76 100644 --- a/lib/packages/storage/wallet_storage.dart +++ b/lib/packages/storage/wallet_storage.dart @@ -28,6 +28,14 @@ extension WalletStorage on AppDatabase { Future deleteWallet(int walletId) => (delete(walletAccountInfos)..where((row) => row.wallet.equals(walletId))).go(); + /// Deletes the `walletInfos` row itself (the encrypted-seed record) after + /// clearing its dependent `walletAccountInfos` rows (FK in + /// [WalletAccountInfos.wallet]). + Future deleteWalletCompletely(int walletId) => transaction(() async { + await (delete(walletAccountInfos)..where((row) => row.wallet.equals(walletId))).go(); + await (delete(walletInfos)..where((row) => row.id.equals(walletId))).go(); + }); + Future get hasWallet => select(walletInfos).get().then((result) => result.isNotEmpty); } diff --git a/lib/screens/kyc/kyc_page_manager.dart b/lib/screens/kyc/kyc_page_manager.dart index 66a2d6f6..93694a3f 100644 --- a/lib/screens/kyc/kyc_page_manager.dart +++ b/lib/screens/kyc/kyc_page_manager.dart @@ -76,7 +76,11 @@ class KycViewManager extends StatelessWidget { KycStep.twoFa => const Kyc2FaPage(), KycStep.ident => KycIdentPage(accessToken: urlOrToken ?? ''), KycStep.financialData => KycFinancialDataPage(url: urlOrToken ?? ''), - (_) => const Scaffold(), + // Exhaustive over KycStep so a new value is a compile error here + // (forced handling) rather than a silent blank Scaffold. dfxApproval + // was the missing case that fell through to the old blank fallback. + KycStep.dfxApproval => + const KycPendingPage(pendingStep: KycStep.dfxApproval), }, KycState() => const Scaffold(), }, diff --git a/lib/screens/legal/subpages/legal_document_page.dart b/lib/screens/legal/subpages/legal_document_page.dart index a4f71b40..4994b730 100644 --- a/lib/screens/legal/subpages/legal_document_page.dart +++ b/lib/screens/legal/subpages/legal_document_page.dart @@ -1,3 +1,5 @@ +import 'dart:developer' as developer; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -7,6 +9,7 @@ import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; import 'package:realunit_wallet/screens/web_view/web_view_page.dart'; import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; +import 'package:realunit_wallet/styles/colors.dart'; import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; class LegalDocumentParams { @@ -41,6 +44,7 @@ class LegalDocumentPage extends StatefulWidget { class _LegalDocumentPageState extends State { String? _markdownContent; + bool _loadFailed = false; @override void initState() { @@ -54,16 +58,32 @@ class _LegalDocumentPageState extends State { Future _loadMarkdown() async { final code = context.read().state.language.code; + final assetPath = 'assets/legal/${widget.params.assetBaseName}_$code.md'; try { - final content = await rootBundle.loadString( - 'assets/legal/${widget.params.assetBaseName}_$code.md', - ); + // cache: false so Retry after a transient failure actually re-reads the + // asset instead of replaying rootBundle's cached error for this key. + final content = await rootBundle.loadString(assetPath, cache: false); if (mounted) setState(() => _markdownContent = content); - } catch (_) { - if (mounted) setState(() => _markdownContent = ''); + } catch (e, stackTrace) { + developer.log( + 'Failed to load legal document "${widget.params.assetBaseName}"', + name: 'realunit_wallet.legal', + error: e, + stackTrace: stackTrace, + level: 1000, // SEVERE + ); + if (mounted) setState(() => _loadFailed = true); } } + void _retryLoad() { + setState(() { + _loadFailed = false; + _markdownContent = null; + }); + _loadMarkdown(); + } + String? get _pdfUrl { if (widget.params.pdfUrls == null) return null; final code = context.read().state.language.code; @@ -75,7 +95,9 @@ class _LegalDocumentPageState extends State { appBar: AppBar( title: Text(widget.params.title), ), - body: _markdownContent != null + body: _loadFailed + ? _buildError(context) + : _markdownContent != null ? Column( children: [ Expanded( @@ -118,6 +140,46 @@ class _LegalDocumentPageState extends State { ), ], ) + // Documents load from a bundled asset (effectively synchronous), so the + // brief null frame stays blank rather than flashing a spinner. : const SizedBox.shrink(), ); + + // Mirrors the canonical full-page error view in + // settings_currencies_page.dart (`_ErrorView`); keep the two in sync. + Widget _buildError(BuildContext context) => Center( + key: const ValueKey('legalDocumentLoadError'), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 48, + color: RealUnitColors.neutral500, + ), + const SizedBox(height: 12), + Text( + S.of(context).legalDocumentLoadFailed, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + S.of(context).legalDocumentLoadFailedDescription, + style: const TextStyle(color: RealUnitColors.neutral500), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + OutlinedButton( + key: const ValueKey('legalDocumentRetryButton'), + onPressed: _retryLoad, + child: Text(S.of(context).retry), + ), + ], + ), + ), + ); } diff --git a/scripts/assemble-handbook-legal.py b/scripts/assemble-handbook-legal.py new file mode 100755 index 00000000..471ad7d3 --- /dev/null +++ b/scripts/assemble-handbook-legal.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Generate the handbook legal-downloads block from the in-app legal Markdown. + +The Markdown under assets/legal/ is the single source of truth for the three +RealUnit documents that the app renders in-app (LegalDocumentPage reads +assets/legal/_.md via rootBundle). The handbook section at +handbook.realunit.app is a *derived export* of those files — never hand-edited. +This mirrors the upstream/downstream relationship that the store-listing and +mails/ sections already have. + +Usage: + scripts/assemble-handbook-legal.py + +Used by: + - Dockerfile.handbook (legal-docs-builder stage → rewrites docs/handbook/de/ + index.html; the PDF/DOCX binaries are produced separately by + scripts/build-legal-downloads.sh — see the determinism note below) + - .github/workflows/handbook-build-check.yaml (sync gate: re-run + git diff) + - local previews (run it, then open docs/handbook/de/index.html) + +What it does: + 1. Discovers the document set: for each base in BASES, globs + assets/legal/_*.md to find the available languages. Errors out if a + base has zero languages. + 2. Resolves each document's title from assets/languages/strings_.arb + (the mapped ARB key per base, see TITLE_KEYS) so the handbook titles stay + in lockstep with the in-app titles. Falls back to the `de` title if a + language's ARB lacks the key. + 3. Renders scripts/templates/legal-downloads.html.tmpl into + /legal-downloads.html and substitutes the rendered block between the + / markers in + docs/handbook/de/index.html in place (idempotent). + +What it deliberately does NOT do: + - It does NOT invoke pandoc and does NOT emit any PDF/DOCX. Those binaries are + non-deterministic (embedded timestamps, tool-version metadata) and are + produced only inside the image by scripts/build-legal-downloads.sh, git- + ignored like the screenshots. Keeping pandoc out of this script is what lets + the rendered HTML block be deterministic and therefore sync-gateable. + + Every value interpolated into the HTML (titles from ARB, discovered language + codes) is HTML-escaped; the document bases are a fixed allowlist (BASES). +""" +import html +import re +import sys +from pathlib import Path + +BEGIN = "" +END = "" + +# The exact three in-app documents rendered from repo-local Markdown. DFX, +# Aktionariat and the externally-hosted corporate PDFs are out of scope — they +# have no Markdown source in the repo and cannot be a derived export. +BASES = ["privacy_policy", "terms_of_use", "registration_agreement"] + +# Maps each document base to the ARB key the app uses for its title, so the +# handbook label matches what the user sees in-app. +TITLE_KEYS = { + "privacy_policy": "legalDisclaimerCheckboxPrivacyPolicy", + "terms_of_use": "termsOfUse", + "registration_agreement": "legalDisclaimerCheckboxRegistrationAgreement", +} + +# Languages are discovered, never hardcoded; this only validates that a token +# extracted from a filename looks like a language code before it is used in an +# id / href / download path. +_LANG_RE = re.compile(r"^[a-z0-9-]+$") +_PLACEHOLDER = re.compile(r"\{\{ (\w+) \}\}") + +REPO = "https://github.com/RealUnitCH/app" + + +def _esc(value: str) -> str: + """HTML-escape a value for safe interpolation into text or an attribute.""" + return html.escape(value, quote=True) + + +def load_arb(repo: Path, lang: str) -> dict: + """Load assets/languages/strings_.arb (JSON). Errors if missing.""" + import json + + path = repo / "assets/languages" / f"strings_{lang}.arb" + if not path.is_file(): + raise SystemExit(f"error: ARB file missing for discovered language '{lang}': {path}") + return json.loads(path.read_text(encoding="utf-8")) + + +def discover(repo: Path) -> "dict[str, list[str]]": + """For each base, glob assets/legal/_*.md → sorted language list.""" + legal = repo / "assets/legal" + result = {} + for base in BASES: + langs = [] + for md in legal.glob(f"{base}_*.md"): + lang = md.name[len(base) + 1 : -len(".md")] + if not _LANG_RE.match(lang): + raise SystemExit(f"error: unexpected language token '{lang}' from {md.name}") + langs.append(lang) + if not langs: + raise SystemExit(f"error: no source Markdown found for '{base}' (assets/legal/{base}_*.md)") + result[base] = sorted(langs) + return result + + +def render_rows(doc_langs: "dict[str, list[str]]", titles: "dict[str, dict[str, str]]") -> str: + """Build the per-(base, lang) download cards. Deterministic ordering: + BASES order, then languages sorted alphabetically.""" + parts = [] + for base in BASES: + de_title = _esc(titles[base]["de"]) + parts.append( + f'

    {de_title} ' + f'

    ' + ) + parts.append('
    ') + for lang in doc_langs[base]: + anchor = f"legal-{base}-{lang}" + stem = f"{base}_{lang}" + title = _esc(titles[base][lang]) + lang_e = _esc(lang) + parts.append( + f' ' + ) + parts.append('
    ') + return "\n".join(parts) + + +def main() -> int: + if len(sys.argv) != 2: + print(f"usage: {sys.argv[0]} ", file=sys.stderr) + return 2 + + repo = Path(__file__).resolve().parent.parent + out = Path(sys.argv[1]) + + # 1) Discover documents + languages from the filesystem (never hardcoded). + doc_langs = discover(repo) + + # 2) Resolve titles from the ARB files, falling back to `de`. Every document + # must have a `de` source, since `de` is the per-document title and + # heading fallback language (render_rows() uses titles[base]["de"]). + for base in BASES: + if "de" not in doc_langs[base]: + raise SystemExit( + f"error: '{base}' has no `de` document (assets/legal/{base}_de.md) — " + "`de` is the per-document title/heading fallback language" + ) + all_langs = sorted({lang for langs in doc_langs.values() for lang in langs}) + arbs = {lang: load_arb(repo, lang) for lang in all_langs} + titles = {} + for base in BASES: + key = TITLE_KEYS[base] + de_title = arbs["de"].get(key) + if not de_title: + raise SystemExit(f"error: ARB key '{key}' (for '{base}') missing from strings_de.arb") + titles[base] = {} + for lang in doc_langs[base]: + titles[base][lang] = arbs[lang].get(key) or de_title + + # 3) Render the block from the template (single pass: each placeholder + # resolved exactly once, a substituted value is never re-scanned). + template = Path(__file__).parent / "templates/legal-downloads.html.tmpl" + ctx = {"rows": render_rows(doc_langs, titles)} + unknown = [] + + def substitute(match): + key = match.group(1) + if key not in ctx: + unknown.append(key) + return match.group(0) + return ctx[key] + + rendered = _PLACEHOLDER.sub(substitute, template.read_text(encoding="utf-8").strip("\n")) + if unknown: + print(f"error: unknown template placeholder(s): {sorted(set(unknown))}", file=sys.stderr) + return 1 + + out.mkdir(parents=True, exist_ok=True) + (out / "legal-downloads.html").write_text(rendered + "\n", encoding="utf-8") + + # 4) Substitute the block in docs/handbook/de/index.html in place (idempotent). + index = repo / "docs/handbook/de/index.html" + content = index.read_text(encoding="utf-8") + if BEGIN not in content or END not in content: + print( + f"error: {index} is missing the {BEGIN} / {END} markers — add them once " + "(see issue #658) before running this script.", + file=sys.stderr, + ) + return 1 + b = content.index(BEGIN) + e = content.index(END) + len(END) + new = content[:b] + BEGIN + "\n" + rendered + "\n" + END + content[e:] + index.write_text(new, encoding="utf-8") + + n = sum(len(v) for v in doc_langs.values()) + print(f"rendered legal-downloads block ({n} sources across {len(BASES)} docs) into {out}; synced {index}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/build-legal-downloads.sh b/scripts/build-legal-downloads.sh new file mode 100755 index 00000000..1cd7a1e7 --- /dev/null +++ b/scripts/build-legal-downloads.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# +# Build the downloadable PDF + DOCX of the in-app legal documents from their +# Markdown sources under assets/legal/, into /legal/. +# +# Usage: +# scripts/build-legal-downloads.sh +# +# Run ONLY inside the handbook image's legal-docs-builder stage (needs pandoc + +# weasyprint). The output is intentionally NON-deterministic — pandoc embeds +# timestamps and tool-version metadata — so it is treated like the assembled +# screenshots: generated only in the image, git-ignored, never committed, and +# never sync-gated. The deterministic HTML block (the download links) is the +# separate concern of scripts/assemble-handbook-legal.py. +# +# weasyprint is used as the PDF engine (HTML/CSS based) deliberately, to avoid +# pulling a full TeX Live into the image just for PDF rendering. +# +# Document discovery mirrors assemble-handbook-legal.py: the same three bases, +# languages discovered by glob (never hardcoded), so a future assets/legal/ +# _.md is picked up automatically. +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +out="$1" +# Resolve the repo root from this script's location (scripts/..), so the script +# works regardless of the caller's working directory. +script_dir="$(cd "$(dirname "$0")" && pwd)" +repo="$(cd "$script_dir/.." && pwd)" +legal_src="$repo/assets/legal" +legal_out="$out/legal" + +bases="privacy_policy terms_of_use registration_agreement" + +mkdir -p "$legal_out" + +count=0 +for base in $bases; do + found=0 + for md in "$legal_src/$base"_*.md; do + # Guard against a literal no-match glob. + [ -e "$md" ] || continue + found=1 + stem="$(basename "$md" .md)" + pandoc "$md" -o "$legal_out/$stem.docx" + pandoc "$md" --pdf-engine=weasyprint -o "$legal_out/$stem.pdf" + count=$((count + 2)) + done + if [ "$found" -eq 0 ]; then + echo "error: no source Markdown found for '$base' ($legal_src/${base}_*.md)" >&2 + exit 1 + fi +done + +echo "built $count legal download files into $legal_out" diff --git a/scripts/templates/legal-downloads.html.tmpl b/scripts/templates/legal-downloads.html.tmpl new file mode 100644 index 00000000..2e468421 --- /dev/null +++ b/scripts/templates/legal-downloads.html.tmpl @@ -0,0 +1,31 @@ + diff --git a/test/goldens/screens/home/goldens/macos/home_page_loaded.png b/test/goldens/screens/home/goldens/macos/home_page_loaded.png index 4dd0c568..84de9124 100644 Binary files a/test/goldens/screens/home/goldens/macos/home_page_loaded.png and b/test/goldens/screens/home/goldens/macos/home_page_loaded.png differ diff --git a/test/goldens/screens/legal/legal_document_golden_test.dart b/test/goldens/screens/legal/legal_document_golden_test.dart index b48d8a7b..1d7f5841 100644 --- a/test/goldens/screens/legal/legal_document_golden_test.dart +++ b/test/goldens/screens/legal/legal_document_golden_test.dart @@ -27,11 +27,15 @@ void main() { ); group('$LegalDocumentPage', () { + // Empty-content placeholder: renders the empty Markdown body before any + // document text is present. Passing an empty string via the + // @visibleForTesting hook keeps this deterministic (no async asset load, + // so it never races into the new load-error state). goldenTest( - 'initial state before markdown loads', + 'empty document placeholder', fileName: 'legal_document_page_default', constraints: const BoxConstraints.tightFor(width: 390, height: 844), - builder: () => wrapForGolden(buildSubject()), + builder: () => wrapForGolden(buildSubject(initialMarkdownContent: '')), ); // The page reads its markdown from rootBundle in production; for the diff --git a/test/packages/repository/wallet_repository_test.dart b/test/packages/repository/wallet_repository_test.dart index f049bca4..b9ab2ded 100644 --- a/test/packages/repository/wallet_repository_test.dart +++ b/test/packages/repository/wallet_repository_test.dart @@ -145,5 +145,34 @@ void main() { final afterAccounts = await db.getWalletAccounts(walletId); expect(afterAccounts, isEmpty); }); + + test('purgeWallet removes the walletInfos seed row AND the mnemonic key', () async { + // The user-facing delete must leave no recoverable seed material — + // neither the encrypted row nor the AES key. + when(() => secureStorage.deleteMnemonicKey()).thenAnswer((_) async {}); + + final walletId = await repo.createWallet(walletName, WalletType.software, seed, address); + await db.insertWalletAccount(walletId, 'acc-0', 0); + expect(await db.getWalletById(walletId), isNotNull); + + await repo.purgeWallet(walletId); + + expect(await db.getWalletById(walletId), isNull); // encrypted seed row gone + expect(await db.getWalletAccounts(walletId), isEmpty); // accounts gone + verify(() => secureStorage.deleteMnemonicKey()).called(1); // AES key removed + }); + + test('deleteWallet (account-only) leaves the seed row and mnemonic key intact', () async { + // Onboarding-regenerate contract: the account-only primitive must NOT + // wipe the seed row or the AES key. + final walletId = await repo.createWallet(walletName, WalletType.software, seed, address); + await db.insertWalletAccount(walletId, 'acc-0', 0); + + await repo.deleteWallet(walletId); + + expect(await db.getWalletById(walletId), isNotNull); // row survives + expect(await db.getWalletAccounts(walletId), isEmpty); // accounts gone + verifyNever(() => secureStorage.deleteMnemonicKey()); // key untouched + }); }); } diff --git a/test/packages/service/dfx/real_unit_sell_payment_info_service_confirm_test.dart b/test/packages/service/dfx/real_unit_sell_payment_info_service_confirm_test.dart index aacbe635..55695662 100644 --- a/test/packages/service/dfx/real_unit_sell_payment_info_service_confirm_test.dart +++ b/test/packages/service/dfx/real_unit_sell_payment_info_service_confirm_test.dart @@ -174,4 +174,59 @@ void main() { ); }); }); + + // The EIP-7702 authorization r/s components must always be serialized as full + // 32-byte (64 hex char) big-endian values. `BigInt.toRadixString(16)` drops + // leading zero bytes, so any signature whose r or s is < 2^248 used to emit + // < 64 hex chars (an invalid 32-byte field). The fix mirrors the BitBox + // reference with `.padLeft(64, '0')`. This sweep signs across many nonces so + // at least one deterministic signature lands on a leading-zero byte, and + // asserts every emitted r/s is exactly 64 hex chars. + group('confirmPayment EIP-7702 r/s 32-byte padding', () { + SellPaymentInfo infoForNonce(int nonce) { + final json = _validEip7702Json()..['userNonce'] = nonce; + return SellPaymentInfo( + id: 42, + eip7702: Eip7702Data.fromJson(json), + amount: 100, + exchangeRate: 1.0, + rate: 1.0, + beneficiary: const BeneficiaryDto(iban: 'CH...'), + estimatedAmount: 100.0, + currency: Currency.chf, + depositAddress: '0xdeposit', + tokenAddress: '0xtoken', + chainId: 1, + ethBalance: 0.1, + requiredGasEth: 0.001, + ); + } + + test('authorization r and s are always 0x + 64 hex chars across nonces', + () async { + for (var nonce = 0; nonce < 96; nonce++) { + Map? body; + final client = MockClient((request) async { + body = jsonDecode(request.body) as Map; + return http.Response('{}', 200); + }); + + await build(client).confirmPayment(infoForNonce(nonce)); + + final authorization = (body!['eip7702'] + as Map)['authorization'] as Map; + final r = authorization['r'] as String; + final s = authorization['s'] as String; + + expect(r.startsWith('0x'), isTrue); + expect(s.startsWith('0x'), isTrue); + expect(r.substring(2).length, 64, + reason: 'r must be a full 32-byte component (nonce=$nonce)'); + expect(s.substring(2).length, 64, + reason: 's must be a full 32-byte component (nonce=$nonce)'); + // Value is unchanged, only left-zero-padded. + expect(BigInt.parse(r.substring(2), radix: 16), isA()); + } + }); + }); } diff --git a/test/packages/service/wallet_service_test.dart b/test/packages/service/wallet_service_test.dart index 91ea64d9..24f19cfa 100644 --- a/test/packages/service/wallet_service_test.dart +++ b/test/packages/service/wallet_service_test.dart @@ -57,6 +57,7 @@ void main() { when(() => settings.saveCurrentWalletId(any())).thenAnswer((_) async => true); when(() => settings.removeCurrentWalletId()).thenAnswer((_) async => true); when(() => repo.deleteWallet(any())).thenAnswer((_) async {}); + when(() => repo.purgeWallet(any())).thenAnswer((_) async {}); when(() => repo.updateAddress(any(), any())).thenAnswer((_) async {}); }); @@ -440,7 +441,10 @@ void main() { await service.deleteCurrentWallet(); - verify(() => repo.deleteWallet(8)).called(1); + // User-facing delete must fully purge (seed row + mnemonic key), not + // the account-only delete. + verify(() => repo.purgeWallet(8)).called(1); + verifyNever(() => repo.deleteWallet(any())); verify(() => settings.removeCurrentWalletId()).called(1); }); }); diff --git a/test/packages/storage/secure_storage_test.dart b/test/packages/storage/secure_storage_test.dart index 19508ca6..96df3098 100644 --- a/test/packages/storage/secure_storage_test.dart +++ b/test/packages/storage/secure_storage_test.dart @@ -120,6 +120,12 @@ void main() { verify(() => mockStorage.delete(key: 'pin.salt')).called(1); }); + test('deleteMnemonicKey deletes the mnemonic encryption key', () async { + await secureStorage.deleteMnemonicKey(); + + verify(() => mockStorage.delete(key: 'wallet.mnemonic.encryption.key')).called(1); + }); + test('getPinSalt returns null when no salt is stored', () async { when( () => mockStorage.read(key: 'pin.salt'), diff --git a/test/screens/kyc/kyc_page_manager_test.dart b/test/screens/kyc/kyc_page_manager_test.dart new file mode 100644 index 00000000..cf2f56b2 --- /dev/null +++ b/test/screens/kyc/kyc_page_manager_test.dart @@ -0,0 +1,130 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/kyc/dto/kyc_level_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/kyc/dto/kyc_session_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/kyc/dto/kyc_step_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/user_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_info_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_state.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/kyc_page_manager.dart'; +import 'package:realunit_wallet/screens/kyc/subpages/kyc_pending_page.dart'; + +import '../../helper/helper.dart'; + +class _MockDfxKycService extends Mock implements DfxKycService {} + +class _MockRealUnitRegistrationService extends Mock + implements RealUnitRegistrationService {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockAWallet extends Mock implements AWallet {} + +UserKycDto _kycHeader({KycLevel level = KycLevel.level0}) => + UserKycDto(hash: 'h', level: level, dataComplete: false); + +UserDto _user({String? mail = 'test@example.com'}) => + UserDto(mail: mail, kyc: _kycHeader()); + +KycLevelDto _kycStatus({ + required KycLevel level, + List steps = const [], + KycProcessStatus processStatus = KycProcessStatus.inProgress, +}) => KycLevelDto(kycLevel: level, kycSteps: steps, processStatus: processStatus); + +KycSessionDto _session({ + required KycLevel level, + required List steps, + KycStepSessionDto? currentStep, + KycProcessStatus processStatus = KycProcessStatus.inProgress, +}) => KycSessionDto( + kycLevel: level, + kycSteps: steps, + currentStep: currentStep, + processStatus: processStatus, +); + +KycStepSessionDto _currentStep( + KycStepName name, { + String url = 'https://example.com/session', + UrlType urlType = UrlType.browser, + KycStepStatus status = KycStepStatus.inProgress, +}) => KycStepSessionDto( + session: KycSessionInfoDto(url: url, type: urlType), + name: name, + status: status, + sequenceNumber: 0, + isCurrent: true, +); + +void main() { + late _MockDfxKycService kycService; + late _MockRealUnitRegistrationService registrationService; + late _MockAppStore appStore; + late _MockAWallet wallet; + + setUp(() { + kycService = _MockDfxKycService(); + registrationService = _MockRealUnitRegistrationService(); + appStore = _MockAppStore(); + wallet = _MockAWallet(); + when(() => appStore.wallet).thenReturn(wallet); + when(() => wallet.walletType).thenReturn(WalletType.software); + when(() => registrationService.getRegistrationInfo()).thenAnswer( + (_) async => RealUnitRegistrationInfoDto( + state: RealUnitRegistrationState.alreadyRegistered, + ), + ); + }); + + // An in-progress `dfxApproval` step used to land on a blank white Scaffold + // (the `(_) => const Scaffold()` fallback in KycViewManager). It must render + // the existing pending page instead. + testWidgets( + 'KycSuccess(dfxApproval) renders KycPendingPage, not a blank Scaffold', + (tester) async { + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => _kycStatus( + level: KycLevel.level50, + processStatus: KycProcessStatus.inProgress, + ), + ); + when(() => kycService.getUser()).thenAnswer((_) async => _user()); + when(() => kycService.continueKyc()).thenAnswer( + (_) async => _session( + level: KycLevel.level50, + steps: const [], + currentStep: _currentStep(KycStepName.dfxApproval), + ), + ); + + final cubit = KycCubit(kycService, registrationService, appStore); + await tester.pumpApp( + BlocProvider.value( + value: cubit, + child: const KycViewManager(), + ), + ); + + cubit.markLegalDisclaimerAccepted(); + await cubit.checkKyc(); + await tester.pumpAndSettle(); + + // Sanity: the cubit really reached the bug-triggering state. + expect(cubit.state, isA()); + expect((cubit.state as KycSuccess).currentStep, KycStep.dfxApproval); + + // Regression assertion: the pending page renders (not a blank screen). + expect(find.byType(KycPendingPage), findsOneWidget); + + await cubit.close(); + }, + ); +} diff --git a/test/screens/legal/legal_document_page_test.dart b/test/screens/legal/legal_document_page_test.dart new file mode 100644 index 00000000..0314e7e7 --- /dev/null +++ b/test/screens/legal/legal_document_page_test.dart @@ -0,0 +1,129 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/legal/subpages/legal_document_page.dart'; +import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; +import 'package:realunit_wallet/styles/language.dart'; + +import '../../helper/helper.dart'; + +void main() { + late MockSettingsBloc settingsBloc; + + setUp(() { + // The page loads its document from rootBundle, which caches results per + // asset path. Clear it between tests so a missing-asset load fails fresh + // each time instead of returning a sibling test's cached future. + rootBundle.clear(); + settingsBloc = MockSettingsBloc(); + when(() => settingsBloc.state).thenReturn(const SettingsState(language: Language.en)); + }); + + Widget host({ + String assetBaseName = 'terms', + String? initialMarkdownContent, + }) => BlocProvider.value( + value: settingsBloc, + child: LegalDocumentPage( + params: LegalDocumentParams( + title: 'Terms', + assetBaseName: assetBaseName, + ), + initialMarkdownContent: initialMarkdownContent, + ), + ); + + group('$LegalDocumentPage', () { + testWidgets( + 'shows an error with a retry action when the document asset fails to load ' + '(regression for #19: silent blank page)', + (tester) async { + // No bundled asset for this base name -> rootBundle.loadString throws. + await tester.pumpApp(host(assetBaseName: 'missing_legal_asset_test')); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('legalDocumentLoadError')), + findsOneWidget, + reason: 'a failed asset load must surface an error state, not a blank page', + ); + expect( + find.byKey(const ValueKey('legalDocumentRetryButton')), + findsOneWidget, + reason: 'the error state must offer a retry affordance', + ); + expect( + find.byIcon(Icons.error_outline), + findsOneWidget, + reason: 'the error state should render the standard error icon', + ); + expect( + find.text(S.current.legalDocumentLoadFailedDescription), + findsOneWidget, + reason: 'the error state should explain what went wrong', + ); + }, + ); + + testWidgets('renders the markdown body when content is provided', (tester) async { + await tester.pumpApp(host(initialMarkdownContent: '# Hello')); + await tester.pumpAndSettle(); + + expect(find.byType(Markdown), findsOneWidget); + expect(find.byKey(const ValueKey('legalDocumentLoadError')), findsNothing); + }); + + testWidgets('retry re-runs the load and stays in the error state while the asset is missing', ( + tester, + ) async { + await tester.pumpApp(host(assetBaseName: 'missing_legal_asset_test')); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('legalDocumentRetryButton'))); + await tester.pumpAndSettle(); + + // The asset is still missing, so we expect the error state to persist + // without throwing — exercising the retry path. + expect(find.byKey(const ValueKey('legalDocumentLoadError')), findsOneWidget); + }); + + testWidgets('retry recovers and renders the document once the asset loads', ( + tester, + ) async { + const baseName = 'recoverable_legal_asset_test'; + const assetKey = 'assets/legal/${baseName}_en.md'; + var failNext = true; + + // Mock the asset channel so the first load fails and, after retry, the + // second load succeeds — exercising the full error -> retry -> loaded path. + final messenger = tester.binding.defaultBinaryMessenger; + messenger.setMockMessageHandler('flutter/assets', (ByteData? message) async { + final key = utf8.decode( + message!.buffer.asUint8List(message.offsetInBytes, message.lengthInBytes), + ); + if (key == assetKey && !failNext) { + return ByteData.sublistView(Uint8List.fromList(utf8.encode('# Recovered'))); + } + return null; // missing asset -> loadString throws + }); + addTearDown(() => messenger.setMockMessageHandler('flutter/assets', null)); + + await tester.pumpApp(host(assetBaseName: baseName)); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('legalDocumentLoadError')), findsOneWidget); + + failNext = false; + await tester.tap(find.byKey(const ValueKey('legalDocumentRetryButton'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('legalDocumentLoadError')), findsNothing); + expect(find.byType(Markdown), findsOneWidget); + }); + }); +}