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()