diff --git a/.github/workflows/profile-sync.yml b/.github/workflows/profile-sync.yml new file mode 100644 index 0000000..e7e3f5e --- /dev/null +++ b/.github/workflows/profile-sync.yml @@ -0,0 +1,79 @@ +name: Approved website profile proposal + +'on': + repository_dispatch: + types: [vr-lab-profile-approved-v1] + pull_request: + branches: [main] + workflow_dispatch: {} + +permissions: {} + +jobs: + synthetic-tests: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.12' + - name: Install pinned receiver dependencies + run: python -m pip install --disable-pip-version-check -r scripts/profile_sync/requirements.txt + - name: Run synthetic and failure-path tests + run: python -B -m unittest discover -s scripts/profile_sync -p 'test_profile_sync.py' -v + - name: Validate disabled Power Automate definition + run: python -B scripts/profile_sync/powerautomate/test_powerautomate_template.py + - name: Validate disabled Microsoft-only manual handoff + run: python -B scripts/profile_sync/powerautomate/test_powerautomate_manual_handoff.py + + propose-profile: + needs: synthetic-tests + if: >- + github.event_name == 'repository_dispatch' && + github.repository == 'maxdiluca/virtualrealitylab' && + github.ref == 'refs/heads/main' && + github.event.sender.login == 'maxdiluca' && + vars.PROFILE_SYNC_ENABLED == 'true' + environment: website-profile-review + runs-on: ubuntu-24.04 + timeout-minutes: 15 + concurrency: + group: website-profile-sync-write + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.12' + - name: Install pinned receiver dependencies + run: python -m pip install --disable-pip-version-check -r scripts/profile_sync/requirements.txt + - name: Install verified official extended Hugo + shell: bash + run: | + set -euo pipefail + install -d "$RUNNER_TEMP/profile-sync-hugo" + curl --fail --location --max-time 60 --silent --show-error --output "$RUNNER_TEMP/profile-sync-hugo.tar.gz" https://github.com/gohugoio/hugo/releases/download/v0.152.2/hugo_extended_0.152.2_linux-amd64.tar.gz + printf '%s %s\n' 416bcfbdf5f68469ec9644dbe507da50fc21b94b69a125b059d64ed2cb4d8c27 "$RUNNER_TEMP/profile-sync-hugo.tar.gz" | sha256sum --check --strict + tar -xzf "$RUNNER_TEMP/profile-sync-hugo.tar.gz" -C "$RUNNER_TEMP/profile-sync-hugo" hugo + printf '%s\n' "$RUNNER_TEMP/profile-sync-hugo" >> "$GITHUB_PATH" + - name: Validate exact approved bytes, build privately, and reconcile draft proposal + env: + PROFILE_SYNC_ENABLED: ${{ vars.PROFILE_SYNC_ENABLED }} + PROFILE_SYNC_REPOSITORY: maxdiluca/virtualrealitylab + PROFILE_SYNC_BASE_BRANCH: main + GITHUB_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + python -B scripts/profile_sync/profile_sync.py --event-json "$GITHUB_EVENT_PATH" --repo "$GITHUB_WORKSPACE" --output "$RUNNER_TEMP/profile-sync-stage-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" --publish diff --git a/config/_default/hugo.yaml b/config/_default/hugo.yaml index 8d305d0..5b19ba5 100644 --- a/config/_default/hugo.yaml +++ b/config/_default/hugo.yaml @@ -62,3 +62,31 @@ security: _merge: deep sitemap: _merge: deep + +# Keep the existing file-backed profiles as the sole rendered owners of these +# author URLs. These verified generated terms retain canonical links. +cascade: +- _target: + kind: term + path: /authors/william-smith + build: + render: link + list: never +- _target: + kind: term + path: /authors/kika-okwubuasi-andrew + build: + render: link + list: never +- _target: + kind: term + path: /authors/youngbin-park + build: + render: link + list: never +- _target: + kind: term + path: /authors/melissa-mira-zulle + build: + render: link + list: never diff --git a/config/_default/module.yaml b/config/_default/module.yaml index 75fc4f6..4eca19d 100644 --- a/config/_default/module.yaml +++ b/config/_default/module.yaml @@ -5,6 +5,16 @@ ############################ imports: + - path: github.com/HugoBlox/hugo-blox-builder/modules/blox-seo + mounts: + - source: assets + target: assets + - source: layouts + target: layouts + excludeFiles: + - _default/_markup/sitemap.xml + - source: layouts/_default/_markup/sitemap.xml + target: layouts/sitemap.xml - path: github.com/HugoBlox/hugo-blox-builder/modules/blox-plugin-decap-cms - path: github.com/HugoBlox/hugo-blox-builder/modules/blox-plugin-netlify - path: github.com/HugoBlox/hugo-blox-builder/modules/blox-bootstrap/v5 \ No newline at end of file diff --git a/content/site_maintenance/_index.md b/content/site_maintenance/_index.md index a34548d..c451dec 100644 --- a/content/site_maintenance/_index.md +++ b/content/site_maintenance/_index.md @@ -5,8 +5,8 @@ type: maintenance slug: private: false -_build: - list: true +build: + list: always --- \ No newline at end of file diff --git a/scripts/profile_sync/README.md b/scripts/profile_sync/README.md new file mode 100644 index 0000000..7074b47 --- /dev/null +++ b/scripts/profile_sync/README.md @@ -0,0 +1,135 @@ +# Approved website profile receiver + +Status: staged implementation, disabled by default. This package is designed for +`maxdiluca/virtualrealitylab`, base branch `main`, and authentic GitHub +`repository_dispatch` events whose `sender.login` is `maxdiluca`. Installing this +code or passing tests does not approve publication, connect Power Automate, or +establish a live service. + +A separate [Microsoft-only manual-handoff option](powerautomate/manual-handoff.md) +keeps GitHub upload as an operator task. It preserves frozen approval snapshots +and stops at `approved-awaiting-manual-validation`; its cloud actions and optional +handoff mail remain disabled. The local recipe validates both downloaded files +and runs the receiver's strict private Hugo preview before any public upload. +Native installation and a separately authorized synthetic pilot are pending. + +Deploy this directory's Python, requirements, tests, fixtures, README and +`powerautomate/` instructions under `scripts/profile_sync/` in the website +repository. Deploy `profile-sync.yml` as `.github/workflows/profile-sync.yml`. +Do not merge or enable without the website owner's exact-version review. + +Use CPython 3.12 and the pinned dependencies: + +```bash +python -m pip install -r scripts/profile_sync/requirements.txt +python -B -m unittest discover -s scripts/profile_sync -p 'test_profile_sync.py' -v +python -B scripts/profile_sync/powerautomate/test_powerautomate_template.py +python -B scripts/profile_sync/powerautomate/test_powerautomate_manual_handoff.py +python -B scripts/profile_sync/profile_sync.py --event-json /private/path/event.json --repo /private/path/website-checkout --output /private/path/new-staging-directory +``` + +Local staging writes two approved files and a hash receipt in a new private +directory; it performs no network calls. Payload Markdown/image content and +person fields are never printed. Do not use real source records in this public +package or its fixtures. The sole fixture is synthetic. + +The event action is `vr-lab-profile-approved-v1`. `client_payload` has exactly +these six keys: + +```json +{ + "schema_version": 1, + "request_id": "opaque-synthetic-reserved-key", + "folder": "Synthetic_Member", + "profile_markdown": "---\n...\n---\n\nApproved biography text...", + "avatar": {"filename": "selected.png", "content_base64": "..."}, + "approval": {"profile_approved": true, "image_approved": true} +} +``` + +Folder names start with an ASCII letter and contain at most 80 ASCII letters, +digits, underscores or hyphens. Request IDs contain at most 100 such characters +and start with a letter or digit. The private sender ledger reserves each +request ID for one immutable approved snapshot; the receiver never treats a +payload assertion as proof of real consent. The configured connection and +protected GitHub environment must establish that provenance. + +The entire compact UTF-8 payload is limited to 60,000 bytes. Markdown is limited +to 16,000 UTF-8 bytes and the decoded image to 32 KiB. Supported front matter is +`title`, `authors`, `superuser`, `role`, `organizations`, `user_groups`, and +optional `interests`/`social`. The author list must equal the exact folder name; +`superuser` must be false and groups must match the site's explicit group list. +The initial generated skeleton is incomplete: a useful biography is required. +This first release accepts prose, lists and emphasis; raw HTML, Hugo shortcodes, +Markdown links/code, YAML aliases/tags and unknown fields require human review. +Public links must be HTTPS and belong in validated organization/social fields. + +Approval covers the exact Markdown and image bytes, including the announced +path changes: `index.md` becomes `_index.md`, and the selected picture becomes +`avatar.png` or `avatar.jpg`. Existing `_index.md` or `index.md` names are +preserved during duplicate comparison. The receiver does not normalize YAML, +remove null placeholders, rotate, crop or reencode images after approval. +Pillow verifies and fully decodes images. PNG accepts only required pixel and +transparency chunks; JPEG accepts basic/progressive image markers, basic JFIF +without embedded thumbnails and fixed Adobe color information. EXIF, XMP, +comments, ICC/unknown metadata, animation, oversized dimensions and trailing +data are withheld. Clean such an image privately and obtain new approval of +the cleaned file before submitting it. + +This release adds new profiles only. Existing author directories must contain +exactly the same two files and bytes for a no-op; case collisions, extra files +or changed content require human review. One deterministic branch, +`codex/profile-sync/`, holds a pending proposal. A retry reads +and verifies that branch's one-commit, two-added-file changes and exact bytes +before creating/reconciling its draft pull request. An edited branch or closed +proposal is a stopping condition; no force-push, overwrite, delete or auto-merge +is implemented. + +Publishing additionally requires `--publish`, `PROFILE_SYNC_ENABLED=true`, the +fixed repository/base environment bindings, an authentic dispatch context and +`GITHUB_TOKEN`. Checkout HEAD must match the event SHA and current remote main. +Before any mutation, the receiver uses Git's tracked-file list to copy only +the website's `assets/`, `config/`, `content/`, `layouts/` and `static/` source, +plus exactly `go.mod`, `go.sum` and `theme.toml`, to private temporary directories. +It filters unrelated tracked names before inspecting or copying their files; +operational outputs, root mail, local settings and generated artifacts stay +outside the snapshot. Symlinks inside the selected source are rejected and the +selected bytes remain limited to 250 MiB. Unknown build dependencies must fail +the real baseline build rather than silently broadening this allowlist. It uses exact +extended Hugo 0.152.2 for baseline and proposed builds with +`--printPathWarnings --panicOnWarning`. The proposed build must add exactly one +author HTML route. Build tools receive a minimal environment without GitHub +tokens; build output is captured privately. No Python approximation of Hugo's +title/slug rules is used. A missing tool, existing warning, route collision, +missing rendered author page or changed base stops the write. + +The workflow installs the official extended Linux Hugo archive only after +checking its fixed SHA-256. It uses immutable GitHub Action commit pins, runs +synthetic tests with read-only permissions, and limits the write job to the +trusted dispatch on main. Setup must create the `website-profile-review` +environment with an independent required reviewer, prevent self-approval, +restrict it to main, retain branch protections, and permit GitHub Actions to +create pull requests. Enable the repository variable only after a private +synthetic full-site build and a controlled draft-PR/read-back exercise. A missing +environment protection cannot be inferred from its name and must be checked. + +This is a cloud workflow invoked by Power Automate, not a local poller. It does +not merge, deploy, change existing profiles, prove institutional authority, +clean source folders, alert people, or verify a live deployed page. GitHub may +require explicit follow-up checks/workflows for PRs created by `GITHUB_TOKEN`; +do not assume a draft PR triggered every existing CI or deploy workflow. +Publication remains a separate owner-reviewed merge followed by a check of the +deployed page and selected image. Never broaden the sender or field allowlists +to work around a withheld submission. + +Full-site validation on 2 October 2026 passed against website base +`f7be0695c2061e249deee020167778fed5e55ca0` plus the three proposed site repairs: +mount the pinned `blox-seo` sitemap at its proper layout path, replace deprecated +maintenance-page build options, and stop four generated taxonomy terms from +rendering over their existing file-backed profiles. The existing canonical +author URLs are retained. The actual receiver's strict baseline and synthetic +proposal builds passed with no warnings; author HTML routes changed from 141 +to 142, adding only `/author/synthetic-member/`. No warning gate was relaxed. +These repairs remain a code proposal until the website owner reviews and merges +the pull request. Local build success does not establish cloud execution, +profile consent or deployment. diff --git a/scripts/profile_sync/fixtures/approved-event.json b/scripts/profile_sync/fixtures/approved-event.json new file mode 100644 index 0000000..e328280 --- /dev/null +++ b/scripts/profile_sync/fixtures/approved-event.json @@ -0,0 +1,16 @@ +{ + "action": "vr-lab-profile-approved-v1", + "sender": {"login": "maxdiluca"}, + "repository": {"full_name": "maxdiluca/virtualrealitylab"}, + "client_payload": { + "schema_version": 1, + "request_id": "synthetic-only-20261002-001", + "folder": "Synthetic_Member", + "profile_markdown": "---\ntitle: Synthetic Member\nauthors: [Synthetic_Member]\nsuperuser: false\nrole: Synthetic Project Student\norganizations:\n - name: University of Birmingham\n url: https://www.birmingham.ac.uk\ninterests: [null]\nsocial:\n - icon: link\n icon_pack: fas\n link: null\nuser_groups: [Project Students]\n---\n\nThis entirely synthetic member explores how people perceive virtual environments. This biography contains no real person data.\n", + "avatar": { + "filename": "synthetic.png", + "content_base64": "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAI0lEQVR4nGOUC6hgIAUwkaSaYVQDcYCJSHVwMKqBGEByKAEA8mgBBjzodkQAAAAASUVORK5CYII=" + }, + "approval": {"profile_approved": true, "image_approved": true} + } +} diff --git a/scripts/profile_sync/powerautomate/README.md b/scripts/profile_sync/powerautomate/README.md new file mode 100644 index 0000000..6c25985 --- /dev/null +++ b/scripts/profile_sync/powerautomate/README.md @@ -0,0 +1,58 @@ +# Power Automate profile handoff template + +Status: disabled draft, locally checked on 2 October 2026. These JSON files are a WDL definition and explicit connector bindings for deployment assembly; they are **not an importable Power Automate ZIP, a solution package, or a cloud-validated flow**. No flow was created, enabled or run by these files. + +A separate [Microsoft-only manual-handoff draft](manual-handoff.md) preserves the approval and frozen-byte gates while preparing a private operator task, with optional handoff mail disabled. It has no GitHub/HTTP action or GitHub reference. Its status requires local receiver validation and a strict private Hugo preview before any public upload. It does not replace the original template or private native candidates and has not been imported or run. + +Use [the manual route's placeholder bindings](bindings.manual-handoff.example.json) with that variant. They omit GitHub dispatch and reference bindings, and include the separate false-by-default handoff notification switch. Bind actual values only in private owner-controlled settings after native schema validation. + +On 2 October 2026, a separate manual shell containing only a fixed Compose action was created in an isolated unmanaged solution and verified Off, with no sharing or runs. Its initial version `0.1.0.0` had no connection references; an official PAC 2.12.2 no-change unpack/pack/unpack roundtrip preserved the complete JSON/XML, solution and workflow identity, component inventory and Off state. The same dedicated solution was then advanced to `0.1.0.1` with three genuine solution connection references for SharePoint, Standard approvals and Outlook, bound through the tenant UI to existing owner-controlled connections. A second no-change PAC roundtrip preserved those three native reference records and their connector metadata along with the complete shell JSON/XML and Off state. The shell still contains only the inert manual trigger and Compose action. The full definition is not installed or cloud-validated. After the owner authorised opening Power Automate's separate GitHub authorisation, the environment's applicable Data Loss Prevention policy blocked connection creation before OAuth consent appeared. No GitHub connection or access grant was created. Administrator review of an approved environment or policy resolution is now required; actual requested GitHub permissions still need review if consent becomes available. The separately installed ChatGPT GitHub connector does not supply this connection. + +Flow ownership does not override DLP restrictions. An administrator must review the exact policy scope and all applicable policies, including permitted connector combinations. Another permissive policy or a different environment alone does not override an applicable block. Keep the draft Off while the supported resolution is assessed; no alternative authentication route has been used. See Microsoft's [policy administration](https://learn.microsoft.com/en-us/power-platform/admin/prevent-data-loss), [policy scope](https://learn.microsoft.com/en-us/power-platform/admin/dlp-policy-scope), and [combined policy effects](https://learn.microsoft.com/en-us/power-platform/admin/dlp-combined-effect-multiple-policies). + +Corrected full-definition candidates were assembled and packed privately with those genuine native logical references and an explicitly empty GitHub binding. Official PAC pack/unpack read-back preserved their complete definitions, native metadata, identities and Off state, and all three external switches remain false. One private candidate carries the source coordinates verified in the authenticated source designer and a proposed approver account; no individual real profile folder is selected, and that proposed account does not establish runtime authorisation. These are local packaging results only. The candidates remain unsuitable for import until the missing GitHub reference and tenant connector schemas are resolved; there has been no full-flow import, activation, run, approval notification or website dispatch. + +Microsoft supports native flow ZIP exports and solution-based code management; raw WDL JSON cannot be imported directly into My flows. Use only the dedicated solution's native export for supported assembly, verify a no-change PAC roundtrip preserves its complete definition and metadata, and resolve connector references privately before importing the full stopped flow. Current Microsoft documentation distinguishes modern YAML flow components from legacy XML packaging; a successful pack command alone does not establish that a cloud flow was preserved or that a tenant will accept its connector bindings. See [native solution export](https://learn.microsoft.com/en-us/power-automate/export-flow-solution), [code management](https://learn.microsoft.com/en-us/power-automate/manage-flows-with-code), [PAC solution commands](https://learn.microsoft.com/en-us/power-platform/developer/cli/reference/solution), and [modern source format](https://learn.microsoft.com/en-us/power-platform/alm/solution-source-control-yaml-format). + +`flow.template.json` sets the proposed flow state to `Stopped`. `EnableCloudActions`, `EnableExternalDispatch`, and `EnableFailureNotifications` default to `false`. The first switch guards all SharePoint reads and approval notifications; the second separately guards GitHub dispatch; the third separately guards the optional fixed failure message to the same named approver. Approval creation itself sends an external notification when cloud actions are enabled. + +`bindings.example.json` lists placeholder parameter values and connection references. Resolve them privately in the authorised tenant; never commit live SharePoint URLs, roots, connection IDs, approver addresses or credentials. The configuration gate fixes the public GitHub target to `maxdiluca/virtualrealitylab`; another target requires separate review and a template change. Bind embedded, owner-controlled connections rather than accepting connections selected by the run-only user. Restrict manual run permission to the designated profile operator. The template JSON uses the classic flow-level `connectionReferences` map and matching `host.connectionName` aliases. Native solution assembly instead maps each alias with `runtimeSource: embedded`, its connector `api.name`, and a `connection.connectionReferenceLogicalName` taken from an actual exported native reference component. An empty draft binding is not a genuine logical reference; never invent its name, ID or component metadata. Verify the tenant designer's current connector input/output shapes before import and activation. + +The manual trigger accepts a case-sensitive single `FolderKey`, optional `AvatarFilename` (default `DEFAULT_AVATAR.png`), and stable `SubmissionKey`. It always reads only `ProfileRoot/FolderKey/index.md` and the named image. It does not enumerate folders or read induction/member lists. Folder and image stem must begin with an ASCII letter and then use ASCII letters, digits, `_` or `-`; folder/stem maximum is 80 characters. Image extensions are lowercase `.png`, `.jpg`, or `.jpeg`. The opaque submission key begins with an ASCII letter or digit and has the same character allowlist, with maximum 100 characters. Do not rename or normalise source folders automatically. + +The extension gate checks the last dot-separated segment against an exact, case-sensitive `contains` array allowlist. WDL `startsWith` and `endsWith` are case-insensitive and cannot enforce lowercase extensions; the offline evaluator uses those documented semantics and rejects uppercase and mixed-case extensions through the actual stored allowlist expression. + +Before running, the operator prepares a private rendered website preview and verifies and privately records the person's separate consent for that exact final preview and the image, completeness of the biography, the website role, and the exact public-only files. The three trigger attestations (`SubjectConsentVerified`, `ImageConsentVerified`, `DispatchIntentReserved`) must be true. These are manual operational gates, not proof of consent collected by software. A generated skeleton or default image alone does not establish readiness. The receiver independently rejects invalid role/front matter, skeletal or incomplete biography, unsafe links/content and invalid image bytes or private metadata. The first receiver release adds new profiles only; a differing existing profile stops as a conflict for human handling. + +The flow freezes both file-content base64 blobs before requesting approval from the sole configured `ProfileApprover`; Markdown must survive a lossless UTF-8 decode/re-encode check. The approval includes the exact Markdown and image attachments, has reassignment disabled, and expires after seven days. It accepts exactly one `Approve` response only when the connector's authenticated `responder.email` matches the configured operator (case-insensitive, whitespace trimmed). It then re-reads both named files; any byte difference stops the handoff. The outgoing payload uses the frozen approved bytes, so a change after the re-read cannot substitute unreviewed content. The approval discloses destination path changes only: `index.md` becomes `content/authors/FolderKey/_index.md`, and the selected image becomes `content/authors/FolderKey/avatar.png` or `avatar.jpg` according to its extension. The receiver preserves file contents exactly; it does not rewrite Markdown or re-encode images. A file needing metadata removal must be prepared again and receive fresh consent/approval. Do not broaden approvals to multiple or forwarded recipients. + +The Power Automate prefilter requires nonempty Markdown, at most 12,000 decoded characters and at most 21,332 base64 characters (a conservative 15,999-byte UTF-8 boundary), and at most 43,688 image base64 characters (a conservative 32,766-byte image boundary). The receiver permits at most 16,000 Markdown bytes and 32 KiB image bytes. The serialized client payload must be at most 60,000 characters and its UTF-8 base64 at most 80,000 characters, conservatively enforcing the 60,000-byte receiver envelope. Oversized images need a separately prepared and reapproved image; this flow never resizes them. + +After approval, `CreateRepositoryDispatchEvent` sends event type `vr-lab-profile-approved-v1` to the configured repository, with exactly these six top-level client-payload keys: + +```json +{ + "schema_version": 1, + "request_id": "synthetic_submission_v1", + "folder": "Staff_SyntheticPerson", + "profile_markdown": "", + "avatar": {"filename": "DEFAULT_AVATAR.png", "content_base64": ""}, + "approval": {"profile_approved": true, "image_approved": true} +} +``` + +The receiver computes the content digest. Power Automate does not generate a new random request ID on each run. Before a run is allowed, reserve `SubmissionKey` in an institutionally approved private durable ledger for the exact folder, exact Markdown/image file versions, and recorded consent/approval purpose, and record dispatch intent. Keep the same key for reconciliation of the same submission. This template does not implement an automatic source-side ledger. Deployment therefore requires a reviewed manual ledger process or an independently implemented atomic reservation component. Never erase or overwrite a ledger intent to force a retry. A failed or timed-out dispatch may have reached GitHub: stop, reconcile the workflow run, branch/PR and exact receiver digest for the reserved key, then decide recovery. A new version needs new consent/approval and a new ledger reservation. + +GET actions permit bounded retry; approval creation, dispatch and optional failure email use `retryPolicy: none`. A failed or timed-out main scope enters a catch scope that emits a fixed minimal error code and, only if explicitly enabled, sends the fixed private operator notice. The notice contains no profile, folder, person, image, payload or connector error text. Source-bearing actions and the trigger mark inputs/outputs secure; deployment must independently verify run-history permissions, retention and tenant DLP policy. Explicit invalid-input, rejected-approval and changed-content gates stop with fixed error codes; they do not send failure email. + +A successful connector response yields `queued-unverified`, not `published`. The source flow has no automatic GitHub PR polling/read-back step in this release. Operator follow-up must verify the receiver's workflow result, exact branch/PR contents and allowed paths before treating PR creation as successful; after a human merge, verify the deployed page and image separately. No automatic merge, source deletion, access change or induction action is included. Rollback stops the flow/dispatch capability, preserves uncertain-write evidence, and handles any pending PR under the website owner's review. + +Run the offline checks from this directory with: + +```bash +python3 -B test_powerautomate_template.py +``` + +These checks inspect actual connector allowlists, dependencies, disabled controls, approval snapshots/identity, payload bounds, retry policy and minimal failure handling. Synthetic hostile strings are evaluated against the stored boundary expressions using a bounded evaluator. They do not verify Power Automate importability, dynamic connector schemas, attachment rendering, approval response authentication, license availability or live execution. Before activation, run a tenant validation with synthetic files and confirm exact approval attachment bytes, private-only delivery, rejection/wrong-responder/timeout paths, changed-file detection, duplicate reservation, unknown dispatch reconciliation and receiver PR read-back. + +Operation names were checked against primary documentation on 2 October 2026: [SharePoint `GetFileContentByPath`](https://learn.microsoft.com/en-us/connectors/sharepointonline/), [Standard approvals `StartAndWaitForAnApproval`](https://learn.microsoft.com/en-us/connectors/approvals/), [approval attachments](https://learn.microsoft.com/en-us/power-automate/approval-attachments), [GitHub `CreateRepositoryDispatchEvent`](https://learn.microsoft.com/en-us/connectors/github/), [Outlook `SendEmailV2`](https://learn.microsoft.com/en-us/connectors/office365/), and [WDL expression functions](https://learn.microsoft.com/en-us/azure/logic-apps/expression-functions-reference). Approval input/output fields are dynamically defined by the connector; tenant validation remains required. diff --git a/scripts/profile_sync/powerautomate/bindings.example.json b/scripts/profile_sync/powerautomate/bindings.example.json new file mode 100644 index 0000000..66a0d4b --- /dev/null +++ b/scripts/profile_sync/powerautomate/bindings.example.json @@ -0,0 +1,35 @@ +{ + "status": "disabled-draft-requires-tenant-binding-and-cloud-validation", + "definitionParameterValues": { + "EnableCloudActions": false, + "EnableExternalDispatch": false, + "EnableFailureNotifications": false, + "ProfileSiteAddress": "__PROFILE_SITE_ADDRESS__", + "ProfileRoot": "/__PROFILE_ROOT__", + "ProfileApprover": "__PROFILE_APPROVER_UPN__", + "GitHubOwner": "__GITHUB_OWNER__", + "GitHubRepository": "__GITHUB_REPOSITORY__" + }, + "connectionReferences": { + "shared_sharepointonline": { + "connectionName": "__SHARED_SHAREPOINTONLINE_CONNECTION_NAME__", + "source": "Embedded", + "id": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline" + }, + "shared_approvals": { + "connectionName": "__SHARED_APPROVALS_CONNECTION_NAME__", + "source": "Embedded", + "id": "/providers/Microsoft.PowerApps/apis/shared_approvals" + }, + "shared_github": { + "connectionName": "__SHARED_GITHUB_CONNECTION_NAME__", + "source": "Embedded", + "id": "/providers/Microsoft.PowerApps/apis/shared_github" + }, + "shared_office365": { + "connectionName": "__SHARED_OFFICE365_CONNECTION_NAME__", + "source": "Embedded", + "id": "/providers/Microsoft.PowerApps/apis/shared_office365" + } + } +} diff --git a/scripts/profile_sync/powerautomate/bindings.manual-handoff.example.json b/scripts/profile_sync/powerautomate/bindings.manual-handoff.example.json new file mode 100644 index 0000000..4df67e1 --- /dev/null +++ b/scripts/profile_sync/powerautomate/bindings.manual-handoff.example.json @@ -0,0 +1,30 @@ +{ + "status": "disabled-manual-handoff-draft-native-schema-and-runtime-approval-pending", + "definitionParameterValues": { + "EnableCloudActions": false, + "EnableFailureNotifications": false, + "ProfileSiteAddress": "__PROFILE_SITE_ADDRESS__", + "ProfileRoot": "/__PROFILE_ROOT__", + "ProfileApprover": "__PROFILE_APPROVER_UPN__", + "GitHubOwner": "__GITHUB_OWNER__", + "GitHubRepository": "__GITHUB_REPOSITORY__", + "EnableHandoffNotifications": false + }, + "connectionReferences": { + "shared_sharepointonline": { + "connectionName": "__SHARED_SHAREPOINTONLINE_CONNECTION_NAME__", + "source": "Embedded", + "id": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline" + }, + "shared_approvals": { + "connectionName": "__SHARED_APPROVALS_CONNECTION_NAME__", + "source": "Embedded", + "id": "/providers/Microsoft.PowerApps/apis/shared_approvals" + }, + "shared_office365": { + "connectionName": "__SHARED_OFFICE365_CONNECTION_NAME__", + "source": "Embedded", + "id": "/providers/Microsoft.PowerApps/apis/shared_office365" + } + } +} diff --git a/scripts/profile_sync/powerautomate/flow.manual-handoff.template.json b/scripts/profile_sync/powerautomate/flow.manual-handoff.template.json new file mode 100644 index 0000000..d4640ba --- /dev/null +++ b/scripts/profile_sync/powerautomate/flow.manual-handoff.template.json @@ -0,0 +1,1031 @@ +{ + "properties": { + "displayName": "VR Lab approved profile manual handoff (disabled draft)", + "state": "Stopped", + "definition": { + "$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "EnableCloudActions": { + "type": "Bool", + "defaultValue": false + }, + "EnableFailureNotifications": { + "type": "Bool", + "defaultValue": false + }, + "ProfileSiteAddress": { + "type": "String", + "defaultValue": "__PROFILE_SITE_ADDRESS__" + }, + "ProfileRoot": { + "type": "String", + "defaultValue": "/__PROFILE_ROOT__" + }, + "ProfileApprover": { + "type": "String", + "defaultValue": "__PROFILE_APPROVER_UPN__" + }, + "GitHubOwner": { + "type": "String", + "defaultValue": "__GITHUB_OWNER__" + }, + "GitHubRepository": { + "type": "String", + "defaultValue": "__GITHUB_REPOSITORY__" + }, + "EnableHandoffNotifications": { + "type": "Bool", + "defaultValue": false + } + }, + "triggers": { + "manual": { + "type": "Request", + "kind": "Button", + "inputs": { + "schema": { + "type": "object", + "properties": { + "FolderKey": { + "type": "string", + "title": "FolderKey (single profile folder, case-sensitive)", + "minLength": 1, + "maxLength": 80 + }, + "AvatarFilename": { + "type": "string", + "title": "AvatarFilename", + "default": "DEFAULT_AVATAR.png" + }, + "SubmissionKey": { + "type": "string", + "title": "SubmissionKey from the private durable ledger", + "minLength": 1, + "maxLength": 100 + }, + "SubjectConsentVerified": { + "type": "boolean", + "title": "Separate recorded subject consent verified", + "default": false + }, + "ImageConsentVerified": { + "type": "boolean", + "title": "Separate recorded image consent verified", + "default": false + }, + "DispatchIntentReserved": { + "type": "boolean", + "title": "Private dispatch intent reserved for this key and exact file versions; uncertain prior writes reconciled", + "default": false + } + }, + "required": [ + "FolderKey", + "SubmissionKey", + "SubjectConsentVerified", + "ImageConsentVerified", + "DispatchIntentReserved" + ], + "additionalProperties": false + } + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + }, + "concurrency": { + "runs": 1, + "maximumWaitingRuns": 10 + } + } + } + }, + "actions": { + "Cloud_actions_enabled": { + "type": "If", + "expression": "@equals(parameters('EnableCloudActions'),true)", + "runAfter": {}, + "actions": { + "Cloud_gate_passed": { + "type": "Compose", + "inputs": "cloud-gate-passed", + "runAfter": {} + } + }, + "else": { + "actions": { + "Disabled": { + "type": "Compose", + "inputs": { + "status": "disabled-no-cloud-actions", + "publication_verified": false + }, + "runAfter": {} + }, + "Stop_disabled": { + "type": "Terminate", + "inputs": { + "runStatus": "Succeeded" + }, + "runAfter": { + "Disabled": [ + "Succeeded" + ] + } + } + } + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Folder_key": { + "type": "Compose", + "inputs": "@string(coalesce(triggerBody()?['FolderKey'],''))", + "runAfter": { + "Cloud_actions_enabled": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Submission_key": { + "type": "Compose", + "inputs": "@string(coalesce(triggerBody()?['SubmissionKey'],''))", + "runAfter": { + "Folder_key": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Avatar_filename": { + "type": "Compose", + "inputs": "@if(empty(triggerBody()?['AvatarFilename']),'DEFAULT_AVATAR.png',string(triggerBody()?['AvatarFilename']))", + "runAfter": { + "Submission_key": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Avatar_stem": { + "type": "Compose", + "inputs": "@first(split(outputs('Avatar_filename'),'.'))", + "runAfter": { + "Avatar_filename": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Require_input_boundaries": { + "type": "If", + "expression": "@and(and(greater(length(outputs('Folder_key')),0),lessOrEquals(length(outputs('Folder_key')),80),equals(uriComponent(outputs('Folder_key')),outputs('Folder_key')),not(contains(outputs('Folder_key'),'.')),not(contains(outputs('Folder_key'),'!')),not(contains(outputs('Folder_key'),'~')),not(contains(outputs('Folder_key'),'*')),not(contains(outputs('Folder_key'),'''')),not(contains(outputs('Folder_key'),'(')),not(contains(outputs('Folder_key'),')')),contains('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',substring(concat(outputs('Folder_key'),'#'),0,1))),and(greater(length(outputs('Submission_key')),0),lessOrEquals(length(outputs('Submission_key')),100),equals(uriComponent(outputs('Submission_key')),outputs('Submission_key')),not(contains(outputs('Submission_key'),'.')),not(contains(outputs('Submission_key'),'!')),not(contains(outputs('Submission_key'),'~')),not(contains(outputs('Submission_key'),'*')),not(contains(outputs('Submission_key'),'''')),not(contains(outputs('Submission_key'),'(')),not(contains(outputs('Submission_key'),')')),contains('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789',substring(concat(outputs('Submission_key'),'#'),0,1))),equals(length(split(outputs('Avatar_filename'),'.')),2),and(greater(length(outputs('Avatar_stem')),0),lessOrEquals(length(outputs('Avatar_stem')),80),equals(uriComponent(outputs('Avatar_stem')),outputs('Avatar_stem')),not(contains(outputs('Avatar_stem'),'.')),not(contains(outputs('Avatar_stem'),'!')),not(contains(outputs('Avatar_stem'),'~')),not(contains(outputs('Avatar_stem'),'*')),not(contains(outputs('Avatar_stem'),'''')),not(contains(outputs('Avatar_stem'),'(')),not(contains(outputs('Avatar_stem'),')')),contains('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',substring(concat(outputs('Avatar_stem'),'#'),0,1))),contains(json('[\"png\",\"jpg\",\"jpeg\"]'),last(split(outputs('Avatar_filename'),'.'))),equals(triggerBody()?['SubjectConsentVerified'],true),equals(triggerBody()?['ImageConsentVerified'],true),equals(triggerBody()?['DispatchIntentReserved'],true))", + "actions": { + "Require_input_boundaries_passed": { + "type": "Compose", + "inputs": "validated", + "runAfter": {} + } + }, + "else": { + "actions": { + "Require_input_boundaries_stopped": { + "type": "Terminate", + "inputs": { + "runStatus": "Failed", + "runError": { + "code": "PROFILE_INPUT_OR_READINESS_INVALID", + "message": "Profile sync stopped. Inspect the private run and reconcile the private submission ledger before any retry." + } + }, + "runAfter": {} + } + } + }, + "runAfter": { + "Avatar_stem": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Require_configuration": { + "type": "If", + "expression": "@and(startsWith(parameters('ProfileSiteAddress'),'https://'),not(contains(parameters('ProfileSiteAddress'),'__')),startsWith(parameters('ProfileRoot'),'/'),not(endsWith(parameters('ProfileRoot'),'/')),not(contains(parameters('ProfileRoot'),'..')),not(contains(parameters('ProfileRoot'),'__')),not(contains(parameters('ProfileRoot'),'%')),not(contains(parameters('ProfileRoot'),'?')),not(contains(parameters('ProfileRoot'),'#')),greater(length(parameters('ProfileApprover')),0),equals(length(split(parameters('ProfileApprover'),'@')),2),not(contains(parameters('ProfileApprover'),'__')),not(contains(parameters('ProfileApprover'),';')),not(contains(parameters('ProfileApprover'),',')),not(contains(parameters('ProfileApprover'),' ')),not(contains(parameters('ProfileApprover'),decodeUriComponent('%0A'))),not(contains(parameters('ProfileApprover'),decodeUriComponent('%0D'))),equals(parameters('GitHubOwner'),'maxdiluca'),equals(parameters('GitHubRepository'),'virtualrealitylab'))", + "actions": { + "Require_configuration_passed": { + "type": "Compose", + "inputs": "validated", + "runAfter": {} + } + }, + "else": { + "actions": { + "Require_configuration_stopped": { + "type": "Terminate", + "inputs": { + "runStatus": "Failed", + "runError": { + "code": "PROFILE_CONFIGURATION_INVALID", + "message": "Profile sync stopped. Inspect the private run and reconcile the private submission ledger before any retry." + } + }, + "runAfter": {} + } + } + }, + "runAfter": { + "Require_input_boundaries": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Profile_sync": { + "type": "Scope", + "actions": { + "Get_markdown": { + "type": "OpenApiConnection", + "inputs": { + "host": { + "connectionName": "shared_sharepointonline", + "apiId": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline", + "operationId": "GetFileContentByPath" + }, + "parameters": { + "dataset": "@parameters('ProfileSiteAddress')", + "path": "@concat(parameters('ProfileRoot'),'/',outputs('Folder_key'),'/index.md')", + "inferContentType": true + }, + "retryPolicy": { + "type": "exponential", + "count": 3, + "interval": "PT5S", + "minimumInterval": "PT5S", + "maximumInterval": "PT30S" + } + }, + "runAfter": {}, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Get_avatar": { + "type": "OpenApiConnection", + "inputs": { + "host": { + "connectionName": "shared_sharepointonline", + "apiId": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline", + "operationId": "GetFileContentByPath" + }, + "parameters": { + "dataset": "@parameters('ProfileSiteAddress')", + "path": "@concat(parameters('ProfileRoot'),'/',outputs('Folder_key'),'/',outputs('Avatar_filename'))", + "inferContentType": true + }, + "retryPolicy": { + "type": "exponential", + "count": 3, + "interval": "PT5S", + "minimumInterval": "PT5S", + "maximumInterval": "PT30S" + } + }, + "runAfter": { + "Get_markdown": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Markdown_snapshot_base64": { + "type": "Compose", + "inputs": "@string(coalesce(body('Get_markdown')?['$content'],''))", + "runAfter": { + "Get_avatar": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Avatar_snapshot_base64": { + "type": "Compose", + "inputs": "@string(coalesce(body('Get_avatar')?['$content'],''))", + "runAfter": { + "Markdown_snapshot_base64": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Markdown_snapshot_text": { + "type": "Compose", + "inputs": "@base64ToString(outputs('Markdown_snapshot_base64'))", + "runAfter": { + "Avatar_snapshot_base64": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Require_snapshot_bounds": { + "type": "If", + "expression": "@and(equals(base64(outputs('Markdown_snapshot_text')),outputs('Markdown_snapshot_base64')),greater(length(trim(outputs('Markdown_snapshot_text'))),0),lessOrEquals(length(outputs('Markdown_snapshot_text')),12000),lessOrEquals(length(outputs('Markdown_snapshot_base64')),21332),greater(length(outputs('Avatar_snapshot_base64')),0),lessOrEquals(length(outputs('Avatar_snapshot_base64')),43688))", + "actions": { + "Require_snapshot_bounds_passed": { + "type": "Compose", + "inputs": "validated", + "runAfter": {} + } + }, + "else": { + "actions": { + "Require_snapshot_bounds_stopped": { + "type": "Terminate", + "inputs": { + "runStatus": "Failed", + "runError": { + "code": "PROFILE_SNAPSHOT_INVALID", + "message": "Profile sync stopped. Inspect the private run and reconcile the private submission ledger before any retry." + } + }, + "runAfter": {} + } + } + }, + "runAfter": { + "Markdown_snapshot_text": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Approval": { + "type": "OpenApiConnectionWebhook", + "inputs": { + "host": { + "connectionName": "shared_approvals", + "apiId": "/providers/Microsoft.PowerApps/apis/shared_approvals", + "operationId": "StartAndWaitForAnApproval" + }, + "parameters": { + "approvalType": "Basic", + "WebhookApprovalCreationInput/title": "VR Lab website: review the exact attached profile", + "WebhookApprovalCreationInput/assignedTo": "@parameters('ProfileApprover')", + "WebhookApprovalCreationInput/details": "Review both exact attachments in full. The receiver will preserve their bytes and place index.md at content/authors/FolderKey/_index.md and the image at content/authors/FolderKey/avatar.png or avatar.jpg (according to its extension); it will not rewrite or re-encode their contents. Approve only after you have verified separate recorded consent from the person for the exact final website preview and public image use; complete biography, valid role and public links; no private induction/access material or image metadata; and an immutable private ledger reservation for the submitted folder, exact file versions and SubmissionKey. This approves a proposed GitHub profile addition. Publication still requires PR review and merge. Reject anything incomplete or changed.", + "WebhookApprovalCreationInput/enableNotifications": true, + "WebhookApprovalCreationInput/enableReassignment": false, + "WebhookApprovalCreationInput/attachments": [ + { + "name": "index.md", + "content": "@outputs('Markdown_snapshot_base64')" + }, + { + "name": "@outputs('Avatar_filename')", + "content": "@outputs('Avatar_snapshot_base64')" + } + ] + }, + "retryPolicy": { + "type": "none" + } + }, + "runAfter": { + "Require_snapshot_bounds": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + }, + "limit": { + "timeout": "P7D" + } + }, + "Require_one_response": { + "type": "If", + "expression": "@equals(length(coalesce(body('Approval')?['responses'],json('[]'))),1)", + "actions": { + "Require_one_response_passed": { + "type": "Compose", + "inputs": "validated", + "runAfter": {} + } + }, + "else": { + "actions": { + "Require_one_response_stopped": { + "type": "Terminate", + "inputs": { + "runStatus": "Failed", + "runError": { + "code": "PROFILE_APPROVAL_RESPONSE_INVALID", + "message": "Profile sync stopped. Inspect the private run and reconcile the private submission ledger before any retry." + } + }, + "runAfter": {} + } + } + }, + "runAfter": { + "Approval": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Require_exact_approval": { + "type": "If", + "expression": "@and(equals(length(coalesce(body('Approval')?['responses'],json('[]'))),1),equals(body('Approval')?['outcome'],'Approve'),equals(first(if(empty(body('Approval')?['responses']),json('[{}]'),coalesce(body('Approval')?['responses'],json('[]'))))?['approverResponse'],'Approve'),equals(toLower(trim(string(coalesce(first(if(empty(body('Approval')?['responses']),json('[{}]'),coalesce(body('Approval')?['responses'],json('[]'))))?['responder']?['email'],'')))),toLower(trim(parameters('ProfileApprover')))))", + "actions": { + "Require_exact_approval_passed": { + "type": "Compose", + "inputs": "validated", + "runAfter": {} + } + }, + "else": { + "actions": { + "Require_exact_approval_stopped": { + "type": "Terminate", + "inputs": { + "runStatus": "Failed", + "runError": { + "code": "PROFILE_APPROVAL_DENIED_OR_WRONG_RESPONDER", + "message": "Profile sync stopped. Inspect the private run and reconcile the private submission ledger before any retry." + } + }, + "runAfter": {} + } + } + }, + "runAfter": { + "Require_one_response": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Reread_markdown": { + "type": "OpenApiConnection", + "inputs": { + "host": { + "connectionName": "shared_sharepointonline", + "apiId": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline", + "operationId": "GetFileContentByPath" + }, + "parameters": { + "dataset": "@parameters('ProfileSiteAddress')", + "path": "@concat(parameters('ProfileRoot'),'/',outputs('Folder_key'),'/index.md')", + "inferContentType": true + }, + "retryPolicy": { + "type": "exponential", + "count": 3, + "interval": "PT5S", + "minimumInterval": "PT5S", + "maximumInterval": "PT30S" + } + }, + "runAfter": { + "Require_exact_approval": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Reread_avatar": { + "type": "OpenApiConnection", + "inputs": { + "host": { + "connectionName": "shared_sharepointonline", + "apiId": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline", + "operationId": "GetFileContentByPath" + }, + "parameters": { + "dataset": "@parameters('ProfileSiteAddress')", + "path": "@concat(parameters('ProfileRoot'),'/',outputs('Folder_key'),'/',outputs('Avatar_filename'))", + "inferContentType": true + }, + "retryPolicy": { + "type": "exponential", + "count": 3, + "interval": "PT5S", + "minimumInterval": "PT5S", + "maximumInterval": "PT30S" + } + }, + "runAfter": { + "Reread_markdown": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Require_unchanged_snapshots": { + "type": "If", + "expression": "@and(equals(string(coalesce(body('Reread_markdown')?['$content'],'')),outputs('Markdown_snapshot_base64')),equals(string(coalesce(body('Reread_avatar')?['$content'],'')),outputs('Avatar_snapshot_base64')))", + "actions": { + "Require_unchanged_snapshots_passed": { + "type": "Compose", + "inputs": "validated", + "runAfter": {} + } + }, + "else": { + "actions": { + "Require_unchanged_snapshots_stopped": { + "type": "Terminate", + "inputs": { + "runStatus": "Failed", + "runError": { + "code": "PROFILE_CHANGED_DURING_APPROVAL", + "message": "Profile sync stopped. Inspect the private run and reconcile the private submission ledger before any retry." + } + }, + "runAfter": {} + } + } + }, + "runAfter": { + "Reread_avatar": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Client_payload": { + "type": "Compose", + "inputs": { + "schema_version": 1, + "request_id": "@outputs('Submission_key')", + "folder": "@outputs('Folder_key')", + "profile_markdown": "@outputs('Markdown_snapshot_text')", + "avatar": { + "filename": "@outputs('Avatar_filename')", + "content_base64": "@outputs('Avatar_snapshot_base64')" + }, + "approval": { + "profile_approved": true, + "image_approved": true + } + }, + "runAfter": { + "Require_unchanged_snapshots": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Serialized_payload": { + "type": "Compose", + "inputs": "@string(outputs('Client_payload'))", + "runAfter": { + "Client_payload": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Require_payload_bounds": { + "type": "If", + "expression": "@and(lessOrEquals(length(outputs('Serialized_payload')),60000),lessOrEquals(length(base64(outputs('Serialized_payload'))),80000))", + "actions": { + "Require_payload_bounds_passed": { + "type": "Compose", + "inputs": "validated", + "runAfter": {} + } + }, + "else": { + "actions": { + "Require_payload_bounds_stopped": { + "type": "Terminate", + "inputs": { + "runStatus": "Failed", + "runError": { + "code": "PROFILE_PAYLOAD_TOO_LARGE", + "message": "Profile sync stopped. Inspect the private run and reconcile the private submission ledger before any retry." + } + }, + "runAfter": {} + } + } + }, + "runAfter": { + "Serialized_payload": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Manual_event": { + "type": "Compose", + "inputs": { + "action": "vr-lab-profile-approved-v1", + "sender": { + "login": "maxdiluca" + }, + "repository": { + "full_name": "maxdiluca/virtualrealitylab" + }, + "client_payload": "@outputs('Client_payload')" + }, + "runAfter": { + "Require_payload_bounds": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Serialized_manual_event": { + "type": "Compose", + "inputs": "@string(outputs('Manual_event'))", + "runAfter": { + "Manual_event": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Optional_handoff_notification": { + "type": "If", + "expression": "@equals(parameters('EnableHandoffNotifications'),true)", + "actions": { + "Send_manual_handoff_to_same_operator": { + "type": "OpenApiConnection", + "inputs": { + "host": { + "connectionName": "shared_office365", + "apiId": "/providers/Microsoft.PowerApps/apis/shared_office365", + "operationId": "SendEmailV2" + }, + "parameters": { + "emailMessage/To": "@parameters('ProfileApprover')", + "emailMessage/Subject": "VR Lab profile: manual validation and draft PR task", + "emailMessage/Body": "Approved snapshots await manual validation. Download all three attachments privately and record this handoff against the existing reserved SubmissionKey. The event.json sender/repository labels are a local compatibility wrapper, not authenticated GitHub provenance or proof of consent. Run the existing local receiver without --publish, compare its staged hashes and exact files with index.md and the avatar, then complete the strict private baseline/proposed Hugo 0.152.2 preview before any public upload. Confirm the separate recorded consent still covers that exact final preview and image. On any invalid content, hidden image metadata, changed bytes, collision or uncertain prior upload, stop and reconcile the private ledger; corrections need new consent, approval and reservation. Only after validation and separate publication authority, manually upload the receiver-staged files to a new draft PR targeting maxdiluca/virtualrealitylab main. Read back the exact two-file set, bytes, branch and draft PR before recording a verified proposal. Review and merge separately; verify the deployed page and image afterwards. No GitHub upload or publication was performed by this flow.", + "emailMessage/Importance": "Normal", + "emailMessage/Attachments": [ + { + "Name": "index.md", + "ContentBytes": "@outputs('Markdown_snapshot_base64')" + }, + { + "Name": "@outputs('Avatar_filename')", + "ContentBytes": "@outputs('Avatar_snapshot_base64')" + }, + { + "Name": "event.json", + "ContentBytes": "@base64(outputs('Serialized_manual_event'))" + } + ] + }, + "retryPolicy": { + "type": "none" + } + }, + "runAfter": {}, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + } + }, + "else": { + "actions": { + "Handoff_notification_disabled": { + "type": "Compose", + "inputs": { + "status": "handoff-notification-disabled" + }, + "runAfter": {} + } + } + }, + "runAfter": { + "Serialized_manual_event": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Manual_checkpoint": { + "type": "Compose", + "inputs": { + "status": "approved-awaiting-manual-validation", + "receiver_validation_verified": false, + "private_preview_verified": false, + "github_write_performed": false, + "publication_verified": false + }, + "runAfter": { + "Optional_handoff_notification": [ + "Succeeded" + ] + } + } + }, + "runAfter": { + "Require_configuration": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Catch_failure": { + "type": "Scope", + "runAfter": { + "Profile_sync": [ + "Failed", + "TimedOut" + ] + }, + "actions": { + "Optional_failure_notification": { + "type": "If", + "expression": "@and(equals(parameters('EnableCloudActions'),true),equals(parameters('EnableFailureNotifications'),true))", + "runAfter": {}, + "actions": { + "Notify_same_named_operator": { + "type": "OpenApiConnection", + "inputs": { + "host": { + "connectionName": "shared_office365", + "apiId": "/providers/Microsoft.PowerApps/apis/shared_office365", + "operationId": "SendEmailV2" + }, + "parameters": { + "emailMessage/To": "@parameters('ProfileApprover')", + "emailMessage/Subject": "VR Lab website profile sync: private operator review required", + "emailMessage/Body": "

PROFILE_SYNC_FAILED_OR_TIMED_OUT

Inspect the private run and reconcile the private submission ledger before any retry. A dispatch failure or timeout may still have queued work. No publication has been verified.

", + "emailMessage/Importance": "Normal" + }, + "retryPolicy": { + "type": "none" + } + }, + "runAfter": {}, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + } + }, + "else": { + "actions": { + "Notification_disabled": { + "type": "Compose", + "inputs": "failure-notification-disabled", + "runAfter": {} + } + } + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Stop_after_failure": { + "type": "Terminate", + "inputs": { + "runStatus": "Failed", + "runError": { + "code": "PROFILE_SYNC_FAILED_OR_TIMED_OUT", + "message": "Profile sync stopped. Inspect the private run and reconcile the private submission ledger before any retry." + } + }, + "runAfter": { + "Optional_failure_notification": [ + "Succeeded", + "Failed", + "TimedOut", + "Skipped" + ] + } + } + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + } + }, + "outputs": {} + }, + "connectionReferences": { + "shared_sharepointonline": { + "connectionName": "__SHARED_SHAREPOINTONLINE_CONNECTION_NAME__", + "source": "Embedded", + "id": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline" + }, + "shared_approvals": { + "connectionName": "__SHARED_APPROVALS_CONNECTION_NAME__", + "source": "Embedded", + "id": "/providers/Microsoft.PowerApps/apis/shared_approvals" + }, + "shared_office365": { + "connectionName": "__SHARED_OFFICE365_CONNECTION_NAME__", + "source": "Embedded", + "id": "/providers/Microsoft.PowerApps/apis/shared_office365" + } + } + } +} diff --git a/scripts/profile_sync/powerautomate/flow.template.json b/scripts/profile_sync/powerautomate/flow.template.json new file mode 100644 index 0000000..ce80af7 --- /dev/null +++ b/scripts/profile_sync/powerautomate/flow.template.json @@ -0,0 +1,977 @@ +{ + "properties": { + "displayName": "VR Lab approved website profile sync (disabled draft)", + "state": "Stopped", + "definition": { + "$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "EnableCloudActions": { + "type": "Bool", + "defaultValue": false + }, + "EnableExternalDispatch": { + "type": "Bool", + "defaultValue": false + }, + "EnableFailureNotifications": { + "type": "Bool", + "defaultValue": false + }, + "ProfileSiteAddress": { + "type": "String", + "defaultValue": "__PROFILE_SITE_ADDRESS__" + }, + "ProfileRoot": { + "type": "String", + "defaultValue": "/__PROFILE_ROOT__" + }, + "ProfileApprover": { + "type": "String", + "defaultValue": "__PROFILE_APPROVER_UPN__" + }, + "GitHubOwner": { + "type": "String", + "defaultValue": "__GITHUB_OWNER__" + }, + "GitHubRepository": { + "type": "String", + "defaultValue": "__GITHUB_REPOSITORY__" + } + }, + "triggers": { + "manual": { + "type": "Request", + "kind": "Button", + "inputs": { + "schema": { + "type": "object", + "properties": { + "FolderKey": { + "type": "string", + "title": "FolderKey (single profile folder, case-sensitive)", + "minLength": 1, + "maxLength": 80 + }, + "AvatarFilename": { + "type": "string", + "title": "AvatarFilename", + "default": "DEFAULT_AVATAR.png" + }, + "SubmissionKey": { + "type": "string", + "title": "SubmissionKey from the private durable ledger", + "minLength": 1, + "maxLength": 100 + }, + "SubjectConsentVerified": { + "type": "boolean", + "title": "Separate recorded subject consent verified", + "default": false + }, + "ImageConsentVerified": { + "type": "boolean", + "title": "Separate recorded image consent verified", + "default": false + }, + "DispatchIntentReserved": { + "type": "boolean", + "title": "Private dispatch intent reserved for this key and exact file versions; uncertain prior writes reconciled", + "default": false + } + }, + "required": [ + "FolderKey", + "SubmissionKey", + "SubjectConsentVerified", + "ImageConsentVerified", + "DispatchIntentReserved" + ], + "additionalProperties": false + } + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + }, + "concurrency": { + "runs": 1, + "maximumWaitingRuns": 10 + } + } + } + }, + "actions": { + "Cloud_actions_enabled": { + "type": "If", + "expression": "@equals(parameters('EnableCloudActions'),true)", + "runAfter": {}, + "actions": { + "Cloud_gate_passed": { + "type": "Compose", + "inputs": "cloud-gate-passed", + "runAfter": {} + } + }, + "else": { + "actions": { + "Disabled": { + "type": "Compose", + "inputs": { + "status": "disabled-no-cloud-actions", + "publication_verified": false + }, + "runAfter": {} + }, + "Stop_disabled": { + "type": "Terminate", + "inputs": { + "runStatus": "Succeeded" + }, + "runAfter": { + "Disabled": [ + "Succeeded" + ] + } + } + } + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Folder_key": { + "type": "Compose", + "inputs": "@string(coalesce(triggerBody()?['FolderKey'],''))", + "runAfter": { + "Cloud_actions_enabled": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Submission_key": { + "type": "Compose", + "inputs": "@string(coalesce(triggerBody()?['SubmissionKey'],''))", + "runAfter": { + "Folder_key": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Avatar_filename": { + "type": "Compose", + "inputs": "@if(empty(triggerBody()?['AvatarFilename']),'DEFAULT_AVATAR.png',string(triggerBody()?['AvatarFilename']))", + "runAfter": { + "Submission_key": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Avatar_stem": { + "type": "Compose", + "inputs": "@first(split(outputs('Avatar_filename'),'.'))", + "runAfter": { + "Avatar_filename": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Require_input_boundaries": { + "type": "If", + "expression": "@and(and(greater(length(outputs('Folder_key')),0),lessOrEquals(length(outputs('Folder_key')),80),equals(uriComponent(outputs('Folder_key')),outputs('Folder_key')),not(contains(outputs('Folder_key'),'.')),not(contains(outputs('Folder_key'),'!')),not(contains(outputs('Folder_key'),'~')),not(contains(outputs('Folder_key'),'*')),not(contains(outputs('Folder_key'),'''')),not(contains(outputs('Folder_key'),'(')),not(contains(outputs('Folder_key'),')')),contains('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',substring(concat(outputs('Folder_key'),'#'),0,1))),and(greater(length(outputs('Submission_key')),0),lessOrEquals(length(outputs('Submission_key')),100),equals(uriComponent(outputs('Submission_key')),outputs('Submission_key')),not(contains(outputs('Submission_key'),'.')),not(contains(outputs('Submission_key'),'!')),not(contains(outputs('Submission_key'),'~')),not(contains(outputs('Submission_key'),'*')),not(contains(outputs('Submission_key'),'''')),not(contains(outputs('Submission_key'),'(')),not(contains(outputs('Submission_key'),')')),contains('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789',substring(concat(outputs('Submission_key'),'#'),0,1))),equals(length(split(outputs('Avatar_filename'),'.')),2),and(greater(length(outputs('Avatar_stem')),0),lessOrEquals(length(outputs('Avatar_stem')),80),equals(uriComponent(outputs('Avatar_stem')),outputs('Avatar_stem')),not(contains(outputs('Avatar_stem'),'.')),not(contains(outputs('Avatar_stem'),'!')),not(contains(outputs('Avatar_stem'),'~')),not(contains(outputs('Avatar_stem'),'*')),not(contains(outputs('Avatar_stem'),'''')),not(contains(outputs('Avatar_stem'),'(')),not(contains(outputs('Avatar_stem'),')')),contains('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',substring(concat(outputs('Avatar_stem'),'#'),0,1))),contains(json('[\"png\",\"jpg\",\"jpeg\"]'),last(split(outputs('Avatar_filename'),'.'))),equals(triggerBody()?['SubjectConsentVerified'],true),equals(triggerBody()?['ImageConsentVerified'],true),equals(triggerBody()?['DispatchIntentReserved'],true))", + "actions": { + "Require_input_boundaries_passed": { + "type": "Compose", + "inputs": "validated", + "runAfter": {} + } + }, + "else": { + "actions": { + "Require_input_boundaries_stopped": { + "type": "Terminate", + "inputs": { + "runStatus": "Failed", + "runError": { + "code": "PROFILE_INPUT_OR_READINESS_INVALID", + "message": "Profile sync stopped. Inspect the private run and reconcile the private submission ledger before any retry." + } + }, + "runAfter": {} + } + } + }, + "runAfter": { + "Avatar_stem": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Require_configuration": { + "type": "If", + "expression": "@and(startsWith(parameters('ProfileSiteAddress'),'https://'),not(contains(parameters('ProfileSiteAddress'),'__')),startsWith(parameters('ProfileRoot'),'/'),not(endsWith(parameters('ProfileRoot'),'/')),not(contains(parameters('ProfileRoot'),'..')),not(contains(parameters('ProfileRoot'),'__')),not(contains(parameters('ProfileRoot'),'%')),not(contains(parameters('ProfileRoot'),'?')),not(contains(parameters('ProfileRoot'),'#')),greater(length(parameters('ProfileApprover')),0),equals(length(split(parameters('ProfileApprover'),'@')),2),not(contains(parameters('ProfileApprover'),'__')),not(contains(parameters('ProfileApprover'),';')),not(contains(parameters('ProfileApprover'),',')),not(contains(parameters('ProfileApprover'),' ')),not(contains(parameters('ProfileApprover'),decodeUriComponent('%0A'))),not(contains(parameters('ProfileApprover'),decodeUriComponent('%0D'))),equals(parameters('GitHubOwner'),'maxdiluca'),equals(parameters('GitHubRepository'),'virtualrealitylab'))", + "actions": { + "Require_configuration_passed": { + "type": "Compose", + "inputs": "validated", + "runAfter": {} + } + }, + "else": { + "actions": { + "Require_configuration_stopped": { + "type": "Terminate", + "inputs": { + "runStatus": "Failed", + "runError": { + "code": "PROFILE_CONFIGURATION_INVALID", + "message": "Profile sync stopped. Inspect the private run and reconcile the private submission ledger before any retry." + } + }, + "runAfter": {} + } + } + }, + "runAfter": { + "Require_input_boundaries": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Profile_sync": { + "type": "Scope", + "actions": { + "Get_markdown": { + "type": "OpenApiConnection", + "inputs": { + "host": { + "connectionName": "shared_sharepointonline", + "apiId": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline", + "operationId": "GetFileContentByPath" + }, + "parameters": { + "dataset": "@parameters('ProfileSiteAddress')", + "path": "@concat(parameters('ProfileRoot'),'/',outputs('Folder_key'),'/index.md')", + "inferContentType": true + }, + "retryPolicy": { + "type": "exponential", + "count": 3, + "interval": "PT5S", + "minimumInterval": "PT5S", + "maximumInterval": "PT30S" + } + }, + "runAfter": {}, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Get_avatar": { + "type": "OpenApiConnection", + "inputs": { + "host": { + "connectionName": "shared_sharepointonline", + "apiId": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline", + "operationId": "GetFileContentByPath" + }, + "parameters": { + "dataset": "@parameters('ProfileSiteAddress')", + "path": "@concat(parameters('ProfileRoot'),'/',outputs('Folder_key'),'/',outputs('Avatar_filename'))", + "inferContentType": true + }, + "retryPolicy": { + "type": "exponential", + "count": 3, + "interval": "PT5S", + "minimumInterval": "PT5S", + "maximumInterval": "PT30S" + } + }, + "runAfter": { + "Get_markdown": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Markdown_snapshot_base64": { + "type": "Compose", + "inputs": "@string(coalesce(body('Get_markdown')?['$content'],''))", + "runAfter": { + "Get_avatar": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Avatar_snapshot_base64": { + "type": "Compose", + "inputs": "@string(coalesce(body('Get_avatar')?['$content'],''))", + "runAfter": { + "Markdown_snapshot_base64": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Markdown_snapshot_text": { + "type": "Compose", + "inputs": "@base64ToString(outputs('Markdown_snapshot_base64'))", + "runAfter": { + "Avatar_snapshot_base64": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Require_snapshot_bounds": { + "type": "If", + "expression": "@and(equals(base64(outputs('Markdown_snapshot_text')),outputs('Markdown_snapshot_base64')),greater(length(trim(outputs('Markdown_snapshot_text'))),0),lessOrEquals(length(outputs('Markdown_snapshot_text')),12000),lessOrEquals(length(outputs('Markdown_snapshot_base64')),21332),greater(length(outputs('Avatar_snapshot_base64')),0),lessOrEquals(length(outputs('Avatar_snapshot_base64')),43688))", + "actions": { + "Require_snapshot_bounds_passed": { + "type": "Compose", + "inputs": "validated", + "runAfter": {} + } + }, + "else": { + "actions": { + "Require_snapshot_bounds_stopped": { + "type": "Terminate", + "inputs": { + "runStatus": "Failed", + "runError": { + "code": "PROFILE_SNAPSHOT_INVALID", + "message": "Profile sync stopped. Inspect the private run and reconcile the private submission ledger before any retry." + } + }, + "runAfter": {} + } + } + }, + "runAfter": { + "Markdown_snapshot_text": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Approval": { + "type": "OpenApiConnectionWebhook", + "inputs": { + "host": { + "connectionName": "shared_approvals", + "apiId": "/providers/Microsoft.PowerApps/apis/shared_approvals", + "operationId": "StartAndWaitForAnApproval" + }, + "parameters": { + "approvalType": "Basic", + "WebhookApprovalCreationInput/title": "VR Lab website: review the exact attached profile", + "WebhookApprovalCreationInput/assignedTo": "@parameters('ProfileApprover')", + "WebhookApprovalCreationInput/details": "Review both exact attachments in full. The receiver will preserve their bytes and place index.md at content/authors/FolderKey/_index.md and the image at content/authors/FolderKey/avatar.png or avatar.jpg (according to its extension); it will not rewrite or re-encode their contents. Approve only after you have verified separate recorded consent from the person for the exact final website preview and public image use; complete biography, valid role and public links; no private induction/access material or image metadata; and an immutable private ledger reservation for the submitted folder, exact file versions and SubmissionKey. This approves a proposed GitHub profile addition. Publication still requires PR review and merge. Reject anything incomplete or changed.", + "WebhookApprovalCreationInput/enableNotifications": true, + "WebhookApprovalCreationInput/enableReassignment": false, + "WebhookApprovalCreationInput/attachments": [ + { + "name": "index.md", + "content": "@outputs('Markdown_snapshot_base64')" + }, + { + "name": "@outputs('Avatar_filename')", + "content": "@outputs('Avatar_snapshot_base64')" + } + ] + }, + "retryPolicy": { + "type": "none" + } + }, + "runAfter": { + "Require_snapshot_bounds": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + }, + "limit": { + "timeout": "P7D" + } + }, + "Require_one_response": { + "type": "If", + "expression": "@equals(length(coalesce(body('Approval')?['responses'],json('[]'))),1)", + "actions": { + "Require_one_response_passed": { + "type": "Compose", + "inputs": "validated", + "runAfter": {} + } + }, + "else": { + "actions": { + "Require_one_response_stopped": { + "type": "Terminate", + "inputs": { + "runStatus": "Failed", + "runError": { + "code": "PROFILE_APPROVAL_RESPONSE_INVALID", + "message": "Profile sync stopped. Inspect the private run and reconcile the private submission ledger before any retry." + } + }, + "runAfter": {} + } + } + }, + "runAfter": { + "Approval": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Require_exact_approval": { + "type": "If", + "expression": "@and(equals(length(coalesce(body('Approval')?['responses'],json('[]'))),1),equals(body('Approval')?['outcome'],'Approve'),equals(first(if(empty(body('Approval')?['responses']),json('[{}]'),coalesce(body('Approval')?['responses'],json('[]'))))?['approverResponse'],'Approve'),equals(toLower(trim(string(coalesce(first(if(empty(body('Approval')?['responses']),json('[{}]'),coalesce(body('Approval')?['responses'],json('[]'))))?['responder']?['email'],'')))),toLower(trim(parameters('ProfileApprover')))))", + "actions": { + "Require_exact_approval_passed": { + "type": "Compose", + "inputs": "validated", + "runAfter": {} + } + }, + "else": { + "actions": { + "Require_exact_approval_stopped": { + "type": "Terminate", + "inputs": { + "runStatus": "Failed", + "runError": { + "code": "PROFILE_APPROVAL_DENIED_OR_WRONG_RESPONDER", + "message": "Profile sync stopped. Inspect the private run and reconcile the private submission ledger before any retry." + } + }, + "runAfter": {} + } + } + }, + "runAfter": { + "Require_one_response": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Reread_markdown": { + "type": "OpenApiConnection", + "inputs": { + "host": { + "connectionName": "shared_sharepointonline", + "apiId": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline", + "operationId": "GetFileContentByPath" + }, + "parameters": { + "dataset": "@parameters('ProfileSiteAddress')", + "path": "@concat(parameters('ProfileRoot'),'/',outputs('Folder_key'),'/index.md')", + "inferContentType": true + }, + "retryPolicy": { + "type": "exponential", + "count": 3, + "interval": "PT5S", + "minimumInterval": "PT5S", + "maximumInterval": "PT30S" + } + }, + "runAfter": { + "Require_exact_approval": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Reread_avatar": { + "type": "OpenApiConnection", + "inputs": { + "host": { + "connectionName": "shared_sharepointonline", + "apiId": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline", + "operationId": "GetFileContentByPath" + }, + "parameters": { + "dataset": "@parameters('ProfileSiteAddress')", + "path": "@concat(parameters('ProfileRoot'),'/',outputs('Folder_key'),'/',outputs('Avatar_filename'))", + "inferContentType": true + }, + "retryPolicy": { + "type": "exponential", + "count": 3, + "interval": "PT5S", + "minimumInterval": "PT5S", + "maximumInterval": "PT30S" + } + }, + "runAfter": { + "Reread_markdown": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Require_unchanged_snapshots": { + "type": "If", + "expression": "@and(equals(string(coalesce(body('Reread_markdown')?['$content'],'')),outputs('Markdown_snapshot_base64')),equals(string(coalesce(body('Reread_avatar')?['$content'],'')),outputs('Avatar_snapshot_base64')))", + "actions": { + "Require_unchanged_snapshots_passed": { + "type": "Compose", + "inputs": "validated", + "runAfter": {} + } + }, + "else": { + "actions": { + "Require_unchanged_snapshots_stopped": { + "type": "Terminate", + "inputs": { + "runStatus": "Failed", + "runError": { + "code": "PROFILE_CHANGED_DURING_APPROVAL", + "message": "Profile sync stopped. Inspect the private run and reconcile the private submission ledger before any retry." + } + }, + "runAfter": {} + } + } + }, + "runAfter": { + "Reread_avatar": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Client_payload": { + "type": "Compose", + "inputs": { + "schema_version": 1, + "request_id": "@outputs('Submission_key')", + "folder": "@outputs('Folder_key')", + "profile_markdown": "@outputs('Markdown_snapshot_text')", + "avatar": { + "filename": "@outputs('Avatar_filename')", + "content_base64": "@outputs('Avatar_snapshot_base64')" + }, + "approval": { + "profile_approved": true, + "image_approved": true + } + }, + "runAfter": { + "Require_unchanged_snapshots": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Serialized_payload": { + "type": "Compose", + "inputs": "@string(outputs('Client_payload'))", + "runAfter": { + "Client_payload": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Require_payload_bounds": { + "type": "If", + "expression": "@and(lessOrEquals(length(outputs('Serialized_payload')),60000),lessOrEquals(length(base64(outputs('Serialized_payload'))),80000))", + "actions": { + "Require_payload_bounds_passed": { + "type": "Compose", + "inputs": "validated", + "runAfter": {} + } + }, + "else": { + "actions": { + "Require_payload_bounds_stopped": { + "type": "Terminate", + "inputs": { + "runStatus": "Failed", + "runError": { + "code": "PROFILE_PAYLOAD_TOO_LARGE", + "message": "Profile sync stopped. Inspect the private run and reconcile the private submission ledger before any retry." + } + }, + "runAfter": {} + } + } + }, + "runAfter": { + "Serialized_payload": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "External_dispatch_enabled": { + "type": "If", + "expression": "@equals(parameters('EnableExternalDispatch'),true)", + "runAfter": { + "Require_payload_bounds": [ + "Succeeded" + ] + }, + "actions": { + "Dispatch_approved_profile": { + "type": "OpenApiConnection", + "inputs": { + "host": { + "connectionName": "shared_github", + "apiId": "/providers/Microsoft.PowerApps/apis/shared_github", + "operationId": "CreateRepositoryDispatchEvent" + }, + "parameters": { + "repositoryOwner": "@parameters('GitHubOwner')", + "repositoryName": "@parameters('GitHubRepository')", + "event_type": "vr-lab-profile-approved-v1", + "client_payload": "@outputs('Client_payload')" + }, + "retryPolicy": { + "type": "none" + } + }, + "runAfter": {}, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Queued_unverified": { + "type": "Compose", + "inputs": { + "status": "queued-unverified", + "publication_verified": false + }, + "runAfter": { + "Dispatch_approved_profile": [ + "Succeeded" + ] + } + } + }, + "else": { + "actions": { + "Dispatch_disabled": { + "type": "Compose", + "inputs": { + "status": "staged-dispatch-disabled", + "publication_verified": false + }, + "runAfter": {} + } + } + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + } + }, + "runAfter": { + "Require_configuration": [ + "Succeeded" + ] + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Catch_failure": { + "type": "Scope", + "runAfter": { + "Profile_sync": [ + "Failed", + "TimedOut" + ] + }, + "actions": { + "Optional_failure_notification": { + "type": "If", + "expression": "@and(equals(parameters('EnableCloudActions'),true),equals(parameters('EnableFailureNotifications'),true))", + "runAfter": {}, + "actions": { + "Notify_same_named_operator": { + "type": "OpenApiConnection", + "inputs": { + "host": { + "connectionName": "shared_office365", + "apiId": "/providers/Microsoft.PowerApps/apis/shared_office365", + "operationId": "SendEmailV2" + }, + "parameters": { + "emailMessage/To": "@parameters('ProfileApprover')", + "emailMessage/Subject": "VR Lab website profile sync: private operator review required", + "emailMessage/Body": "

PROFILE_SYNC_FAILED_OR_TIMED_OUT

Inspect the private run and reconcile the private submission ledger before any retry. A dispatch failure or timeout may still have queued work. No publication has been verified.

", + "emailMessage/Importance": "Normal" + }, + "retryPolicy": { + "type": "none" + } + }, + "runAfter": {}, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + } + }, + "else": { + "actions": { + "Notification_disabled": { + "type": "Compose", + "inputs": "failure-notification-disabled", + "runAfter": {} + } + } + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + }, + "Stop_after_failure": { + "type": "Terminate", + "inputs": { + "runStatus": "Failed", + "runError": { + "code": "PROFILE_SYNC_FAILED_OR_TIMED_OUT", + "message": "Profile sync stopped. Inspect the private run and reconcile the private submission ledger before any retry." + } + }, + "runAfter": { + "Optional_failure_notification": [ + "Succeeded", + "Failed", + "TimedOut", + "Skipped" + ] + } + } + }, + "runtimeConfiguration": { + "secureData": { + "properties": [ + "inputs", + "outputs" + ] + } + } + } + }, + "outputs": {} + }, + "connectionReferences": { + "shared_sharepointonline": { + "connectionName": "__SHARED_SHAREPOINTONLINE_CONNECTION_NAME__", + "source": "Embedded", + "id": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline" + }, + "shared_approvals": { + "connectionName": "__SHARED_APPROVALS_CONNECTION_NAME__", + "source": "Embedded", + "id": "/providers/Microsoft.PowerApps/apis/shared_approvals" + }, + "shared_github": { + "connectionName": "__SHARED_GITHUB_CONNECTION_NAME__", + "source": "Embedded", + "id": "/providers/Microsoft.PowerApps/apis/shared_github" + }, + "shared_office365": { + "connectionName": "__SHARED_OFFICE365_CONNECTION_NAME__", + "source": "Embedded", + "id": "/providers/Microsoft.PowerApps/apis/shared_office365" + } + } + } +} diff --git a/scripts/profile_sync/powerautomate/manual-handoff.md b/scripts/profile_sync/powerautomate/manual-handoff.md new file mode 100644 index 0000000..2d63eaf --- /dev/null +++ b/scripts/profile_sync/powerautomate/manual-handoff.md @@ -0,0 +1,123 @@ +# Microsoft-only profile approval and manual handoff + +Status: separate disabled local draft prepared on 2 October 2026. [flow.manual-handoff.template.json](flow.manual-handoff.template.json) is a WDL template, not an importable ZIP or a cloud-validated flow. It does not replace [flow.template.json](flow.template.json) or the private full-flow candidates. No import, activation, run, approval notification, handoff email or upload has been performed with this variant. + +[bindings.manual-handoff.example.json](bindings.manual-handoff.example.json) contains only placeholder values and the three Microsoft connector aliases, with every switch false. Resolve genuine native references and private parameter values during reviewed assembly; keep live bindings outside tracked files. + +This variant contains only SharePoint, Standard approvals and Outlook connection aliases. It has no GitHub or HTTP action, no GitHub connection reference, and no `EnableExternalDispatch` parameter. The public destination remains fixed to `maxdiluca/virtualrealitylab` by the unchanged configuration gate. Removing a connector does not certify that the remaining connector combination meets tenant policy; the administrator/designer must confirm it. + +The existing manual input, consent attestations, private ledger reservation, selected-file reads, frozen snapshots, sole authenticated approver, seven-day approval timeout, byte/UTF-8 bounds and source re-read equality gates remain unchanged. `DispatchIntentReserved` retains its original schema key for compatibility; for this route it reserves the same immutable submission and proposed manual upload intent. It does not authorize an automatic dispatch. The variant keeps the original fixed, separately controlled failure notice. + +The flow state is `Stopped`. `EnableCloudActions`, `EnableHandoffNotifications` and `EnableFailureNotifications` all default to `false`. Enabling cloud actions permits SharePoint reads and creates an approval notification; this requires its own exact runtime authorization. Handoff notification is an additional optional send to the same configured `ProfileApprover`, with no CC/BCC, forwarding or supplied recipient. It has no automatic retry. A timeout does not establish that a message was not delivered; reconcile the same reserved submission before retrying. + +After every existing approval, unchanged-file and payload-size gate passes, the flow prepares a local receiver-compatible `event.json`. If handoff notification is separately enabled, the fixed operator task includes three attachments: frozen `index.md`, the frozen selected avatar, and `event.json`. Both file attachments and the event use the original frozen bytes; source files are not fetched again for this handoff. Source-bearing actions retain secure inputs/outputs. The final status is **`approved-awaiting-manual-validation`**, with receiver validation, private preview, GitHub write and publication all explicitly unverified. A successful mail action is not proof of receipt, file integrity or validation. + +The event's `sender.login`, repository and approval fields are compatibility labels and assertions. This locally constructed JSON is not an authenticated GitHub event and does not prove consent or publication authority. It is usable only as input to the local receiver's validation/staging path. Do not submit it to an automated publishing path, add credentials, spoof a dispatch environment or pass `--publish`. + +## Operator task after an authorized pilot + +1. Verify the exact subject/profile/image consent, private final preview and immutable ledger reservation before starting an authorized run. A generated skeleton, folder, default image or approval flag is insufficient. Select one folder only. +2. Wait for `approved-awaiting-manual-validation`. Approval alone is insufficient: the post-approval source re-read can still fail. If the optional handoff email is used, verify actual delivery to the configured operator and download all three attachments to an approved private directory. Keep them at mode `0600`, the directory at `0700`, and out of the repository/cloud sync. If mail is disabled, the owner must establish and verify a private transfer of the exact approval attachments and compatible event; there is no automatic downloadable packet provided by that branch. +3. Independently read the current live `main` commit of `maxdiluca/virtualrealitylab` through the authorized repository UI before building. Verify that the website checkout's HEAD matches that exact SHA and that its tracked files are clean. Then validate the event through the existing local receiver using the pinned CPython 3.12 dependencies. Compare the receiver's staged Markdown and image hashes with the downloaded attachments, including the disclosed filename changes to `_index.md` and `avatar.png`/`avatar.jpg`. Stop on malformed biography, unsafe links/front matter, hidden image metadata, conflicting existing profiles, invalid bytes, changed content or ambiguous identity. An exact existing-profile `no-op` requires reconciliation of the existing files and proposal/ledger record; it must not produce another upload or duplicate PR. A correction requires new exact-preview consent, approval and a new reservation. +4. Run the receiver's actual strict baseline/proposed private Hugo preview before any public upload. Ordinary local staging does **not** perform this build. Use approved extended Hugo `0.152.2`; the check requires `--printPathWarnings --panicOnWarning`, preservation of the existing **author HTML routes**, and exactly one new author HTML route. It does not prove every other site route/output is unchanged. Record the tested base SHA and strict build result. Build success does not establish human review of the rendering: inspect the rendered page and image privately and reconcile the person's consent with that exact final rendering. +5. Immediately before public upload, independently verify the repository's live `main` still equals the recorded `tested_base_sha`. If it changed, stop, rebuild against the new clean base and obtain fresh consent/approval for any changed exact rendering before proceeding. Only with separate exact publication authority, manually upload the receiver-staged two files to a new draft PR targeting `maxdiluca/virtualrealitylab` → `main`. Public upload already discloses content. Read back the exact paths, file bytes/hashes, branch/head, base and draft PR before recording a verified proposal in the private ledger. Verify the tested base remains applicable during that read-back; a moved base requires reconciliation and renewed validation. Stop on unexpected files or uncertain earlier uploads; do not erase the reservation to force a retry. +6. Review and merge separately. Verify the deployed page and image afterwards before recording publication. Neither approval, handoff mail, local validation nor a draft PR proves deployment. + +## Local validation and strict preview recipe + +Run from a current authorized website checkout, using the receiver's pinned dependencies and the verified extended Hugo executable. Save all three attachments together in the approved private directory. Replace all three private paths and `__VERIFIED_LIVE_MAIN_SHA__` below with the exact current live `main` SHA independently observed through the authorized repository UI. A cached remote-tracking reference or the event's labels do not establish live freshness. The recipe checks clean tracked files and HEAD equality to the supplied SHA; it cannot verify that the supplied SHA was actually read from live GitHub. This runs existing local receiver functions only; it does not call `publish`, use a token or make GitHub API calls. The preview copies only allowlisted tracked website sources. Logs and generated previews must stay private. The self-reported event labels remain untrusted; the operator verifies provenance against the private approval/ledger record independently. + +```bash +python -B - /private/path/event.json /private/path/website-checkout /private/path/new-stage-directory __VERIFIED_LIVE_MAIN_SHA__ <<'PY' +import json +import os +from pathlib import Path +import re +import runpy +import sys + +code = runpy.run_path("scripts/profile_sync/profile_sync.py") +event_path, repo, output = map(Path, sys.argv[1:4]) +verified_live_main_sha = sys.argv[4] +try: + tested_base_sha = code["fixed_command"](["git", "rev-parse", "--verify", "HEAD"], repo).decode("ascii").strip() + if (not re.fullmatch(r"[0-9a-f]{40}", verified_live_main_sha) + or tested_base_sha != verified_live_main_sha): + code["reject"]("website-base-not-verified-current-main") + if code["fixed_command"](["git", "status", "--porcelain=v1", "--untracked-files=no"], repo).strip(): + code["reject"]("website-tracked-checkout-not-clean") + code["safe_directory"](event_path.parent) + if event_path.is_symlink() or event_path.stat().st_size > 1024 * 1024: + code["reject"]("event-file-invalid") + event = json.loads(event_path.read_text(encoding="utf-8"), + object_pairs_hook=code["strict_json"]) + result = code["validate_event"](event, repo) + if result["status"] == "no-op": + code["reject"]("existing-profile-no-op-reconcile") + for relative, content in result["files"].items(): + name = ("index.md" if relative.endswith(".md") + else event["client_payload"]["avatar"]["filename"]) + if code["checked_file"](event_path.parent / name) != content: + code["reject"]("handoff-attachment-bytes-mismatch") + code["stage"](result, output) + code["check_routes"](result, repo, output) + if (code["fixed_command"](["git", "rev-parse", "--verify", "HEAD"], repo).decode("ascii").strip() != tested_base_sha + or code["fixed_command"](["git", "status", "--porcelain=v1", "--untracked-files=no"], repo).strip()): + code["reject"]("website-checkout-changed-during-preview") + report = {"status": "validated-awaiting-rendered-review-and-publication-authority", + "content_digest": result["content_digest"], + "request_digest": result["request_digest"], + "tested_base_sha": tested_base_sha, + "strict_hugo_build_verified": True, + "rendered_preview_review_verified": False, + "external_write_performed": False} + fd = os.open(output / "manual-preview-receipt.json", os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as receipt: + receipt.write(json.dumps(report, indent=2) + "\n") + print(json.dumps(report, sort_keys=True)) +except code["Rejected"] as error: + print(json.dumps({"status": "withheld", "reason": str(error)})) + sys.exit(2) +except Exception: + print(json.dumps({"status": "withheld", "reason": "input-or-build-invalid"})) + sys.exit(2) +PY +``` + +The two staged files, hash receipt and mode-`0600` `manual-preview-receipt.json` are under the selected new staging directory; the rendered proposal is in its `private-preview-proposal/` subdirectory. The receipt binds the build to `tested_base_sha`, records strict build success and leaves human rendered-preview review false. It does not establish live freshness, consent or upload authority. Independently check live `main` again immediately before upload and during draft-PR read-back; stop and repeat validation if the base changed. Preserve a failed stage for private diagnosis and reconciliation; select a genuinely new output directory for an approved retry. A `no-op` stops for reconciliation without staging a duplicate proposal. + +## Native schema and stopped import checks + +Before packing this variant, inspect only dedicated synthetic actions configured with the existing owner-controlled connections. Capture the native code privately; no helpdesk flow export or real profile records are needed. Read back: + +- Manual-trigger field keys, required flags and Boolean attestations. +- SharePoint host/authentication/binding shape and `GetFileContentByPath` parameters; confirm the runtime binary response provides the expected `$content` bytes during a later authorized synthetic run. +- Standard approvals' actual `Basic` enum, assigned-to field, attachment array/content encoding, notifications/reassignment flags and seven-day timeout. Its input/output fields are dynamic. Confirm `responses`, `approverResponse`, authenticated `responder.email` and `outcome` in native schema and later synthetic runtime behavior. +- Outlook's exact native `emailMessage/Attachments` array and `Name`/`ContentBytes` shapes, with all three attachments. Confirm fixed recipient, no CC/BCC and retry `none`. Documentation alone does not prove that the tenant accepts this WDL shape or preserves attachment bytes. +- Three genuine native connection-reference logical names and connector metadata. Do not fabricate native references or substitute run-only-user connections. + +Use only the dedicated native solution wrapper, preserve identity/Off metadata, and use official PAC pack/unpack with complete JSON/XML read-back. Use the separate manual-handoff bindings example, rather than the full-flow example that includes GitHub; replace placeholders privately with verified owner-controlled Microsoft bindings. Before an authorized import, verify the existing target flow is Off and clear **Enable Plugin steps and flows included in the solution** where offered. After import, inspect the exact action/reference inventory, private parameter bindings, all false switches, secure settings, native designer/Flow checker, Off state and absence of runs. Privately re-export and compare the saved definition. An Off import/checker success is not runtime validation; a later explicitly authorized synthetic pilot must verify downloaded attachment bytes, private-only delivery, rejection/wrong responder/multiple response/timeout/changed-file handling and manual receiver/preview results. + +Microsoft documents [native solution import settings](https://learn.microsoft.com/en-us/power-apps/maker/data-platform/import-update-export-solutions) and [flow state on import](https://learn.microsoft.com/en-us/power-automate/import-flow-solution). Approval attachments need correct binary encoding; verify actual rendering/download behavior. See [approval attachments](https://learn.microsoft.com/en-us/power-automate/approval-attachments). Outlook documents multiple-attachment corruption and possible duplicate sends after timeout/retry; the variant disables automatic write retries and requires byte/delivery read-back. See [Outlook connector](https://learn.microsoft.com/en-us/connectors/office365/). + +## Local evidence + +Run these checks using the receiver's pinned environment: + +```bash +python -B automations/website-profile-sync/powerautomate/test_powerautomate_manual_handoff.py -v +python -B automations/website-profile-sync/powerautomate/test_powerautomate_template.py -v +``` + +Those commands use the A_LAB preparation layout. In the exported website layout, the receiver and `fixtures/` sit directly under `scripts/profile_sync/`; run: + +```bash +python -B scripts/profile_sync/powerautomate/test_powerautomate_manual_handoff.py -v +python -B scripts/profile_sync/powerautomate/test_powerautomate_template.py -v +``` + +The manual-handoff test selects exactly one of those two explicit receiver/fixture layouts and rejects missing or ambiguous layouts. + +Nine new checks pass on 2 October 2026. They exercise disabled controls, capability/recipient/retry/status mutations, preserved original gates, consent and ledger failures, denied/missing/multiple/wrong-responder approvals, changed source bytes, byte budgets, and invalid content/image rejection through the actual receiver. The synthetic packet test materializes the stored expressions, decodes all three attachments, validates the event through the actual receiver and verifies the staged bytes match. The original nine definition checks also pass. These checks do not establish cloud importability, DLP compliance, attachment transport, delivery, authenticated runtime approval or actual Hugo rendering in the tenant pilot. No real profile has been used. + +The revised guide recipe also passed Python syntax checking and nine separate synthetic control scenarios: successful bound receipt, malformed/stale base SHA, dirty tracked checkout, attachment mismatch, existing-profile no-op, changed HEAD/dirty checkout during preview, and build failure. The build function was mocked for those recipe control checks; they establish gate and receipt behavior, not an actual Hugo build or human rendering review. Both A_LAB and exported website test layouts were rerun successfully after the guide correction. diff --git a/scripts/profile_sync/powerautomate/test_powerautomate_manual_handoff.py b/scripts/profile_sync/powerautomate/test_powerautomate_manual_handoff.py new file mode 100644 index 0000000..8e34df5 --- /dev/null +++ b/scripts/profile_sync/powerautomate/test_powerautomate_manual_handoff.py @@ -0,0 +1,253 @@ +"""Synthetic manual-handoff checks; no tenant, email, source records or network.""" + +import base64 +from copy import deepcopy +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest + +from test_powerautomate_template import Expression, all_actions + + +HERE = Path(__file__).resolve().parent +ORIGINAL = json.loads((HERE / "flow.template.json").read_text()) +TEMPLATE = json.loads((HERE / "flow.manual-handoff.template.json").read_text()) +DEFINITION = TEMPLATE["properties"]["definition"] +ROOT = DEFINITION["actions"] +WORK = ROOT["Profile_sync"]["actions"] +SECURE = {"secureData": {"properties": ["inputs", "outputs"]}} +LOCAL_RECEIVER = HERE.parent / "github" +EXPORTED_RECEIVER = HERE.parent +layouts = [directory for directory in (LOCAL_RECEIVER, EXPORTED_RECEIVER) + if (directory / "profile_sync.py").is_file() + and (directory / "fixtures/approved-event.json").is_file()] +if len(layouts) != 1: + raise RuntimeError("manual-handoff receiver layout missing or ambiguous") +RECEIVER = layouts[0] +FIXTURE = json.loads((RECEIVER / "fixtures/approved-event.json").read_text()) +spec = importlib.util.spec_from_file_location("manual_handoff_receiver", RECEIVER / "profile_sync.py") +receiver = importlib.util.module_from_spec(spec) +spec.loader.exec_module(receiver) + + +def resolve(value, outputs, parameters=None): + """Materialize only expression values in the stored synthetic packet.""" + if isinstance(value, str) and value.startswith("@"): + return Expression(value, outputs=outputs, parameters=parameters).evaluate() + if isinstance(value, dict): + return {key: resolve(item, outputs, parameters) for key, item in value.items()} + if isinstance(value, list): + return [resolve(item, outputs, parameters) for item in value] + return value + + +def assert_contract(template): + """Fail closed on capability, delivery, dependency or status broadening.""" + p = template["properties"] + d = p["definition"] + work = d["actions"]["Profile_sync"]["actions"] + assert p["state"] == "Stopped" + assert "EnableExternalDispatch" not in d["parameters"] + for key in ("EnableCloudActions", "EnableHandoffNotifications", "EnableFailureNotifications"): + assert d["parameters"][key]["defaultValue"] is False + assert set(p["connectionReferences"]) == {"shared_sharepointonline", "shared_approvals", "shared_office365"} + names = [name for name, _ in all_actions(d["actions"])] + assert len(names) == len(set(names)) + allowed = { + ("shared_sharepointonline", "GetFileContentByPath"), + ("shared_approvals", "StartAndWaitForAnApproval"), + ("shared_office365", "SendEmailV2"), + } + for _, action in all_actions(d["actions"]): + assert action["type"] in {"Compose", "If", "Scope", "Terminate", "OpenApiConnection", "OpenApiConnectionWebhook"} + if action["type"].startswith("OpenApiConnection"): + host = action["inputs"]["host"] + assert (host["connectionName"], host["operationId"]) in allowed + assert host["apiId"] == "/providers/Microsoft.PowerApps/apis/" + host["connectionName"] + assert action["runtimeConfiguration"] == SECURE + if host["operationId"] != "GetFileContentByPath": + assert action["inputs"]["retryPolicy"] == {"type": "none"} + assert work["Manual_event"]["runAfter"] == {"Require_payload_bounds": ["Succeeded"]} + assert work["Manual_event"]["inputs"] == { + "action": "vr-lab-profile-approved-v1", "sender": {"login": "maxdiluca"}, + "repository": {"full_name": "maxdiluca/virtualrealitylab"}, + "client_payload": "@outputs('Client_payload')", + } + assert work["Serialized_manual_event"]["runAfter"] == {"Manual_event": ["Succeeded"]} + assert work["Serialized_manual_event"]["inputs"] == "@string(outputs('Manual_event'))" + optional = work["Optional_handoff_notification"] + assert optional["runAfter"] == {"Serialized_manual_event": ["Succeeded"]} + assert optional["expression"] == "@equals(parameters('EnableHandoffNotifications'),true)" + mail = optional["actions"]["Send_manual_handoff_to_same_operator"] + params = mail["inputs"]["parameters"] + assert set(params) == {"emailMessage/To", "emailMessage/Subject", "emailMessage/Body", "emailMessage/Importance", "emailMessage/Attachments"} + assert params["emailMessage/To"] == "@parameters('ProfileApprover')" + assert "@" not in params["emailMessage/Subject"] + params["emailMessage/Body"] + assert params["emailMessage/Attachments"] == [ + {"Name": "index.md", "ContentBytes": "@outputs('Markdown_snapshot_base64')"}, + {"Name": "@outputs('Avatar_filename')", "ContentBytes": "@outputs('Avatar_snapshot_base64')"}, + {"Name": "event.json", "ContentBytes": "@base64(outputs('Serialized_manual_event'))"}, + ] + for name in ("Manual_event", "Serialized_manual_event", "Optional_handoff_notification"): + assert work[name]["runtimeConfiguration"] == SECURE + assert work["Manual_checkpoint"]["runAfter"] == {"Optional_handoff_notification": ["Succeeded"]} + assert work["Manual_checkpoint"]["inputs"] == { + "status": "approved-awaiting-manual-validation", "receiver_validation_verified": False, + "private_preview_verified": False, "github_write_performed": False, "publication_verified": False, + } + + +class ManualHandoffTests(unittest.TestCase): + def packet(self): + payload = FIXTURE["client_payload"] + outputs = { + "Folder_key": payload["folder"], "Submission_key": payload["request_id"], + "Avatar_filename": payload["avatar"]["filename"], + "Markdown_snapshot_text": payload["profile_markdown"], + "Markdown_snapshot_base64": base64.b64encode(payload["profile_markdown"].encode()).decode(), + "Avatar_snapshot_base64": payload["avatar"]["content_base64"], + } + outputs["Client_payload"] = resolve(WORK["Client_payload"]["inputs"], outputs) + outputs["Manual_event"] = resolve(WORK["Manual_event"]["inputs"], outputs) + outputs["Serialized_manual_event"] = resolve(WORK["Serialized_manual_event"]["inputs"], outputs) + return outputs + + def test_disabled_microsoft_only_contract(self): + assert_contract(TEMPLATE) + self.assertNotIn("shared_github", json.dumps(TEMPLATE)) + self.assertNotIn("CreateRepositoryDispatchEvent", json.dumps(TEMPLATE)) + for key in ("EnableCloudActions", "EnableHandoffNotifications", "EnableFailureNotifications"): + self.assertFalse(Expression("@equals(parameters('" + key + "'),true)", + parameters={key: DEFINITION["parameters"][key]["defaultValue"]}).evaluate()) + + def test_every_existing_source_consent_approval_ledger_and_size_gate_preserved(self): + original = ORIGINAL["properties"]["definition"] + self.assertEqual(DEFINITION["triggers"], original["triggers"]) + for name, action in original["actions"].items(): + if name != "Profile_sync": + self.assertEqual(ROOT[name], action, name) + for name, action in original["actions"]["Profile_sync"]["actions"].items(): + if name != "External_dispatch_enabled": + self.assertEqual(WORK[name], action, name) + for key, value in original["parameters"].items(): + if key != "EnableExternalDispatch": + self.assertEqual(DEFINITION["parameters"][key], value) + + def test_capability_delivery_and_success_claim_mutations_are_rejected(self): + variants = [] + v = deepcopy(TEMPLATE) + v["properties"]["definition"]["parameters"]["EnableHandoffNotifications"]["defaultValue"] = True + variants.append(v) + v = deepcopy(TEMPLATE) + v["properties"]["definition"]["actions"]["Profile_sync"]["actions"]["Manual_event"]["runAfter"] = {"Get_markdown": ["Succeeded"]} + variants.append(v) + for mutation in ("recipient", "attachment", "retry", "cc", "status", "http"): + v = deepcopy(TEMPLATE) + work = v["properties"]["definition"]["actions"]["Profile_sync"]["actions"] + mail = work["Optional_handoff_notification"]["actions"]["Send_manual_handoff_to_same_operator"] + if mutation == "recipient": + mail["inputs"]["parameters"]["emailMessage/To"] = "@triggerBody()?['Recipient']" + elif mutation == "attachment": + mail["inputs"]["parameters"]["emailMessage/Attachments"][0]["ContentBytes"] = "@body('Reread_markdown')?['$content']" + elif mutation == "retry": + mail["inputs"]["retryPolicy"] = {"type": "exponential", "count": 3} + elif mutation == "cc": + mail["inputs"]["parameters"]["emailMessage/Cc"] = "other@example.invalid" + elif mutation == "status": + work["Manual_checkpoint"]["inputs"]["publication_verified"] = True + else: + work["Unexpected_http"] = {"type": "Http", "inputs": {"uri": "https://example.invalid"}, "runAfter": {}} + variants.append(v) + for index, variant in enumerate(variants): + with self.subTest(mutation=index), self.assertRaises(AssertionError): + assert_contract(variant) + + def test_manual_packet_matches_existing_local_receiver_and_exact_attachment_bytes(self): + outputs = self.packet() + mail = WORK["Optional_handoff_notification"]["actions"]["Send_manual_handoff_to_same_operator"] + attachments = resolve(mail["inputs"]["parameters"]["emailMessage/Attachments"], outputs) + downloaded = {item["Name"]: base64.b64decode(item["ContentBytes"], validate=True) for item in attachments} + event = json.loads(downloaded["event.json"]) + self.assertEqual(event, FIXTURE) + with tempfile.TemporaryDirectory() as name: + root = Path(name).resolve() + repo = root / "website" + (repo / "content/authors").mkdir(parents=True) + result = receiver.validate_event(event, repo) + self.assertEqual(result["files"], { + "content/authors/Synthetic_Member/_index.md": downloaded["index.md"], + "content/authors/Synthetic_Member/avatar.png": downloaded["synthetic.png"], + }) + stage = root / "stage" + receiver.stage(result, stage) + for relative, content in result["files"].items(): + self.assertEqual((stage / relative).read_bytes(), content) + self.assertFalse(json.loads((stage / "receipt.json").read_text())["external_write_performed"]) + + def test_receiver_still_rejects_incomplete_content_and_changed_image_bytes(self): + with tempfile.TemporaryDirectory() as name: + repo = Path(name).resolve() + (repo / "content/authors").mkdir(parents=True) + event = self.packet()["Manual_event"] + event["client_payload"]["profile_markdown"] = event["client_payload"]["profile_markdown"].split("---\n\n")[0] + "---\n\n" + with self.assertRaises(receiver.Rejected): + receiver.validate_event(event, repo) + event = self.packet()["Manual_event"] + original = base64.b64decode(event["client_payload"]["avatar"]["content_base64"]) + event["client_payload"]["avatar"]["content_base64"] = base64.b64encode(original + b"unapproved-trailing-data").decode() + with self.assertRaises(receiver.Rejected): + receiver.validate_event(event, repo) + self.assertFalse(WORK["Manual_checkpoint"]["inputs"]["receiver_validation_verified"]) + self.assertFalse(WORK["Manual_checkpoint"]["inputs"]["private_preview_verified"]) + + def test_missing_consent_or_ledger_reservation_stops(self): + outputs = self.packet() + outputs["Avatar_stem"] = outputs["Avatar_filename"].split(".")[0] + attestations = {"SubjectConsentVerified": True, "ImageConsentVerified": True, "DispatchIntentReserved": True} + gate = ROOT["Require_input_boundaries"]["expression"] + self.assertTrue(Expression(gate, outputs=outputs, trigger=attestations).evaluate()) + for key in attestations: + for bad in (False, None, "true"): + self.assertFalse(Expression(gate, outputs=outputs, trigger={**attestations, key: bad}).evaluate()) + for bad in ("../secret", "folder/other", "0invalid", "A" * 81): + self.assertFalse(Expression(gate, outputs={**outputs, "Folder_key": bad}, trigger=attestations).evaluate()) + + def test_denied_wrong_missing_or_multiple_approval_stops(self): + approved = {"outcome": "Approve", "responses": [{"approverResponse": "Approve", "responder": {"email": "operator@example.invalid"}}]} + config = {"ProfileApprover": "operator@example.invalid"} + gate = WORK["Require_exact_approval"]["expression"] + self.assertTrue(Expression(gate, parameters=config, bodies={"Approval": approved}).evaluate()) + cases = [{}, {"outcome": "Approve"}, {"outcome": "Approve", "responses": []}, + {"outcome": "Approve", "responses": None}, {"outcome": "Reject", "responses": approved["responses"]}, + {"outcome": "Approve", "responses": approved["responses"] * 2}, + {"outcome": "Approve", "responses": [{"approverResponse": "Approve", "responder": {"email": "other@example.invalid"}}]}] + for case in cases: + self.assertFalse(Expression(gate, parameters=config, bodies={"Approval": case}).evaluate()) + + def test_changed_or_missing_reread_never_releases_handoff(self): + outputs = self.packet() + bodies = {"Reread_markdown": {"$content": outputs["Markdown_snapshot_base64"]}, + "Reread_avatar": {"$content": outputs["Avatar_snapshot_base64"]}} + gate = WORK["Require_unchanged_snapshots"]["expression"] + self.assertTrue(Expression(gate, outputs=outputs, bodies=bodies).evaluate()) + for key in bodies: + for content in ("Y2hhbmdlZA==", "", None): + self.assertFalse(Expression(gate, outputs=outputs, bodies={**bodies, key: {"$content": content}}).evaluate()) + self.assertEqual(WORK["Client_payload"]["runAfter"], {"Require_unchanged_snapshots": ["Succeeded"]}) + + def test_payload_byte_limit_and_separate_failure_notification(self): + gate = WORK["Require_payload_bounds"]["expression"] + for text, expected in (("x" * 60000, True), ("x" * 60001, False), ("é" * 30000, True), ("é" * 30001, False)): + self.assertEqual(Expression(gate, outputs={"Serialized_payload": text}).evaluate(), expected) + failure = ROOT["Catch_failure"]["actions"]["Optional_failure_notification"] + self.assertIn("EnableFailureNotifications", failure["expression"]) + self.assertNotIn("EnableHandoffNotifications", failure["expression"]) + instructions = WORK["Optional_handoff_notification"]["actions"]["Send_manual_handoff_to_same_operator"]["inputs"]["parameters"]["emailMessage/Body"] + for requirement in ("without --publish", "strict private", "before any public upload", "draft PR", "Read back", "not authenticated GitHub provenance"): + self.assertIn(requirement, instructions) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/profile_sync/powerautomate/test_powerautomate_template.py b/scripts/profile_sync/powerautomate/test_powerautomate_template.py new file mode 100644 index 0000000..4fb95a6 --- /dev/null +++ b/scripts/profile_sync/powerautomate/test_powerautomate_template.py @@ -0,0 +1,310 @@ +"""Offline structural and synthetic boundary checks; not a Power Automate validator.""" + +import base64 +import json +import re +import unittest +from pathlib import Path +from urllib.parse import quote, unquote + + +HERE = Path(__file__).resolve().parent +TEMPLATE = json.loads((HERE / "flow.template.json").read_text()) +DEFINITION = TEMPLATE["properties"]["definition"] +ROOT = DEFINITION["actions"] +WORK = ROOT["Profile_sync"]["actions"] + + +class Expression: + """Evaluate the small documented WDL subset used by boundary conditions. + + This deliberately does not run actions, emulate connectors or claim platform + equivalence. It evaluates the expressions stored in the actual template so + changing a security condition without updating a duplicate Python predicate + is detectable using hostile synthetic fixtures. + """ + + def __init__(self, expression, outputs=None, trigger=None, parameters=None, + bodies=None): + self.source = expression.removeprefix("@") + self.pos = 0 + self.outputs = outputs or {} + self.trigger = trigger or {} + self.parameters = parameters or {} + self.bodies = bodies or {} + + def evaluate(self): + result = self.value() + self.space() + if self.pos != len(self.source): + raise AssertionError("unparsed WDL expression") + return result + + def space(self): + while self.pos < len(self.source) and self.source[self.pos].isspace(): + self.pos += 1 + + def value(self): + self.space() + if self.source[self.pos] == "'": + self.pos += 1 + chars = [] + while self.pos < len(self.source): + char = self.source[self.pos] + self.pos += 1 + if char == "'": + if self.pos < len(self.source) and self.source[self.pos] == "'": + chars.append("'") + self.pos += 1 + else: + break + else: + chars.append(char) + result = "".join(chars) + else: + token = re.match(r"[A-Za-z_][A-Za-z0-9_]*|[0-9]+", self.source[self.pos:]) + if not token: + raise AssertionError("unsupported WDL token") + name = token.group(0) + self.pos += len(name) + self.space() + if name.isdigit(): + result = int(name) + elif name in ("true", "false", "null"): + result = {"true": True, "false": False, "null": None}[name] + else: + assert self.source[self.pos] == "(" + self.pos += 1 + args = [] + self.space() + while self.source[self.pos] != ")": + args.append(self.value()) + self.space() + if self.source[self.pos] != ",": + break + self.pos += 1 + assert self.source[self.pos] == ")" + self.pos += 1 + result = self.call(name, args) + self.space() + while self.source[self.pos:self.pos + 2] == "?[": + self.pos += 2 + key = self.value() + assert self.source[self.pos] == "]" + self.pos += 1 + result = result.get(key) if isinstance(result, dict) else None + self.space() + return result + + def call(self, name, args): + functions = { + "and": lambda *a: all(a), "or": lambda *a: any(a), + "not": lambda a: not a, "equals": lambda a, b: a == b, + "greater": lambda a, b: a > b, + "lessOrEquals": lambda a, b: a <= b, + "contains": lambda a, b: b in a, "length": len, + "uriComponent": lambda a: quote(a, safe="-_.!~*'()"), + "substring": lambda a, start, size: a[start:start + size], + "concat": lambda *a: "".join(a), "split": lambda a, b: a.split(b), + "first": lambda a: a[0], "last": lambda a: a[-1], + "toLower": str.lower, "trim": str.strip, + "startsWith": lambda a, b: a.lower().startswith(b.lower()), + "endsWith": lambda a, b: a.lower().endswith(b.lower()), + "coalesce": lambda *a: next((v for v in a if v is not None), None), + "string": lambda a: a if isinstance(a, str) else json.dumps(a, separators=(",", ":"), ensure_ascii=False), + "base64": lambda a: base64.b64encode(a.encode("utf-8")).decode(), + "base64ToString": lambda a: base64.b64decode(a).decode("utf-8"), + "json": json.loads, "decodeUriComponent": unquote, + "empty": lambda a: a is None or len(a) == 0, + "if": lambda condition, yes, no: yes if condition else no, + "outputs": lambda a: self.outputs[a], + "triggerBody": lambda: self.trigger, + "parameters": lambda a: self.parameters[a], + "body": lambda a: self.bodies[a], + } + if name not in functions: + raise AssertionError(f"unsupported WDL function: {name}") + return functions[name](*args) + + +def all_actions(actions): + for name, action in actions.items(): + yield name, action + yield from all_actions(action.get("actions", {})) + yield from all_actions(action.get("else", {}).get("actions", {})) + + +class TemplateTests(unittest.TestCase): + def boundaries(self, folder="Staff_SyntheticPerson", submission="synthetic_v1", + avatar="DEFAULT_AVATAR.png", **attestations): + outputs = {"Folder_key": folder, "Submission_key": submission, + "Avatar_filename": avatar, "Avatar_stem": avatar.split(".")[0]} + trigger = {"SubjectConsentVerified": True, "ImageConsentVerified": True, + "DispatchIntentReserved": True, **attestations} + return Expression(ROOT["Require_input_boundaries"]["expression"], + outputs=outputs, trigger=trigger).evaluate() + + def test_disabled_by_default_and_before_every_cloud_action(self): + self.assertEqual(TEMPLATE["properties"]["state"], "Stopped") + for key in ("EnableCloudActions", "EnableExternalDispatch", "EnableFailureNotifications"): + self.assertIs(DEFINITION["parameters"][key]["defaultValue"], False) + self.assertEqual(ROOT["Folder_key"]["runAfter"], {"Cloud_actions_enabled": ["Succeeded"]}) + self.assertEqual(ROOT["Profile_sync"]["runAfter"], {"Require_configuration": ["Succeeded"]}) + self.assertEqual(ROOT["Cloud_actions_enabled"]["else"]["actions"]["Stop_disabled"]["inputs"], {"runStatus": "Succeeded"}) + self.assertEqual(DEFINITION["triggers"]["manual"]["kind"], "Button") + names = [name for name, _ in all_actions(ROOT)] + self.assertEqual(len(names), len(set(names)), "Power Automate action names must be unambiguous") + + def test_hostile_folder_filename_and_submission_boundaries(self): + self.assertTrue(self.boundaries()) + self.assertTrue(self.boundaries(folder="A" + "x" * 79, submission="9" + "x" * 99, + avatar="A" + "x" * 79 + ".jpeg")) + bad_folders = ("", "../secret", "safe/secret", "safe\\secret", "%2fsecret", + "x.y", "x!", "x~", "x*", "x'", "x(", "x)", "x y", "x\n", + "équipe", "_hidden", "-hidden", "0start", "A" * 81) + for folder in bad_folders: + with self.subTest(folder=repr(folder)): + self.assertFalse(self.boundaries(folder=folder)) + for avatar in ("index.md", "../photo.png", "folder/photo.png", "photo.svg", "photo.PNG", + "photo.png.exe", "photo..png", "_photo.png", "A" * 81 + ".png"): + with self.subTest(avatar=avatar): + self.assertFalse(self.boundaries(avatar=avatar)) + for submission in ("", "../key", "key%20", "_key", "-key", "k" * 101): + self.assertFalse(self.boundaries(submission=submission)) + for key in ("SubjectConsentVerified", "ImageConsentVerified", "DispatchIntentReserved"): + self.assertFalse(self.boundaries(**{key: False})) + + def test_extension_allowlist_with_actual_wdl_case_semantics(self): + # WDL startsWith/endsWith ignore case; contains uses exact case. + self.assertTrue(Expression("@endsWith('photo.PNG','.png')").evaluate()) + self.assertTrue(Expression("@startsWith('HTTPS://example.invalid','https://')").evaluate()) + self.assertFalse(Expression("@contains(json('[\"png\",\"jpg\",\"jpeg\"]'),'PNG')").evaluate()) + for avatar in ("photo.PNG", "photo.Png", "photo.JPG", "photo.JpG", "photo.JPEG", "photo.JpeG"): + with self.subTest(avatar=avatar): + self.assertFalse(self.boundaries(avatar=avatar)) + for avatar in ("PHOTO.png", "PHOTO.jpg", "PHOTO.jpeg"): + self.assertTrue(self.boundaries(avatar=avatar)) + for avatar in ("photo.pngx", "photo.jpgx", "photo.jpegx", "photo.png.exe", "photo..png"): + self.assertFalse(self.boundaries(avatar=avatar)) + + def test_named_gets_only_and_connection_aliases(self): + connectors = [(n, a) for n, a in all_actions(ROOT) if a["type"].startswith("OpenApiConnection")] + allowed = {"GetFileContentByPath", "StartAndWaitForAnApproval", "CreateRepositoryDispatchEvent", "SendEmailV2"} + self.assertEqual({a["inputs"]["host"]["operationId"] for _, a in connectors}, allowed) + reads = [(n, a) for n, a in connectors if a["inputs"]["host"]["operationId"] == "GetFileContentByPath"] + self.assertEqual({n for n, _ in reads}, {"Get_markdown", "Get_avatar", "Reread_markdown", "Reread_avatar"}) + for name, action in connectors: + host = action["inputs"]["host"] + self.assertIn(host["connectionName"], TEMPLATE["properties"]["connectionReferences"]) + self.assertEqual(action["runtimeConfiguration"]["secureData"]["properties"], ["inputs", "outputs"]) + self.assertNotIn("authentication", action["inputs"]) + for name, action in reads: + parameters = action["inputs"]["parameters"] + self.assertEqual(parameters["dataset"], "@parameters('ProfileSiteAddress')") + self.assertIn("parameters('ProfileRoot')", parameters["path"]) + self.assertIn("outputs('Folder_key')", parameters["path"]) + self.assertNotIn("triggerBody", parameters["path"]) + self.assertEqual(action["inputs"]["retryPolicy"]["count"], 3) + + def test_configuration_rejects_other_github_target_or_recipient(self): + config = {"ProfileSiteAddress": "https://example.invalid", "ProfileRoot": "/SyntheticProfiles", + "ProfileApprover": "operator@example.invalid", "GitHubOwner": "maxdiluca", + "GitHubRepository": "virtualrealitylab"} + gate = ROOT["Require_configuration"]["expression"] + self.assertTrue(Expression(gate, parameters=config).evaluate()) + for key, value in (("GitHubOwner", "other"), ("GitHubRepository", "other"), + ("ProfileApprover", "one@example.invalid;two@example.invalid"), + ("ProfileApprover", "one@example.invalid\ntwo@example.invalid"), + ("ProfileRoot", "/public/../private"), + ("ProfileRoot", "/__PROFILE_ROOT__")): + self.assertFalse(Expression(gate, parameters={**config, key: value}).evaluate()) + + def test_snapshot_size_nonempty_and_lossless_utf8(self): + markdown = "---\ntitle: Synthetic Person\n---\nA complete synthetic public biography.\n" + encoded = base64.b64encode(markdown.encode("utf-8")).decode() + outputs = {"Markdown_snapshot_text": markdown, "Markdown_snapshot_base64": encoded, + "Avatar_snapshot_base64": "aW1hZ2U="} + gate = WORK["Require_snapshot_bounds"]["expression"] + self.assertTrue(Expression(gate, outputs=outputs).evaluate()) + for key, value in (("Markdown_snapshot_text", "different decoded content"), + ("Markdown_snapshot_text", ""), ("Avatar_snapshot_base64", ""), + ("Avatar_snapshot_base64", "A" * 43692)): + self.assertFalse(Expression(gate, outputs={**outputs, key: value}).evaluate()) + unicode_text = "é" * 8000 + utf8 = base64.b64encode(unicode_text.encode("utf-8")).decode() + self.assertFalse(Expression(gate, outputs={**outputs, "Markdown_snapshot_text": unicode_text, + "Markdown_snapshot_base64": utf8}).evaluate()) + + def test_exact_snapshot_approval_identity_and_reread(self): + approval = WORK["Approval"] + p = approval["inputs"]["parameters"] + self.assertEqual(p["WebhookApprovalCreationInput/assignedTo"], "@parameters('ProfileApprover')") + self.assertIn("content/authors/FolderKey/_index.md", p["WebhookApprovalCreationInput/details"]) + self.assertIn("content/authors/FolderKey/avatar.png", p["WebhookApprovalCreationInput/details"]) + self.assertIs(p["WebhookApprovalCreationInput/enableReassignment"], False) + self.assertEqual(p["WebhookApprovalCreationInput/attachments"], [ + {"name": "index.md", "content": "@outputs('Markdown_snapshot_base64')"}, + {"name": "@outputs('Avatar_filename')", "content": "@outputs('Avatar_snapshot_base64')"}, + ]) + self.assertEqual(approval["runAfter"], {"Require_snapshot_bounds": ["Succeeded"]}) + self.assertEqual(approval["inputs"]["retryPolicy"], {"type": "none"}) + self.assertEqual(approval["limit"]["timeout"], "P7D") + gate = WORK["Require_exact_approval"]["expression"] + approved = {"outcome": "Approve", "responses": [{"approverResponse": "Approve", + "responder": {"email": "Operator@Example.invalid"}}]} + config = {"ProfileApprover": "operator@example.invalid"} + self.assertTrue(Expression(gate, parameters=config, bodies={"Approval": approved}).evaluate()) + for bad in ({"outcome": "Reject", "responses": approved["responses"]}, + {"outcome": "Approve", "responses": [{"approverResponse": "Approve", "responder": {"email": "other@example.invalid"}}]}, + {"outcome": "Approve", "responses": [{"approverResponse": "Approve", "responder": {}}]}, + {"outcome": "Approve", "responses": approved["responses"] * 2}, + {"outcome": "Approve", "responses": []}, + {"outcome": "Approve", "responses": None}, + {"outcome": "Approve"}, {}): + self.assertFalse(Expression(gate, parameters=config, bodies={"Approval": bad}).evaluate()) + stable = {"Markdown_snapshot_base64": "bWFya2Rvd24=", "Avatar_snapshot_base64": "aW1hZ2U="} + bodies = {"Reread_markdown": {"$content": stable["Markdown_snapshot_base64"]}, + "Reread_avatar": {"$content": stable["Avatar_snapshot_base64"]}} + equal_gate = WORK["Require_unchanged_snapshots"]["expression"] + self.assertTrue(Expression(equal_gate, outputs=stable, bodies=bodies).evaluate()) + bodies["Reread_avatar"]["$content"] = "Y2hhbmdlZA==" + self.assertFalse(Expression(equal_gate, outputs=stable, bodies=bodies).evaluate()) + self.assertEqual(WORK["Reread_markdown"]["runAfter"], {"Require_exact_approval": ["Succeeded"]}) + self.assertEqual(WORK["Client_payload"]["runAfter"], {"Require_unchanged_snapshots": ["Succeeded"]}) + + def test_six_key_receiver_contract_byte_budget_and_no_write_retry(self): + payload = WORK["Client_payload"]["inputs"] + self.assertEqual(set(payload), {"schema_version", "request_id", "folder", "profile_markdown", "avatar", "approval"}) + self.assertEqual(payload["schema_version"], 1) + self.assertEqual(payload["request_id"], "@outputs('Submission_key')") + self.assertEqual(payload["approval"], {"profile_approved": True, "image_approved": True}) + gate = WORK["Require_payload_bounds"]["expression"] + for value, expected in (("x" * 60000, True), ("x" * 60001, False), ("é" * 30000, True), ("é" * 30001, False)): + self.assertEqual(Expression(gate, outputs={"Serialized_payload": value}).evaluate(), expected) + external = WORK["External_dispatch_enabled"] + self.assertEqual(external["expression"], "@equals(parameters('EnableExternalDispatch'),true)") + self.assertEqual(external["runAfter"], {"Require_payload_bounds": ["Succeeded"]}) + dispatch = external["actions"]["Dispatch_approved_profile"] + self.assertEqual(dispatch["inputs"]["parameters"]["event_type"], "vr-lab-profile-approved-v1") + self.assertEqual(dispatch["inputs"]["parameters"]["client_payload"], "@outputs('Client_payload')") + self.assertEqual(dispatch["inputs"]["retryPolicy"], {"type": "none"}) + self.assertEqual(external["actions"]["Queued_unverified"]["inputs"], {"status": "queued-unverified", "publication_verified": False}) + self.assertNotIn("guid(", json.dumps(TEMPLATE)) + + def test_failure_timeout_handling_minimal_operator_only_message(self): + catch = ROOT["Catch_failure"] + self.assertEqual(catch["runAfter"], {"Profile_sync": ["Failed", "TimedOut"]}) + optional = catch["actions"]["Optional_failure_notification"] + self.assertIn("EnableFailureNotifications", optional["expression"]) + notification = optional["actions"]["Notify_same_named_operator"]["inputs"] + params = notification["parameters"] + self.assertEqual(params["emailMessage/To"], "@parameters('ProfileApprover')") + self.assertEqual(set(params), {"emailMessage/To", "emailMessage/Subject", "emailMessage/Body", "emailMessage/Importance"}) + self.assertNotIn("@", params["emailMessage/Body"]) + self.assertEqual(notification["retryPolicy"], {"type": "none"}) + self.assertEqual(catch["actions"]["Stop_after_failure"]["inputs"]["runError"]["code"], "PROFILE_SYNC_FAILED_OR_TIMED_OUT") + self.assertNotIn("result(", json.dumps(catch)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/profile_sync/profile_sync.py b/scripts/profile_sync/profile_sync.py new file mode 100644 index 0000000..3f6c48f --- /dev/null +++ b/scripts/profile_sync/profile_sync.py @@ -0,0 +1,752 @@ +#!/usr/bin/env python3 +"""Bounded, new-only website profile receiver. Local staging is the default. + +Approval booleans are connector assertions, not evidence of real consent. The +configured trusted connection must bind them to review of these exact bytes. +No model, supplied URL, executable Markdown or local shell is invoked. +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import io +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import unicodedata +from urllib.parse import quote, urlencode, urlsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener +from urllib.error import HTTPError, URLError +import warnings + +from PIL import Image, UnidentifiedImageError +import yaml + +REPOSITORY = "maxdiluca/virtualrealitylab" +BASE_BRANCH = "main" +TRUSTED_SENDER = "maxdiluca" +EVENT_TYPE = "vr-lab-profile-approved-v1" +MAX_PAYLOAD_BYTES = 60000 +MAX_MARKDOWN_BYTES = 16000 +MAX_IMAGE_BYTES = 32768 +MAX_PIXELS = 1000000 +HUGO_VERSION = "0.152.2" +BUILD_DIRECTORIES = frozenset({"assets", "config", "content", "layouts", "static"}) +BUILD_FILES = frozenset({"go.mod", "go.sum", "theme.toml"}) +MAX_BUILD_BYTES = 250 * 1024 * 1024 +GROUPS = frozenset({ + "Affiliated Faculty", "Researchers", "Collaborators", "PhD Students", + "Research Assistants", "Volunteer Research Assistants", "Interns", + "Project Students", "Visitors", "Alumni Researchers", + "Alumni Research Assistants", "Alumni Interns", "Alumni Project Students", +}) +FOLDER_PATTERN = re.compile(r"[A-Za-z][A-Za-z0-9_-]{0,79}\Z") +REQUEST_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,99}\Z") +IMAGE_PATTERN = re.compile(r"[A-Za-z][A-Za-z0-9_-]{0,79}\.(png|jpg|jpeg)\Z", re.I) + + +class Rejected(Exception): + """A fixed, non-content error code safe to print to a workflow log.""" + + +def reject(code: str) -> None: + raise Rejected(code) + + +def exact_keys(value: object, required: set[str], optional: set[str] | None = None) -> dict: + if not isinstance(value, dict) or not required.issubset(value): + reject("schema-invalid") + if not set(value).issubset(required | (optional or set())): + reject("schema-unsupported-field") + return value + + +def sha(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def prose_links(value: str) -> bool: + return bool(re.search(r"(?i)\b(?:[a-z][a-z0-9+.-]*://|www\.)|\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", value)) + + +def text_field(value: object, limit: int, code: str = "profile-field-invalid", *, allow_url: bool = False) -> str: + if not isinstance(value, str): + reject(code) + if any(unicodedata.category(c) in {"Cc", "Cf", "Cs"} for c in value): + reject(code) + value = unicodedata.normalize("NFC", value.strip()) + if not value or len(value) > limit or any( + unicodedata.category(c) in {"Cc", "Cf", "Cs"} for c in value + ): + reject(code) + if (any(c in value for c in "<>\\[]`") or "{{" in value or "}}" in value + or (not allow_url and prose_links(value))): + reject("executable-content-rejected") + return value + + +def https_url(value: object) -> str: + value = text_field(value, 1000, "link-invalid", allow_url=True) + if any(c.isspace() for c in value): + reject("link-invalid") + try: + parsed = urlsplit(value) + hostname = parsed.hostname + port = parsed.port + except ValueError: + reject("link-invalid") + if (parsed.scheme != "https" or not hostname or "." not in hostname + or parsed.username is not None or parsed.password is not None + or port not in {None, 443} or not hostname.isascii() + or not re.fullmatch(r"[A-Za-z0-9.-]+", hostname) + or hostname.endswith((".local", ".internal", ".localhost"))): + reject("link-invalid") + # Links are displayed only. They are never fetched, resolved or used as tools. + return value + + +class StrictLoader(yaml.SafeLoader): + pass + + +def strict_mapping(loader: StrictLoader, node: yaml.MappingNode, deep: bool = False) -> dict: + mapping = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + if not isinstance(key, str) or key in mapping: + reject("yaml-duplicate-or-nonstring-key") + mapping[key] = loader.construct_object(value_node, deep=deep) + return mapping + + +StrictLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, strict_mapping) + + +def validated_markdown(markdown: object, folder: str) -> bytes: + if not isinstance(markdown, str): + reject("markdown-invalid") + try: + encoded = markdown.encode("utf-8") + except UnicodeError: + reject("markdown-invalid") + if len(encoded) > MAX_MARKDOWN_BYTES: + reject("markdown-too-large") + markdown = markdown.replace("\r\n", "\n") + if "\r" in markdown or not markdown.startswith("---\n"): + reject("frontmatter-required") + parts = markdown.split("\n---\n", 1) + if len(parts) != 2 or len(parts[0]) > 8000: + reject("frontmatter-invalid") + header, body = parts[0][4:], parts[1].strip() + if any(unicodedata.category(c) in {"Cf", "Cs"} or + (unicodedata.category(c) == "Cc" and c != "\n") for c in markdown): + reject("markdown-control-character") + # This first release accepts prose, paragraphs, lists and emphasis. URLs + # belong in the validated social fields; Markdown links/code need review. + if (any(c in body for c in "<>[]`\\") or "{{" in body or "}}" in body + or prose_links(body)): + reject("markdown-unsupported-markup") + if len(re.findall(r"[^\W_]", body, re.UNICODE)) < 40: + reject("profile-incomplete") + try: + tokens = list(yaml.scan(header)) + if len(tokens) > 1000 or any(isinstance(token, ( + yaml.tokens.AliasToken, yaml.tokens.AnchorToken, yaml.tokens.TagToken + )) for token in tokens): + reject("yaml-alias-anchor-or-tag") + profile = yaml.load(header, Loader=StrictLoader) + except yaml.YAMLError: + reject("yaml-invalid") + exact_keys(profile, {"title", "authors", "superuser", "role", "organizations", "user_groups"}, + {"interests", "social"}) + title = text_field(profile["title"], 120) + role = text_field(profile["role"], 160) + if profile["authors"] != [folder] or profile["superuser"] is not False: + reject("profile-identity-or-superuser-invalid") + groups = profile["user_groups"] + if (not isinstance(groups, list) or not 1 <= len(groups) <= 3 + or any(not isinstance(group, str) or group not in GROUPS for group in groups) + or len(groups) != len(set(groups))): + reject("profile-group-invalid") + organizations = profile["organizations"] + if not isinstance(organizations, list) or not 1 <= len(organizations) <= 3: + reject("organization-invalid") + clean_orgs = [] + for organization in organizations: + exact_keys(organization, {"name"}, {"url"}) + clean = {"name": text_field(organization["name"], 120)} + if organization.get("url") is not None and organization.get("url") != "": + clean["url"] = https_url(organization["url"]) + clean_orgs.append(clean) + interests = profile.get("interests", []) + if not isinstance(interests, list) or len(interests) > 12: + reject("interests-invalid") + clean_interests = [text_field(interest, 120) for interest in interests if interest is not None] + socials = profile.get("social", []) + if not isinstance(socials, list) or len(socials) > 8: + reject("social-invalid") + clean_socials = [] + for social in socials: + exact_keys(social, {"icon", "icon_pack", "link"}) + if social["link"] is None or social["link"] == "": + if social["icon"] != "link" or social["icon_pack"] != "fas": + reject("social-placeholder-invalid") + continue + icon = social["icon"] + pack = social["icon_pack"] + if (not isinstance(icon, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,40}", icon) + or not isinstance(pack, str) or pack not in {"fas", "fab", "ai"}): + reject("social-icon-invalid") + clean_socials.append({"icon": icon, "icon_pack": pack, "link": https_url(social["link"])}) + # Validation never rewrites the bytes that the subject approved. Null + # placeholders may remain in supported fields; unknown fields fail closed. + return encoded + + +def png_structure(raw: bytes) -> None: + if not raw.startswith(b"\x89PNG\r\n\x1a\n"): + reject("avatar-format-invalid") + offset, types = 8, [] + while offset < len(raw): + if offset + 12 > len(raw): + reject("avatar-decode-invalid") + length = int.from_bytes(raw[offset:offset + 4], "big") + kind = raw[offset + 4:offset + 8] + end = offset + 12 + length + if end > len(raw): + reject("avatar-decode-invalid") + if kind not in {b"IHDR", b"PLTE", b"IDAT", b"IEND", b"tRNS"}: + # Text, EXIF, ICC/XMP and unknown chunks require private cleaning and + # a fresh approval of the cleaned bytes; they are never stripped here. + reject("avatar-metadata-or-animation-rejected") + types.append(kind) + if kind == b"IEND": + if length != 0 or end != len(raw): + reject("avatar-trailing-data-rejected") + break + offset = end + if (not types or types[0] != b"IHDR" or types.count(b"IHDR") != 1 + or b"IDAT" not in types or types[-1] != b"IEND"): + reject("avatar-decode-invalid") + + +def jpeg_structure(raw: bytes) -> None: + if not raw.startswith(b"\xff\xd8"): + reject("avatar-format-invalid") + offset, in_scan = 2, False + while offset < len(raw): + if in_scan: + while offset < len(raw) and raw[offset] != 0xFF: + offset += 1 + if offset >= len(raw) or raw[offset] != 0xFF: + reject("avatar-decode-invalid") + while offset < len(raw) and raw[offset] == 0xFF: + offset += 1 + if offset >= len(raw): + reject("avatar-decode-invalid") + marker = raw[offset] + offset += 1 + if in_scan and (marker == 0 or 0xD0 <= marker <= 0xD7): + continue + if marker == 0xD9: + if offset != len(raw): + reject("avatar-trailing-data-rejected") + return + if marker not in {0xC0, 0xC2, 0xC4, 0xDB, 0xDD, 0xDA, 0xE0, 0xEE}: + reject("avatar-metadata-or-marker-rejected") + in_scan = False + if offset + 2 > len(raw): + reject("avatar-decode-invalid") + length = int.from_bytes(raw[offset:offset + 2], "big") + if length < 2 or offset + length > len(raw): + reject("avatar-decode-invalid") + segment = raw[offset + 2:offset + length] + if marker == 0xE0 and (len(segment) != 14 or not segment.startswith(b"JFIF\0") + or segment[-2:] != b"\0\0"): + reject("avatar-metadata-or-marker-rejected") + if marker == 0xEE and (len(segment) != 12 or not segment.startswith(b"Adobe")): + reject("avatar-metadata-or-marker-rejected") + offset += length + in_scan = marker == 0xDA + reject("avatar-decode-invalid") + + +def validated_image(avatar: object) -> tuple[str, bytes]: + exact_keys(avatar, {"filename", "content_base64"}) + filename, content = avatar["filename"], avatar["content_base64"] + if not isinstance(filename, str) or not IMAGE_PATTERN.fullmatch(filename): + reject("avatar-filename-invalid") + if not isinstance(content, str) or len(content) > 43692: + reject("avatar-too-large-or-invalid") + try: + raw = base64.b64decode(content, validate=True) + except (ValueError, UnicodeError): + reject("avatar-base64-invalid") + if not raw or len(raw) > MAX_IMAGE_BYTES: + reject("avatar-too-large-or-invalid") + expected_format = "PNG" if filename.lower().endswith(".png") else "JPEG" + if expected_format == "PNG": + png_structure(raw) + else: + jpeg_structure(raw) + Image.MAX_IMAGE_PIXELS = MAX_PIXELS + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Image.DecompressionBombWarning) + with Image.open(io.BytesIO(raw)) as source: + if (source.format != expected_format or source.width > 1600 + or source.height > 1600 or source.width * source.height > MAX_PIXELS + or getattr(source, "n_frames", 1) != 1): + reject("avatar-format-or-dimensions-invalid") + source.verify() + with Image.open(io.BytesIO(raw)) as source: + source.load() + # Decode completely to catch corruption. Preserve the exact + # approved pixels, orientation and bytes; no post-approval edit. + if source.getexif(): + reject("avatar-metadata-or-marker-rejected") + return ("avatar.png" if expected_format == "PNG" else "avatar.jpg", raw) + except (UnidentifiedImageError, OSError, ValueError, Image.DecompressionBombError, + Image.DecompressionBombWarning): + reject("avatar-decode-invalid") + + +def safe_directory(path: Path, *, must_exist: bool = True) -> Path: + absolute = path.absolute() + for component in [absolute, *absolute.parents]: + if component.is_symlink(): + reject("path-symlink-rejected") + if must_exist and not absolute.is_dir(): + reject("directory-missing") + return absolute + + +def target_directory(repo: Path, folder: str) -> tuple[Path, bool]: + root = safe_directory(repo) + authors = safe_directory(root / "content" / "authors") + for candidate in authors.iterdir(): + if candidate.name.casefold() == folder.casefold() and candidate.name != folder: + reject("author-case-collision") + target = authors / folder + if target.is_symlink() or (target.exists() and not target.is_dir()): + reject("author-target-invalid") + return target, target.is_dir() + + +def existing_profile_name(target: Path) -> str: + present = [name for name in ("index.md", "_index.md") if (target / name).exists()] + if len(present) != 1: + reject("existing-profile-ambiguous") + return present[0] + + +def checked_file(path: Path) -> bytes: + if path.is_symlink() or not path.is_file() or path.stat().st_size > 4 * 1024 * 1024: + reject("existing-profile-conflict") + return path.read_bytes() + + +def validate_event(event: object, repo: Path) -> dict: + # Reject an untrusted sender before parsing any authored content. + if (not isinstance(event, dict) or not isinstance(event.get("sender"), dict) + or not isinstance(event.get("repository"), dict) + or event["sender"].get("login") != TRUSTED_SENDER + or event["repository"].get("full_name") != REPOSITORY + or event.get("action") != EVENT_TYPE): + reject("event-origin-rejected") + payload = exact_keys(event.get("client_payload"), { + "schema_version", "request_id", "folder", "profile_markdown", "avatar", "approval" + }) + if type(payload["schema_version"]) is not int or payload["schema_version"] != 1: + reject("schema-version-invalid") + try: + payload_bytes = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + except (UnicodeError, TypeError, ValueError): + reject("payload-invalid") + if len(payload_bytes) > MAX_PAYLOAD_BYTES: + reject("payload-too-large") + approval = exact_keys(payload["approval"], {"profile_approved", "image_approved"}) + if approval["profile_approved"] is not True or approval["image_approved"] is not True: + reject("approval-assertion-missing") + folder, request_id = payload["folder"], payload["request_id"] + if not isinstance(folder, str) or not FOLDER_PATTERN.fullmatch(folder): + reject("folder-invalid") + if folder.casefold() in {"index", "con", "prn", "aux", "nul"} or re.fullmatch( + r"(?:com|lpt)[0-9]", folder, re.I + ): + reject("folder-reserved") + if not isinstance(request_id, str) or not REQUEST_PATTERN.fullmatch(request_id): + reject("request-id-invalid") + target, exists = target_directory(repo, folder) + profile_name = existing_profile_name(target) if exists else "_index.md" + prefix = f"content/authors/{folder}/" + avatar_name, avatar_bytes = validated_image(payload["avatar"]) + files = {prefix + profile_name: validated_markdown(payload["profile_markdown"], folder), + prefix + avatar_name: avatar_bytes} + if exists: + if set(entry.name for entry in target.iterdir()) != {Path(path).name for path in files}: + reject("existing-profile-conflict") + if any(checked_file(Path(repo) / path) != data for path, data in files.items()): + reject("existing-profile-conflict") + file_hashes = {path: sha(data) for path, data in sorted(files.items())} + digest = sha(json.dumps(file_hashes, sort_keys=True, separators=(",", ":")).encode("utf-8")) + return {"status": "no-op" if exists else "staged", "folder": folder, "files": files, + "file_hashes": file_hashes, "content_digest": digest, + "request_digest": sha(request_id.encode("ascii")), + "branch": "codex/profile-sync/" + folder.casefold()} + + +def stage(result: dict, output: Path) -> None: + safe_directory(output.parent) + if output.exists() or output.is_symlink(): + reject("stage-output-exists") + output.mkdir(mode=0o700) + for relative, data in result["files"].items(): + destination = output / relative + destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + destination.write_bytes(data) + destination.chmod(0o600) + if destination.read_bytes() != data: + reject("stage-readback-failed") + report = {key: result[key] for key in ("status", "file_hashes", "content_digest", "request_digest")} + report["public_content_review_required"] = True + report["external_write_performed"] = False + report_path = output / "receipt.json" + report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + report_path.chmod(0o600) + + +def fixed_command(argv: list[str], cwd: Path) -> bytes: + environment = {key: os.environ[key] for key in ( + "PATH", "HOME", "TMPDIR", "TEMP", "TMP", "GOPATH", "GOMODCACHE", "GOCACHE" + ) if key in os.environ} + try: + process = subprocess.run(argv, cwd=cwd, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, timeout=60, check=False, env=environment) + except (OSError, subprocess.TimeoutExpired): + reject("route-check-tool-unavailable") + if process.returncode != 0 or len(process.stdout) > 2 * 1024 * 1024: + # Do not emit Hugo/Git diagnostics: they can contain the approved bio. + reject("route-check-build-or-collision-failed") + return process.stdout + + +def check_routes(result: dict, repo: Path, output: Path) -> None: + """Build only the allowlisted tracked website source, never the checkout. + + A baseline and proposed build use the site's actual Hugo configuration. + Duplicate output paths are fatal; the proposal must produce one new author + HTML page. Existing generated/ignored/private state is never copied. + """ + version = fixed_command(["hugo", "version"], repo) + if not re.match(rb"hugo v0\.152\.2(?:\+|[-\s])", version): + reject("route-check-hugo-version-invalid") + tracked = fixed_command(["git", "ls-files", "-z"], repo) + names = tracked.decode("utf-8").split("\0") + if not names or names[-1] != "" or len(names) > 10000: + reject("route-check-snapshot-invalid") + preview = output / "private-preview-source" + if preview.exists() or preview.is_symlink(): + reject("route-check-output-exists") + preview.mkdir(mode=0o700) + total_bytes = 0 + for name in names[:-1]: + relative = Path(name) + if (relative.is_absolute() or ".." in relative.parts or not relative.parts + or any(unicodedata.category(c) in {"Cc", "Cf", "Cs"} for c in name)): + reject("route-check-snapshot-invalid") + # Classify names before touching their filesystem entries. Operational + # outputs, local settings, mail and build artifacts are not site inputs. + if not ((len(relative.parts) > 1 and relative.parts[0] in BUILD_DIRECTORIES) + or name in BUILD_FILES): + continue + source = repo / relative + safe_directory(source.parent) + if source.is_symlink() or not source.is_file(): + reject("route-check-snapshot-invalid") + total_bytes += source.stat().st_size + if total_bytes > MAX_BUILD_BYTES: + reject("route-check-snapshot-too-large") + destination = preview / relative + destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + shutil.copyfile(source, destination) + destination.chmod(0o600) + if not (preview / "content" / "authors").is_dir(): + reject("route-check-snapshot-invalid") + baseline = output / "private-preview-baseline" + proposed = output / "private-preview-proposal" + for destination in (baseline, proposed): + destination.mkdir(mode=0o700) + build_arguments = ["hugo", "--source", str(preview), "--environment", "production", + "--printPathWarnings", "--panicOnWarning"] + fixed_command(build_arguments + ["--destination", str(baseline)], repo) + baseline_pages = {path.relative_to(baseline).as_posix() + for path in (baseline / "author").rglob("index.html")} + for relative, data in result["files"].items(): + destination = preview / relative + if destination.exists() or destination.is_symlink(): + reject("route-check-existing-profile-conflict") + destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + destination.write_bytes(data) + destination.chmod(0o600) + fixed_command(build_arguments + ["--destination", str(proposed)], repo) + proposed_pages = {path.relative_to(proposed).as_posix() + for path in (proposed / "author").rglob("index.html")} + new_pages = proposed_pages - baseline_pages + if len(new_pages) != 1 or not baseline_pages.issubset(proposed_pages): + reject("route-check-new-author-page-not-unique") + new_page = proposed / next(iter(new_pages)) + if new_page.is_symlink() or not new_page.is_file() or new_page.stat().st_size == 0: + reject("route-check-new-author-page-missing") + result["route_check"] = {"hugo_version": HUGO_VERSION, + "content_digest": result["content_digest"], + "base_sha": os.environ.get("GITHUB_SHA")} + + +class NoRedirect(HTTPRedirectHandler): + def redirect_request(self, request, fp, code, message, headers, new_url): + return None + + +class GitHub: + """Fixed-host REST client. Never follows supplied URLs or retries a mutation.""" + + def __init__(self, token: str): + self.token = token + + def call(self, method: str, resource: str, body: dict | None = None, + *, missing_ok: bool = False) -> object: + request = Request("https://api.github.com/repos/" + REPOSITORY + resource, + data=None if body is None else json.dumps(body).encode("utf-8"), + method=method, headers={"Authorization": "Bearer " + self.token, + "Accept": "application/vnd.github+json", "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", "User-Agent": "vr-lab-profile-sync"}) + try: + with build_opener(NoRedirect).open(request, timeout=30) as response: + data = response.read(8 * 1024 * 1024 + 1) + if len(data) > 8 * 1024 * 1024: + reject("github-response-too-large") + return json.loads(data) + except HTTPError as error: + if missing_ok and error.code == 404: + return None + reject("github-request-rejected" if method == "GET" else "github-write-outcome-unknown") + except (URLError, TimeoutError, OSError, ValueError): + reject("github-read-unavailable" if method == "GET" else "github-write-outcome-unknown") + + +def remote_content(api: GitHub, path: str, ref: str) -> bytes: + resource = "/contents/" + quote(path, safe="/") + "?" + urlencode({"ref": ref}) + value = api.call("GET", resource) + if not isinstance(value, dict) or value.get("type") != "file" or value.get("encoding") != "base64": + reject("github-content-readback-invalid") + try: + return base64.b64decode(value["content"].replace("\n", ""), validate=True) + except (KeyError, TypeError, ValueError): + reject("github-content-readback-invalid") + + +def branch_sha(api: GitHub, branch: str) -> str | None: + value = api.call("GET", "/git/ref/heads/" + quote(branch, safe="/"), missing_ok=True) + if value is None: + return None + try: + result = value["object"]["sha"] + except (KeyError, TypeError): + reject("github-ref-invalid") + if not re.fullmatch(r"[0-9a-f]{40}", result): + reject("github-ref-invalid") + return result + + +def verify_proposal_branch(api: GitHub, head: str, result: dict, base: str) -> None: + commit = api.call("GET", "/git/commits/" + head) + if (not isinstance(commit, dict) or len(commit.get("parents", [])) != 1 + or "content-sha256=" + result["content_digest"] not in commit.get("message", "")): + reject("pending-proposal-conflict") + parent = commit["parents"][0].get("sha") + if not isinstance(parent, str) or not re.fullmatch(r"[0-9a-f]{40}", parent): + reject("pending-proposal-conflict") + if parent != base: + ancestry = api.call("GET", "/compare/" + base + "..." + parent) + if (not isinstance(ancestry, dict) or ancestry.get("status") not in {"behind", "identical"} + or ancestry.get("total_commits") != 0 + or ancestry.get("merge_base_commit", {}).get("sha") != parent): + reject("pending-proposal-base-conflict") + # Compare against the actual PR base, not just an arbitrary branch parent. + comparison = api.call("GET", "/compare/" + base + "..." + head) + if (not isinstance(comparison, dict) or comparison.get("total_commits") != 1 + or set(item.get("filename") for item in comparison.get("files", [])) != set(result["files"]) + or any(item.get("status") != "added" for item in comparison.get("files", []))): + reject("pending-proposal-conflict") + if any(remote_content(api, path, head) != data for path, data in result["files"].items()): + reject("pending-proposal-conflict") + + +def proposal_pr(api: GitHub, result: dict) -> dict | None: + query = urlencode({"state": "all", "head": REPOSITORY.split("/")[0] + ":" + result["branch"], + "base": BASE_BRANCH, "per_page": 100}) + matches = api.call("GET", "/pulls?" + query) + if not isinstance(matches, list) or len(matches) > 1: + reject("pull-request-ambiguous") + if not matches: + return None + pr = matches[0] + if pr.get("state") != "open" or pr.get("merged_at"): + reject("prior-proposal-closed") + if (pr.get("draft") is not True or pr.get("base", {}).get("ref") != BASE_BRANCH + or pr.get("head", {}).get("ref") != result["branch"] + or pr.get("head", {}).get("repo", {}).get("full_name") != REPOSITORY): + reject("pull-request-readback-invalid") + return pr + + +def publish(result: dict, repo: Path, output: Path, api: GitHub | None = None) -> dict: + required = {"PROFILE_SYNC_ENABLED": "true", "PROFILE_SYNC_REPOSITORY": REPOSITORY, + "PROFILE_SYNC_BASE_BRANCH": BASE_BRANCH, "GITHUB_REPOSITORY": REPOSITORY, + "GITHUB_EVENT_NAME": "repository_dispatch"} + if any(os.environ.get(key) != value for key, value in required.items()): + reject("live-publishing-disabled") + token = os.environ.get("GITHUB_TOKEN") + if not token: + reject("github-token-missing") + api = api or GitHub(token) + if fixed_command(["git", "rev-parse", "HEAD"], repo).strip().decode("ascii") != os.environ.get("GITHUB_SHA"): + reject("checkout-base-mismatch") + try: + fixed_command(["git", "diff", "--quiet", "HEAD", "--"], repo) + except Rejected: + reject("checkout-tracked-changes-rejected") + base = branch_sha(api, BASE_BRANCH) + if base is None or os.environ.get("GITHUB_SHA") != base: + reject("base-changed-retry-from-new-checkout") + root = api.call("GET", "/contents/content/authors?" + urlencode({"ref": base})) + if not isinstance(root, list): + reject("github-author-directory-invalid") + matches = [item for item in root if item.get("name", "").casefold() == result["folder"].casefold()] + if matches: + if len(matches) != 1 or matches[0].get("name") != result["folder"]: + reject("author-case-collision") + if result["status"] != "no-op" or any( + remote_content(api, path, base) != data for path, data in result["files"].items() + ): + reject("existing-profile-conflict") + return {"status": "no-op", "external_write_performed": False} + if result["status"] == "no-op": + reject("base-changed-retry-from-new-checkout") + check_routes(result, repo, output) + if branch_sha(api, BASE_BRANCH) != base: + reject("base-changed-retry-from-new-checkout") + head = branch_sha(api, result["branch"]) + if head is None: + base_commit = api.call("GET", "/git/commits/" + base) + entries = [] + for path, content in result["files"].items(): + blob = api.call("POST", "/git/blobs", {"encoding": "base64", + "content": base64.b64encode(content).decode("ascii")}) + entries.append({"path": path, "mode": "100644", "type": "blob", "sha": blob["sha"]}) + tree = api.call("POST", "/git/trees", {"base_tree": base_commit["tree"]["sha"], "tree": entries}) + commit = api.call("POST", "/git/commits", { + "message": "Prepare approved website profile\n\ncontent-sha256=" + result["content_digest"] + + "\nrequest-sha256=" + result["request_digest"], + "tree": tree["sha"], "parents": [base]}) + try: + api.call("POST", "/git/refs", {"ref": "refs/heads/" + result["branch"], "sha": commit["sha"]}) + except Rejected as error: + if str(error) != "github-write-outcome-unknown": + raise + # A lost response is reconciled by reading the deterministic ref. + head = branch_sha(api, result["branch"]) + if head is None: + reject("github-write-outcome-unknown") + verify_proposal_branch(api, head, result, base) + # Stop if main changed while preparing the branch; never mutate or merge main. + if branch_sha(api, BASE_BRANCH) != base: + reject("base-changed-retry-from-new-checkout") + pr = proposal_pr(api, result) + if pr is None: + try: + api.call("POST", "/pulls", { + "title": "Add approved VR Lab website profile", "head": result["branch"], + "base": BASE_BRANCH, "draft": True, + "body": "Prepared from the configured Power Automate connection's approval assertions. " + "The website owner must check exact public content and approval provenance before merging.\n\n" + "Canonical content SHA-256: " + result["content_digest"] + "\n" + "Opaque request SHA-256: " + result["request_digest"] + "\n\n" + "This proposal adds one profile and one validated, metadata-free image, preserving the approved " + "file bytes. No automatic merge is enabled."}) + except Rejected as error: + if str(error) != "github-write-outcome-unknown": + raise + pr = proposal_pr(api, result) + if pr is None: + reject("github-write-outcome-unknown") + if pr.get("head", {}).get("sha") != head or branch_sha(api, result["branch"]) != head: + reject("pull-request-readback-invalid") + number = pr.get("number") + if type(number) is not int or number < 1: + reject("pull-request-readback-invalid") + changed = api.call("GET", "/pulls/" + str(number) + "/files?per_page=100") + if (not isinstance(changed, list) + or {item.get("filename") for item in changed} != set(result["files"]) + or len(changed) != len(result["files"]) + or any(item.get("status") != "added" for item in changed)): + reject("pull-request-file-scope-invalid") + fresh_pr = proposal_pr(api, result) + if (fresh_pr is None or fresh_pr.get("head", {}).get("sha") != head + or branch_sha(api, result["branch"]) != head): + reject("pull-request-readback-invalid") + url = pr.get("html_url") + if not isinstance(url, str) or not re.fullmatch( + r"https://github\.com/maxdiluca/virtualrealitylab/pull/[0-9]+", url + ): + reject("pull-request-readback-invalid") + return {"status": "draft-pull-request-verified", "pull_request_url": url, + "external_write_performed": True} + + +def strict_json(pairs: list[tuple[str, object]]) -> dict: + mapping = {} + for key, value in pairs: + if key in mapping: + reject("json-duplicate-key") + mapping[key] = value + return mapping + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--event-json", required=True, type=Path) + parser.add_argument("--repo", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--publish", action="store_true", help="Opt-in, configured draft PR writes only") + args = parser.parse_args() + try: + if args.event_json.is_symlink() or args.event_json.stat().st_size > 1024 * 1024: + reject("event-file-invalid") + event = json.loads(args.event_json.read_text(encoding="utf-8"), object_pairs_hook=strict_json) + result = validate_event(event, args.repo) + stage(result, args.output) + public_report = {key: result[key] for key in ("status", "content_digest", "request_digest")} + public_report["external_write_performed"] = False + if args.publish: + public_report.update(publish(result, args.repo, args.output)) + print(json.dumps(public_report, sort_keys=True)) + return 0 + except Rejected as error: + print(json.dumps({"status": "withheld", "reason": str(error)})) + return 2 + except Exception: + # Do not print exception messages: parsers may include source content. + print(json.dumps({"status": "withheld", "reason": "input-or-service-invalid"})) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/profile_sync/requirements.txt b/scripts/profile_sync/requirements.txt new file mode 100644 index 0000000..4527184 --- /dev/null +++ b/scripts/profile_sync/requirements.txt @@ -0,0 +1,2 @@ +PyYAML==6.0.3 +Pillow==12.3.0 diff --git a/scripts/profile_sync/test_profile_sync.py b/scripts/profile_sync/test_profile_sync.py new file mode 100644 index 0000000..dd01f22 --- /dev/null +++ b/scripts/profile_sync/test_profile_sync.py @@ -0,0 +1,570 @@ +"""Synthetic-only correctness and failure tests; no real sources or network.""" +import base64 +from copy import deepcopy +import io +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch +from urllib.parse import parse_qs, unquote, urlsplit + +from PIL import Image, PngImagePlugin +import profile_sync as sync + +HERE = Path(__file__).parent +BASE = "a" * 40 +HEAD = "b" * 40 +TREE = "c" * 40 + + +def image_bytes(format="PNG", size=(16, 16), **kwargs): + output = io.BytesIO() + Image.new("RGB", size, color=(30, 80, 120)).save(output, format=format, **kwargs) + return output.getvalue() + + +def fixture(): + return json.loads((HERE / "fixtures" / "approved-event.json").read_text()) + + +class ReceiverTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name).resolve() + self.repo = self.root / "website" + (self.repo / "content" / "authors").mkdir(parents=True) + self.event = fixture() + self.payload = self.event["client_payload"] + # Tests explicitly use synthetic generated pixels, not a personal photo. + self.payload["avatar"]["content_base64"] = base64.b64encode(image_bytes()).decode() + + def tearDown(self): + self.temporary.cleanup() + + def reject(self, code=None): + with self.assertRaises(sync.Rejected) as caught: + sync.validate_event(self.event, self.repo) + if code is not None: + self.assertEqual(str(caught.exception), code) + + def test_happy_path_preserves_approved_bytes_and_known_path_renames(self): + result = sync.validate_event(self.event, self.repo) + prefix = "content/authors/Synthetic_Member/" + self.assertEqual(result["status"], "staged") + self.assertEqual(result["files"][prefix + "_index.md"], self.payload["profile_markdown"].encode()) + self.assertEqual(result["files"][prefix + "avatar.png"], base64.b64decode(self.payload["avatar"]["content_base64"])) + self.assertEqual(result["branch"], "codex/profile-sync/synthetic_member") + self.assertIn("interests: [null]", result["files"][prefix + "_index.md"].decode()) + + def test_committed_synthetic_fixture_is_valid_without_replacement(self): + self.assertEqual(sync.validate_event(fixture(), self.repo)["status"], "staged") + + def test_prose_scalar_cannot_embed_markdown_or_autolink(self): + for role in ["[Click](https://example.org)", "www.example.org", "https://example.org", "name@example.org", "`code`"]: + event = deepcopy(self.event) + event["client_payload"]["profile_markdown"] = self.payload["profile_markdown"].replace("role: Synthetic Project Student", "role: '" + role + "'") + with self.assertRaises(sync.Rejected): + sync.validate_event(event, self.repo) + + def test_immutable_digest_changes_with_one_approved_byte(self): + first = sync.validate_event(self.event, self.repo) + self.payload["profile_markdown"] += "\n" + second = sync.validate_event(self.event, self.repo) + self.assertNotEqual(first["content_digest"], second["content_digest"]) + self.assertEqual(first["request_digest"], second["request_digest"]) + + def test_event_origin_rejected_before_content_decoder(self): + self.event["sender"]["login"] = "untrusted" + with patch.object(sync, "validated_image", side_effect=AssertionError("must not decode")): + self.reject("event-origin-rejected") + + def test_malformed_origin_fails_closed(self): + self.event["sender"] = "not-a-server-identity" + self.reject("event-origin-rejected") + + def test_repository_and_action_are_fixed(self): + for key, value in [("action", "unexpected"), ("repository", {"full_name": "different/repo"})]: + event = deepcopy(self.event) + event[key] = value + with self.assertRaises(sync.Rejected): + sync.validate_event(event, self.repo) + + def test_approval_missing_false_or_string_is_rejected(self): + for value in [False, "true", 1, None]: + self.payload["approval"]["image_approved"] = value + self.reject("approval-assertion-missing") + + def test_unknown_payload_fields_cannot_broaden_capabilities(self): + self.payload["repository"] = "other/repo" + self.reject("schema-unsupported-field") + + def test_schema_boolean_is_not_numeric_version(self): + self.payload["schema_version"] = True + self.reject("schema-version-invalid") + + def test_traversal_and_unsafe_folders(self): + for folder in ["../Member", "Member/Other", ".Member", "Member name", "Member%2FName", "_hidden", "CON"]: + self.payload["folder"] = folder + self.reject() + + def test_request_id_not_a_path(self): + self.payload["request_id"] = "../old-request" + self.reject("request-id-invalid") + + def test_case_collision(self): + (self.repo / "content/authors/synthetic_member").mkdir() + self.reject("author-case-collision") + + def test_author_directory_symlink_rejected(self): + target = self.repo / "content/authors/Synthetic_Member" + target.symlink_to(self.root, target_is_directory=True) + self.reject("author-target-invalid") + + def test_missing_biography_rejects_generated_skeleton(self): + self.payload["profile_markdown"] = self.payload["profile_markdown"].split("---\n\n")[0] + "---\n\n" + self.reject("profile-incomplete") + + def test_identity_and_superuser_are_not_inferred(self): + for old, new in [("authors: [Synthetic_Member]", "authors: [Other_Member]"), + ("superuser: false", "superuser: true")]: + event = deepcopy(self.event) + event["client_payload"]["profile_markdown"] = self.payload["profile_markdown"].replace(old, new) + with self.assertRaises(sync.Rejected): + sync.validate_event(event, self.repo) + + def test_unknown_group_requires_review(self): + self.payload["profile_markdown"] = self.payload["profile_markdown"].replace("Project Students", "Project Studentses") + self.reject("profile-group-invalid") + + def test_unsupported_yaml_keys_and_duplicate_keys_rejected(self): + for addition in ["avatar: supplied.png\n", "title: Different title\n"]: + event = deepcopy(self.event) + event["client_payload"]["profile_markdown"] = self.payload["profile_markdown"].replace("---\n\n", addition + "---\n\n") + with self.assertRaises(sync.Rejected): + sync.validate_event(event, self.repo) + + def test_yaml_alias_tags_and_objects_rejected(self): + for value in ["&title Synthetic Member", "!!python/object:subprocess.Popen {}", "*outside"]: + self.payload["profile_markdown"] = fixture()["client_payload"]["profile_markdown"].replace("title: Synthetic Member", "title: " + value) + self.reject() + + def test_html_shortcodes_and_markdown_links_rejected(self): + for addition in ["", '{{< include "private" >}}', "[link](javascript:bad)", "`code`"]: + self.payload["profile_markdown"] = fixture()["client_payload"]["profile_markdown"] + addition + self.reject("markdown-unsupported-markup") + + def test_links_require_https_without_credentials(self): + for url in ["http://example.org", "https://name:secret@example.org", "https://localhost", "file:///tmp/file"]: + self.payload["profile_markdown"] = fixture()["client_payload"]["profile_markdown"].replace("https://www.birmingham.ac.uk", url) + self.reject("link-invalid") + + def test_utf8_byte_limit_not_character_limit(self): + self.payload["profile_markdown"] += "é" * 8000 + self.reject("markdown-too-large") + + def test_jpeg_preserved_with_announced_avatar_jpg_rename(self): + jpeg = image_bytes("JPEG") + self.payload["avatar"] = {"filename": "synthetic.jpeg", "content_base64": base64.b64encode(jpeg).decode()} + result = sync.validate_event(self.event, self.repo) + self.assertEqual(result["files"]["content/authors/Synthetic_Member/avatar.jpg"], jpeg) + + def test_progressive_jpeg_pixel_data_is_supported(self): + jpeg = image_bytes("JPEG", progressive=True) + self.payload["avatar"] = {"filename": "synthetic.jpg", "content_base64": base64.b64encode(jpeg).decode()} + result = sync.validate_event(self.event, self.repo) + self.assertEqual(result["files"]["content/authors/Synthetic_Member/avatar.jpg"], jpeg) + + def test_png_personal_metadata_rejected_for_cleanup_and_new_approval(self): + info = PngImagePlugin.PngInfo() + info.add_text("Comment", "Synthetic only metadata") + self.payload["avatar"]["content_base64"] = base64.b64encode(image_bytes(pnginfo=info)).decode() + self.reject("avatar-metadata-or-animation-rejected") + + def test_jpeg_exif_rejected(self): + exif = Image.Exif() + exif[0x010E] = "Synthetic only metadata" + self.payload["avatar"] = {"filename": "synthetic.jpg", "content_base64": base64.b64encode(image_bytes("JPEG", exif=exif)).decode()} + self.reject("avatar-metadata-or-marker-rejected") + + def test_trailing_data_rejected(self): + self.payload["avatar"]["content_base64"] = base64.b64encode(image_bytes() + b"hidden data").decode() + self.reject("avatar-trailing-data-rejected") + + def test_avatar_bad_base64_extension_and_size(self): + for avatar in [{"filename": "x.png", "content_base64": "!!!"}, + {"filename": "../x.png", "content_base64": "a"}, + {"filename": "x.png", "content_base64": "a" * 43693}]: + self.payload["avatar"] = avatar + self.reject() + + def test_image_dimensions_are_bounded_even_if_compressed_file_small(self): + self.payload["avatar"]["content_base64"] = base64.b64encode(image_bytes(size=(1001, 1000))).decode() + self.reject() + + def test_exact_existing_files_noop_and_filename_preserved(self): + result = sync.validate_event(self.event, self.repo) + for name, data in result["files"].items(): + destination = self.repo / name + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(data) + index = self.repo / "content/authors/Synthetic_Member/_index.md" + index.rename(index.with_name("index.md")) + repeat = sync.validate_event(self.event, self.repo) + self.assertEqual(repeat["status"], "no-op") + self.assertIn("content/authors/Synthetic_Member/index.md", repeat["files"]) + + def test_existing_changed_profile_or_extra_file_is_conflict(self): + result = sync.validate_event(self.event, self.repo) + for name, data in result["files"].items(): + destination = self.repo / name + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(data) + (self.repo / "content/authors/Synthetic_Member/extra.txt").write_text("Synthetic extra") + self.reject("existing-profile-conflict") + + def test_ambiguous_existing_index_names_is_conflict(self): + target = self.repo / "content/authors/Synthetic_Member" + target.mkdir() + (target / "index.md").write_text("Synthetic") + (target / "_index.md").write_text("Synthetic") + self.reject("existing-profile-ambiguous") + + def test_staging_is_private_read_back_and_does_not_change_source(self): + result = sync.validate_event(self.event, self.repo) + output = self.root / "new-stage" + sync.stage(result, output) + self.assertEqual(output.stat().st_mode & 0o777, 0o700) + for relative, content in result["files"].items(): + self.assertEqual((output / relative).read_bytes(), content) + self.assertEqual((output / relative).stat().st_mode & 0o777, 0o600) + self.assertFalse((self.repo / "content/authors/Synthetic_Member").exists()) + with self.assertRaises(sync.Rejected): + sync.stage(result, output) + + +class MemoryGitHub: + """A synthetic GitHub model with uncertain-response and stale-content paths.""" + def __init__(self, result): + self.result = result + self.head = None + self.pr = None + self.requests = [] + self.files = deepcopy(result["files"]) + self.ref_unknown = False + self.pr_unknown = False + self.closed = False + self.extra_change = False + self.parent = BASE + self.ancestry = "behind" + + def call(self, method, resource, body=None, missing_ok=False): + self.requests.append((method, resource, body)) + route = unquote(urlsplit(resource).path) + if method == "GET" and route == "/git/ref/heads/main": + return {"object": {"sha": BASE}} + if method == "GET" and route == "/git/ref/heads/" + self.result["branch"]: + return None if self.head is None else {"object": {"sha": self.head}} + if method == "GET" and route == "/contents/content/authors": + return [] + if method == "GET" and route == "/git/commits/" + BASE: + return {"tree": {"sha": TREE}} + if method == "GET" and route == "/git/commits/" + HEAD: + return {"parents": [{"sha": self.parent}], "message": "content-sha256=" + self.result["content_digest"]} + if method == "POST" and route == "/git/blobs": + return {"sha": "d" * 40} + if method == "POST" and route == "/git/trees": + return {"sha": TREE} + if method == "POST" and route == "/git/commits": + return {"sha": HEAD} + if method == "POST" and route == "/git/refs": + self.head = HEAD + if self.ref_unknown: + sync.reject("github-write-outcome-unknown") + return {"object": {"sha": HEAD}} + if method == "GET" and route.startswith("/compare/"): + if route.endswith("..." + self.parent) and self.parent != BASE: + return {"total_commits": 0, "status": self.ancestry, + "merge_base_commit": {"sha": self.parent}} + files = [{"filename": path, "status": "added"} for path in self.files] + if self.extra_change: + files.append({"filename": "config/_default/params.yaml", "status": "modified"}) + return {"total_commits": 1, "files": files} + if method == "GET" and route.startswith("/contents/"): + path = route.removeprefix("/contents/") + return {"type": "file", "encoding": "base64", "content": base64.b64encode(self.files[path]).decode()} + if method == "GET" and route == "/pulls": + return [] if self.pr is None else [self.pr] + if method == "GET" and route == "/pulls/99/files": + return [{"filename": path, "status": "added"} for path in self.files] + if method == "POST" and route == "/pulls": + self.pr = {"number": 99, "draft": True, "state": "closed" if self.closed else "open", "merged_at": None, + "head": {"ref": self.result["branch"], "sha": HEAD, + "repo": {"full_name": sync.REPOSITORY}}, + "base": {"ref": "main"}, "html_url": "https://github.com/maxdiluca/virtualrealitylab/pull/99"} + if self.pr_unknown: + sync.reject("github-write-outcome-unknown") + return self.pr + raise AssertionError("Unexpected synthetic API operation: " + method + " " + resource) + + +class PublishingTests(unittest.TestCase): + def setUp(self): + ReceiverTests.setUp(self) + self.result = sync.validate_event(self.event, self.repo) + self.api = MemoryGitHub(self.result) + self.environment = {"PROFILE_SYNC_ENABLED": "true", "PROFILE_SYNC_REPOSITORY": sync.REPOSITORY, + "PROFILE_SYNC_BASE_BRANCH": "main", "GITHUB_REPOSITORY": sync.REPOSITORY, + "GITHUB_EVENT_NAME": "repository_dispatch", "GITHUB_SHA": BASE, + "GITHUB_TOKEN": "synthetic-unused-token"} + self.output = self.root / "stage" + sync.stage(self.result, self.output) + + tearDown = ReceiverTests.tearDown + + def call_publish(self, route_error=None): + def command(argv, cwd): + return (BASE + "\n").encode() if argv[:3] == ["git", "rev-parse", "HEAD"] else b"" + with patch.dict(os.environ, self.environment, clear=True), patch.object(sync, "fixed_command", side_effect=command), patch.object(sync, "check_routes", side_effect=route_error): + return sync.publish(self.result, self.repo, self.output, self.api) + + def test_disabled_publishing_never_calls_api(self): + with patch.dict(os.environ, {}, clear=True), self.assertRaises(sync.Rejected) as error: + sync.publish(self.result, self.repo, self.output, self.api) + self.assertEqual(str(error.exception), "live-publishing-disabled") + self.assertFalse(self.api.requests) + + def test_new_proposal_only_two_allowed_added_files_and_draft_pr(self): + response = self.call_publish() + self.assertEqual(response["status"], "draft-pull-request-verified") + writes = [item for item in self.api.requests if item[0] != "GET"] + self.assertTrue(all(item[0] == "POST" for item in writes)) + tree = next(body for method, route, body in writes if route == "/git/trees") + self.assertEqual({item["path"] for item in tree["tree"]}, set(self.result["files"])) + reference = next(body for method, route, body in writes if route == "/git/refs") + self.assertEqual(reference["ref"], "refs/heads/codex/profile-sync/synthetic_member") + self.assertFalse(any("/merge" in route for method, route, body in writes)) + + def test_exact_repeat_reconciles_without_second_write(self): + self.call_publish() + self.api.requests = [] + self.call_publish() + self.assertTrue(all(method == "GET" for method, route, body in self.api.requests)) + + def test_lost_ref_and_pr_responses_reconciled_before_retry(self): + self.api.ref_unknown = True + self.api.pr_unknown = True + response = self.call_publish() + self.assertEqual(response["status"], "draft-pull-request-verified") + self.assertEqual(sum(method == "POST" and route == "/pulls" for method, route, body in self.api.requests), 1) + + def test_edited_pending_content_fails_without_overwrite(self): + self.call_publish() + first = next(iter(self.api.files)) + self.api.files[first] += b"Changed synthetic content" + self.api.requests = [] + with self.assertRaises(sync.Rejected): + self.call_publish() + self.assertTrue(all(method == "GET" for method, route, body in self.api.requests)) + + def test_unrelated_pending_branch_change_is_conflict(self): + self.call_publish() + self.api.extra_change = True + self.api.requests = [] + with self.assertRaises(sync.Rejected): + self.call_publish() + self.assertTrue(all(method == "GET" for method, route, body in self.api.requests)) + + def test_closed_proposal_is_not_recreated(self): + self.call_publish() + self.api.pr["state"] = "closed" + self.api.requests = [] + with self.assertRaises(sync.Rejected) as error: + self.call_publish() + self.assertEqual(str(error.exception), "prior-proposal-closed") + self.assertTrue(all(method == "GET" for method, route, body in self.api.requests)) + + def test_pending_branch_unrelated_parent_cannot_broaden_pr(self): + self.call_publish() + self.api.parent = "e" * 40 + self.api.ancestry = "diverged" + self.api.requests = [] + with self.assertRaises(sync.Rejected) as error: + self.call_publish() + self.assertEqual(str(error.exception), "pending-proposal-base-conflict") + self.assertTrue(all(method == "GET" for method, route, body in self.api.requests)) + + def test_route_collision_or_missing_build_tool_blocks_every_write(self): + for code in ["route-check-build-or-collision-failed", "route-check-tool-unavailable"]: + self.api.requests = [] + with self.assertRaises(sync.Rejected): + self.call_publish(sync.Rejected(code)) + self.assertTrue(all(method == "GET" for method, route, body in self.api.requests)) + + +class RouteTests(unittest.TestCase): + def setUp(self): + ReceiverTests.setUp(self) + self.result = sync.validate_event(self.event, self.repo) + existing = self.repo / "content/authors/Existing/_index.md" + existing.parent.mkdir() + existing.write_text("Synthetic baseline author") + self.output = self.root / "stage" + sync.stage(self.result, self.output) + + tearDown = ReceiverTests.tearDown + + def model(self, version="0.152.2", collision=False, no_new_page=False, tracked=None): + calls = [] + def command(argv, cwd): + calls.append(argv) + if argv == ["hugo", "version"]: + return ("hugo v" + version + "+extended linux/amd64\n").encode() + if argv == ["git", "ls-files", "-z"]: + return tracked if tracked is not None else b"content/authors/Existing/_index.md\0" + self.assertIn("--printPathWarnings", argv) + self.assertIn("--panicOnWarning", argv) + source = Path(argv[argv.index("--source") + 1]) + destination = Path(argv[argv.index("--destination") + 1]) + existing = destination / "author/existing/index.html" + existing.parent.mkdir(parents=True) + existing.write_text("Synthetic baseline HTML") + if (source / "content/authors/Synthetic_Member/_index.md").exists(): + if collision: + sync.reject("route-check-build-or-collision-failed") + if not no_new_page: + new = destination / "author/synthetic-member/index.html" + new.parent.mkdir(parents=True) + new.write_text("Synthetic rendered profile HTML") + return b"Private synthetic build diagnostics" + return command, calls + + def test_actual_build_argv_and_two_snapshots_preserve_site_source(self): + command, calls = self.model() + with patch.object(sync, "fixed_command", side_effect=command): + sync.check_routes(self.result, self.repo, self.output) + self.assertEqual(len(calls), 4) + self.assertFalse((self.repo / "content/authors/Synthetic_Member").exists()) + self.assertEqual(self.result["route_check"]["content_digest"], self.result["content_digest"]) + + def test_only_exact_website_sources_are_copied(self): + names = ["content/authors/Existing/_index.md", "assets/example.txt", "config/example.yaml", + "layouts/example.html", "static/example.txt", "go.mod", "go.sum", "theme.toml"] + for name in names[1:]: + path = self.repo / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"Synthetic website input") + outside = ["outputs/missing.xlsx", ".claude/settings.local.json", ".github/workflow.yml", + "public/index.html", "resources/missing", "root-mail.eml", "README.md", "netlify.toml", + "go.mod/extra", "theme.toml.extra", "content-extra/missing"] + command, calls = self.model(tracked=("\0".join(names + outside) + "\0").encode()) + with patch.object(sync, "fixed_command", side_effect=command): + sync.check_routes(self.result, self.repo, self.output) + preview = self.output / "private-preview-source" + self.assertEqual({path.relative_to(preview).as_posix() for path in preview.rglob("*") if path.is_file()}, + set(names) | set(self.result["files"])) + self.assertTrue(all((preview / name).read_bytes() == (self.repo / name).read_bytes() for name in names)) + + def test_unrelated_symlink_and_files_are_never_inspected(self): + unrelated = self.repo / "outputs" + unrelated.symlink_to(self.root / "missing-private-source", target_is_directory=True) + names = ["content/authors/Existing/_index.md", "outputs/node_modules", "root-mail.eml"] + command, calls = self.model(tracked=("\0".join(names) + "\0").encode()) + original_stat = Path.stat + def guarded_stat(path, *args, **kwargs): + if path == unrelated or unrelated in path.parents or path.name == "root-mail.eml": + raise AssertionError("Unrelated source must not be inspected") + return original_stat(path, *args, **kwargs) + with patch.object(sync, "fixed_command", side_effect=command), patch.object(Path, "stat", guarded_stat): + sync.check_routes(self.result, self.repo, self.output) + self.assertFalse((self.output / "private-preview-source/outputs").exists()) + + def test_symlink_inside_selected_source_is_rejected_before_build(self): + linked = self.repo / "assets/linked.txt" + linked.parent.mkdir() + linked.symlink_to(self.root / "missing-source") + command, calls = self.model(tracked=b"content/authors/Existing/_index.md\0assets/linked.txt\0") + with patch.object(sync, "fixed_command", side_effect=command), self.assertRaises(sync.Rejected) as error: + sync.check_routes(self.result, self.repo, self.output) + self.assertEqual(str(error.exception), "route-check-snapshot-invalid") + self.assertEqual(len(calls), 2) + + def test_selected_parent_symlink_is_rejected_before_build(self): + (self.repo / "assets").symlink_to(self.root, target_is_directory=True) + command, calls = self.model(tracked=b"content/authors/Existing/_index.md\0assets/linked.txt\0") + with patch.object(sync, "fixed_command", side_effect=command), self.assertRaises(sync.Rejected) as error: + sync.check_routes(self.result, self.repo, self.output) + self.assertEqual(str(error.exception), "path-symlink-rejected") + self.assertEqual(len(calls), 2) + + def test_unsafe_names_are_rejected_even_when_not_selected(self): + for name in ["../outputs/missing", "/outputs/missing", "outputs/../missing", "outputs/control\nname"]: + command, calls = self.model(tracked=(name + "\0").encode()) + with self.subTest(name=name), patch.object(sync, "fixed_command", side_effect=command), self.assertRaises(sync.Rejected) as error: + sync.check_routes(self.result, self.repo, self.output) + self.assertEqual(str(error.exception), "route-check-snapshot-invalid") + (self.output / "private-preview-source").rmdir() + + def test_byte_limit_applies_only_to_selected_source(self): + outside = self.repo / "outputs/large.bin" + outside.parent.mkdir() + outside.write_bytes(b"Synthetic excluded bytes" * 100) + tracked = b"content/authors/Existing/_index.md\0outputs/large.bin\0" + selected_size = (self.repo / "content/authors/Existing/_index.md").stat().st_size + command, calls = self.model(tracked=tracked) + with patch.object(sync, "fixed_command", side_effect=command), patch.object(sync, "MAX_BUILD_BYTES", selected_size): + sync.check_routes(self.result, self.repo, self.output) + second_output = self.root / "second-stage" + sync.stage(self.result, second_output) + command, calls = self.model(tracked=tracked) + with patch.object(sync, "fixed_command", side_effect=command), patch.object(sync, "MAX_BUILD_BYTES", selected_size - 1), self.assertRaises(sync.Rejected) as error: + sync.check_routes(self.result, self.repo, second_output) + self.assertEqual(str(error.exception), "route-check-snapshot-too-large") + self.assertEqual(len(calls), 2) + + def test_same_title_route_collision_is_fatal(self): + command, calls = self.model(collision=True) + with patch.object(sync, "fixed_command", side_effect=command), self.assertRaises(sync.Rejected): + sync.check_routes(self.result, self.repo, self.output) + + def test_wrong_hugo_version_is_withheld(self): + command, calls = self.model(version="0.153.0") + with patch.object(sync, "fixed_command", side_effect=command), self.assertRaises(sync.Rejected) as error: + sync.check_routes(self.result, self.repo, self.output) + self.assertEqual(str(error.exception), "route-check-hugo-version-invalid") + + def test_missing_new_author_output_is_withheld(self): + command, calls = self.model(no_new_page=True) + with patch.object(sync, "fixed_command", side_effect=command), self.assertRaises(sync.Rejected) as error: + sync.check_routes(self.result, self.repo, self.output) + self.assertEqual(str(error.exception), "route-check-new-author-page-not-unique") + + def test_build_subprocess_never_receives_tokens(self): + process = subprocess.CompletedProcess(["hugo", "version"], 0, b"hugo v0.152.2", b"") + with patch.dict(os.environ, {"GITHUB_TOKEN": "synthetic-secret", "GH_TOKEN": "synthetic-secret", "PATH": "/usr/bin"}), patch.object(sync.subprocess, "run", return_value=process) as run: + sync.fixed_command(["hugo", "version"], self.repo) + environment = run.call_args.kwargs["env"] + self.assertNotIn("GITHUB_TOKEN", environment) + self.assertNotIn("GH_TOKEN", environment) + + +class WorkflowTests(unittest.TestCase): + def test_default_off_protected_main_dispatch_and_pinned_actions(self): + workflow_path = HERE / "profile-sync.yml" + if not workflow_path.exists(): + workflow_path = HERE.parents[1] / ".github/workflows/profile-sync.yml" + workflow = workflow_path.read_text() + self.assertIn("vars.PROFILE_SYNC_ENABLED == 'true'", workflow) + self.assertIn("environment: website-profile-review", workflow) + self.assertIn("github.ref == 'refs/heads/main'", workflow) + self.assertIn("github.event.sender.login == 'maxdiluca'", workflow) + self.assertIn("cancel-in-progress: false", workflow) + self.assertIn("--publish", workflow) + self.assertIn("416bcfbdf5f68469ec9644dbe507da50fc21b94b69a125b059d64ed2cb4d8c27", workflow) + self.assertNotIn("upload-artifact", workflow) + + +if __name__ == "__main__": + unittest.main()