Add Google mailbox acceptance evidence workflow
This commit is contained in:
parent
c5ace6b63f
commit
b29d63d413
@ -36,7 +36,7 @@ This is the working delivery tracker for GuestOps Web. Update a milestone when i
|
||||
| 9 | Gitea and reproducible releases | A | In progress | The reviewed candidate is promoted in the local `main` history. CI now records the full commit, matched application version, archive checksum and immutable image IDs, and the rollback procedure is documented. Push the merge, retain the successful release evidence off-host, and create a new immutable approval tag; the existing `0.1.0` tag remains attached to the original foundation release. |
|
||||
| 10 | Debian deployment and persistence | A | In progress | Compose uses separate named database and shared key volumes, private host configuration, loopback-only API access and bounded logs. The confirmation-gated persistence drill verifies restart and container-recreation behaviour. Run it on the provisioned Debian host, complete HTTPS and controlled-reboot acceptance, and retain the evidence. |
|
||||
| 11 | Backups, monitoring, and recovery | A | In progress | Encrypted backup and isolated restore tooling now includes opt-in systemd scheduling without command-line secrets. Install and test it on Debian, configure monitored off-host transfer and durable logs, name alert/retention owners, and retain evidence from a timed restore and recovery drill. |
|
||||
| 12 | Google mailbox and reviewed-reply acceptance | B | Planned | Complete OAuth verification, import/send acceptance, reconnect/revocation tests, identity-change handling, and duplicate/uncertain-send drills with a sandbox mailbox. |
|
||||
| 12 | Google mailbox and reviewed-reply acceptance | B | In progress | The synthetic-data provider runbook, exact scenario set and restricted-record validator are implemented. Complete every scenario against the accepted Debian release and dedicated Google sandbox accounts, independently review the evidence, and retain the validated record. |
|
||||
| 13 | Rezlynx/Guestline adapter | C | Planned | Obtain the provider contract and sandbox, implement the adapter and mapping, and accept idempotency, stale-data, ambiguous-write, and reconciliation paths. |
|
||||
| 14 | Payment links and status | C | Planned | Select/confirm the payment-provider path, complete sandbox and webhook acceptance, and prove expiry, replay protection, reconciliation, and support recovery. |
|
||||
| 15 | Knowledge, AI, and FAQ activation | B | Planned | Curate approved hotel knowledge, evaluate suggestion quality, set thresholds, train staff, and stage activation with monitoring and a kill switch. |
|
||||
|
||||
@ -9,7 +9,7 @@ The reviewed candidate is now promoted into the local `main` history. It is not
|
||||
- AI-assisted reply suggestions and staff-reviewed Gmail sending.
|
||||
- Approval-controlled OHIP PMS and NMI payment workflows.
|
||||
- FAQ automation controls, team invitations, password recovery, and stronger Google connection recovery.
|
||||
- Backup, restore, opt-in systemd scheduling, deployment, persistence-drill, diagnostic, release-evidence and rollback tooling.
|
||||
- Backup, restore, opt-in systemd scheduling, deployment, persistence-drill, diagnostic, release-evidence, Google acceptance-record validation and rollback tooling.
|
||||
|
||||
These capabilities still require their separately documented provider, host and operational acceptance. Google, PMS and payment-provider acceptance is not established by local automated tests.
|
||||
|
||||
|
||||
26
deploy/google-acceptance.example.json
Normal file
26
deploy/google-acceptance.example.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"acceptedAt": "2026-01-01T00:00:00Z",
|
||||
"acceptedBy": "REPLACE WITH APPROVER",
|
||||
"endedAt": "2026-01-01T00:00:00Z",
|
||||
"environment": "https://sandbox-guestops.futuresens.co.uk",
|
||||
"mailboxLabel": "sandbox mailbox A",
|
||||
"operator": "REPLACE WITH OPERATOR",
|
||||
"releaseCommit": "0000000000000000000000000000000000000000",
|
||||
"releaseRecordSha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"scenarios": [
|
||||
{"id": "oauth-readonly", "status": "not-run", "evidence": []},
|
||||
{"id": "initial-import", "status": "not-run", "evidence": []},
|
||||
{"id": "duplicate-import", "status": "not-run", "evidence": []},
|
||||
{"id": "same-account-reconnect", "status": "not-run", "evidence": []},
|
||||
{"id": "different-account-rejected", "status": "not-run", "evidence": []},
|
||||
{"id": "provider-revocation", "status": "not-run", "evidence": []},
|
||||
{"id": "reviewed-send", "status": "not-run", "evidence": []},
|
||||
{"id": "gmail-threading", "status": "not-run", "evidence": []},
|
||||
{"id": "duplicate-approval", "status": "not-run", "evidence": []},
|
||||
{"id": "uncertain-send-reconciliation", "status": "not-run", "evidence": []},
|
||||
{"id": "sending-stop-control", "status": "not-run", "evidence": []}
|
||||
],
|
||||
"schemaVersion": 1,
|
||||
"startedAt": "2026-01-01T00:00:00Z",
|
||||
"system": "google-mailbox"
|
||||
}
|
||||
98
deploy/google_acceptance.py
Normal file
98
deploy/google_acceptance.py
Normal file
@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate a restricted Google mailbox acceptance record without reading its evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
SCENARIOS = {
|
||||
"oauth-readonly",
|
||||
"initial-import",
|
||||
"duplicate-import",
|
||||
"same-account-reconnect",
|
||||
"different-account-rejected",
|
||||
"provider-revocation",
|
||||
"reviewed-send",
|
||||
"gmail-threading",
|
||||
"duplicate-approval",
|
||||
"uncertain-send-reconciliation",
|
||||
"sending-stop-control",
|
||||
}
|
||||
|
||||
|
||||
def require(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def utc_timestamp(value: object, field: str) -> dt.datetime:
|
||||
require(isinstance(value, str) and value.endswith("Z"), f"{field} must be a UTC timestamp ending in Z.")
|
||||
try:
|
||||
parsed = dt.datetime.fromisoformat(value.removesuffix("Z") + "+00:00")
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{field} is not a valid timestamp.") from error
|
||||
require(parsed.tzinfo == dt.timezone.utc, f"{field} must be UTC.")
|
||||
return parsed
|
||||
|
||||
|
||||
def validate(report: object) -> None:
|
||||
require(isinstance(report, dict), "Acceptance record must be a JSON object.")
|
||||
require(report.get("schemaVersion") == 1, "Unsupported acceptance record schema.")
|
||||
require(report.get("system") == "google-mailbox", "Acceptance record system must be google-mailbox.")
|
||||
require(re.fullmatch(r"[0-9a-f]{40}", str(report.get("releaseCommit", ""))) is not None,
|
||||
"releaseCommit must be a full lowercase Git SHA.")
|
||||
require(re.fullmatch(r"[0-9a-f]{64}", str(report.get("releaseRecordSha256", ""))) is not None,
|
||||
"releaseRecordSha256 must be a SHA-256 digest.")
|
||||
|
||||
environment = str(report.get("environment", ""))
|
||||
parsed_url = urlparse(environment)
|
||||
require(parsed_url.scheme == "https" and parsed_url.hostname and parsed_url.path in ("", "/") and not parsed_url.query and not parsed_url.fragment,
|
||||
"environment must be an HTTPS origin without credentials, path, query or fragment.")
|
||||
require(parsed_url.username is None and parsed_url.password is None, "environment must not contain credentials.")
|
||||
|
||||
mailbox_label = str(report.get("mailboxLabel", ""))
|
||||
require(3 <= len(mailbox_label) <= 80 and "@" not in mailbox_label,
|
||||
"mailboxLabel must be a short non-email alias; do not put mailbox addresses in the record.")
|
||||
require(2 <= len(str(report.get("operator", ""))) <= 120, "operator is required.")
|
||||
started = utc_timestamp(report.get("startedAt"), "startedAt")
|
||||
ended = utc_timestamp(report.get("endedAt"), "endedAt")
|
||||
accepted = utc_timestamp(report.get("acceptedAt"), "acceptedAt")
|
||||
require(started <= ended <= accepted, "Acceptance timestamps are out of order.")
|
||||
require(2 <= len(str(report.get("acceptedBy", ""))) <= 120, "acceptedBy is required.")
|
||||
|
||||
scenarios = report.get("scenarios")
|
||||
require(isinstance(scenarios, list), "scenarios must be a list.")
|
||||
ids = [item.get("id") for item in scenarios if isinstance(item, dict)]
|
||||
require(len(ids) == len(scenarios) and len(ids) == len(set(ids)), "Scenario IDs must be unique objects.")
|
||||
require(set(ids) == SCENARIOS, "Acceptance record does not contain the exact required scenario set.")
|
||||
for item in scenarios:
|
||||
scenario_id = item["id"]
|
||||
require(item.get("status") == "pass", f"Scenario {scenario_id} has not passed.")
|
||||
evidence = item.get("evidence")
|
||||
require(isinstance(evidence, list) and 1 <= len(evidence) <= 10,
|
||||
f"Scenario {scenario_id} requires one to ten restricted evidence references.")
|
||||
require(all(isinstance(value, str) and 3 <= len(value) <= 200 and "@" not in value for value in evidence),
|
||||
f"Scenario {scenario_id} has an invalid evidence reference; do not include email addresses or raw evidence.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("record", type=Path)
|
||||
args = parser.parse_args()
|
||||
report = json.loads(args.record.read_text(encoding="utf-8"))
|
||||
validate(report)
|
||||
print(f"Google acceptance record is structurally complete: {len(SCENARIOS)} scenarios passed. This validates the record, not the underlying provider evidence.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (OSError, json.JSONDecodeError, ValueError) as error:
|
||||
print(f"Google acceptance record rejected: {error}", file=__import__("sys").stderr)
|
||||
raise SystemExit(1)
|
||||
41
docs/google-acceptance.md
Normal file
41
docs/google-acceptance.md
Normal file
@ -0,0 +1,41 @@
|
||||
# Google mailbox acceptance
|
||||
|
||||
This runbook records real provider acceptance for one dedicated sandbox mailbox using synthetic messages only. It does not enable a production mailbox, FAQ automation, PMS writes or payment creation. Run it only after the exact release artifact has passed the Debian preflight and persistence drill.
|
||||
|
||||
Keep screenshots, Gmail message source, provider console records and request diagnostics in the restricted acceptance store. Do not commit mailbox addresses, authorization codes, refresh/access tokens, guest data, cookies, raw OAuth callbacks or raw evidence. The repository record contains only opaque evidence references.
|
||||
|
||||
## Preparation and stop conditions
|
||||
|
||||
Record the release commit, release-record SHA-256, image IDs, environment, operator, approver and maintenance window. Use two dedicated Google test accounts so the different-account rejection can be exercised without a personal account. Send only clearly synthetic messages between controlled recipients.
|
||||
|
||||
Start with AI drafts, staff sending, FAQ live mode, PMS writes and payment creation disabled. Confirm the OAuth redirect URI exactly matches the HTTPS sandbox. Stop immediately if a mailbox appears under the wrong hotel, a recipient differs from the review screen, an unapproved message is sent, a duplicate is observed, provider output exposes credentials, or an uncertain outcome is about to be blindly retried. Preserve evidence and follow the incident process.
|
||||
|
||||
## Required scenarios
|
||||
|
||||
| Record ID | Exercise | Passing evidence |
|
||||
| --- | --- | --- |
|
||||
| `oauth-readonly` | Connect sandbox mailbox A with sending disabled; inspect consent and saved health. | Only the expected read scope is granted, callback succeeds over HTTPS, mailbox identity is correct, and no credential appears in UI/log evidence. |
|
||||
| `initial-import` | Send several synthetic plain-text inbox messages before and during the seven-day window, including an automated/list message. | Eligible messages import with correct sender, subject and reply identity; excluded automated mail and out-of-window mail do not appear. |
|
||||
| `duplicate-import` | Restart the import pass twice and restart the worker during a paginated pass. | Each provider message appears once and the pass resumes without losing its window. |
|
||||
| `same-account-reconnect` | Disconnect locally, reconnect mailbox A and inspect retained conversations/drafts. | Mailbox identity is retained, connection identity rotates, history remains, and synchronization resumes without duplicates. |
|
||||
| `different-account-rejected` | Start reconnect for mailbox A but choose sandbox mailbox B. | The reconnect is rejected and mailbox A remains disconnected without B being attached to the hotel. |
|
||||
| `provider-revocation` | Remove the app grant in Google's account controls, allow the worker to observe it, then reconnect A. | Health reports reconnection required without repeated provider calls; an owner reconnect restores synchronization. |
|
||||
| `reviewed-send` | Enable server and hotel staff sending, reconnect for send consent, save a synthetic reply and approve its exact recipient/body. | One message appears in Gmail Sent and at the controlled recipient with the approved body, sender and stable message identity. |
|
||||
| `gmail-threading` | Inspect the reviewed send in both Gmail accounts. | Gmail places it in the intended thread and message source contains the expected reply headers without CC/BCC. |
|
||||
| `duplicate-approval` | Concurrently submit or repeat approval for the same imported message, then restart the worker. | Exactly one Gmail message exists; later approval/replay attempts are rejected or show the completed delivery. |
|
||||
| `uncertain-send-reconciliation` | Use an approved, reviewed provider-test method to create or use an uncertain result; never induce it against uncontrolled recipients. | The item stays held, is not automatically resent, and “Verify in Gmail Sent” marks it sent only when the stable identity, sender, recipient and SENT label uniquely match. If the environment cannot safely induce uncertainty, this scenario remains unpassed. |
|
||||
| `sending-stop-control` | Queue a reviewed synthetic reply, disable hotel sending before worker submission, and observe the result. | Nothing reaches Gmail and the item is rejected for staff action; re-enabling does not silently submit an old automatic approval. |
|
||||
|
||||
After each provider-side change, allow for the documented worker interval and capture timestamps in UTC. Treat Gmail acceptance as provider submission, not proof of final delivery; inspect the controlled recipient and any bounce separately.
|
||||
|
||||
## Record and approval
|
||||
|
||||
Copy `deploy/google-acceptance.example.json` to the restricted acceptance store outside the checkout. Replace all placeholders, change a scenario to `pass` only after reviewing its restricted evidence, and use opaque ticket or evidence IDs without email addresses. A different person should complete `acceptedBy` after checking all evidence and confirming the stop conditions did not occur.
|
||||
|
||||
Validate the completed record:
|
||||
|
||||
```sh
|
||||
python3 deploy/google_acceptance.py /secure/acceptance/google-mailbox-release.json
|
||||
```
|
||||
|
||||
The validator checks completeness, immutable release identifiers, ordered UTC timestamps, an HTTPS origin, all required passing scenarios and safe evidence references. It cannot inspect or prove the underlying evidence. Retain the record with the release and link its restricted location from the milestone tracker; do not mark milestone 12 accepted merely because the validator succeeds.
|
||||
@ -35,3 +35,5 @@ Existing mailbox documents without a Version field remain compatible. Their conn
|
||||
Automated fixtures cover pagination, duplicate imports, deleted messages, invalid grants, throttling, client configuration failures, reconnect account matching, missing read scope, cross-hotel ownership, stale workers, interrupted connections and queued-reply protection. MongoDB and HTTP tests exercise persistence, legacy documents, owner-only controls and preview isolation. These tests do not contact Google.
|
||||
|
||||
With a dedicated test mailbox on the HTTPS sandbox, verify consent and callback configuration, initial import, disconnect, manual removal of Google access, reconnect, read-only and send scopes, retained drafts, and staff review of rejected approvals. Keep FAQ live sending and other external writes off until their separate acceptance procedures pass. Full Gmail thread aggregation, history-repair tooling and attachments remain future work.
|
||||
|
||||
Use the [Google mailbox acceptance runbook](google-acceptance.md) and its validated restricted evidence record for the complete provider exercise. Fixture and record-validation tests do not replace that live acceptance.
|
||||
|
||||
67
tests/test_google_acceptance.py
Normal file
67
tests/test_google_acceptance.py
Normal file
@ -0,0 +1,67 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
spec = importlib.util.spec_from_file_location("google_acceptance", Path(__file__).resolve().parents[1] / "deploy" / "google_acceptance.py")
|
||||
acceptance = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(acceptance)
|
||||
|
||||
|
||||
def valid_report():
|
||||
return {
|
||||
"schemaVersion": 1,
|
||||
"system": "google-mailbox",
|
||||
"releaseCommit": "a" * 40,
|
||||
"releaseRecordSha256": "b" * 64,
|
||||
"environment": "https://sandbox-guestops.futuresens.co.uk",
|
||||
"mailboxLabel": "sandbox mailbox A",
|
||||
"operator": "Test operator",
|
||||
"startedAt": "2026-09-29T10:00:00Z",
|
||||
"endedAt": "2026-09-29T11:00:00Z",
|
||||
"acceptedAt": "2026-09-29T12:00:00Z",
|
||||
"acceptedBy": "Test approver",
|
||||
"scenarios": [
|
||||
{"id": scenario, "status": "pass", "evidence": [f"restricted-ticket-{index}"]}
|
||||
for index, scenario in enumerate(sorted(acceptance.SCENARIOS), 1)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class GoogleAcceptanceTests(unittest.TestCase):
|
||||
def test_complete_record_is_accepted(self):
|
||||
acceptance.validate(valid_report())
|
||||
|
||||
def test_missing_or_unpassed_scenario_is_rejected(self):
|
||||
report = valid_report()
|
||||
report["scenarios"].pop()
|
||||
with self.assertRaisesRegex(ValueError, "exact required scenario"):
|
||||
acceptance.validate(report)
|
||||
report = valid_report()
|
||||
report["scenarios"][0]["status"] = "not-run"
|
||||
with self.assertRaisesRegex(ValueError, "has not passed"):
|
||||
acceptance.validate(report)
|
||||
|
||||
def test_record_rejects_email_addresses_and_insecure_origin(self):
|
||||
report = valid_report()
|
||||
report["mailboxLabel"] = "real-address@example.invalid"
|
||||
with self.assertRaisesRegex(ValueError, "non-email alias"):
|
||||
acceptance.validate(report)
|
||||
report = valid_report()
|
||||
report["environment"] = "http://sandbox.example.invalid/path"
|
||||
with self.assertRaisesRegex(ValueError, "HTTPS origin"):
|
||||
acceptance.validate(report)
|
||||
|
||||
def test_record_rejects_bad_timestamps_and_evidence(self):
|
||||
report = valid_report()
|
||||
report["acceptedAt"] = "2026-09-29T09:00:00Z"
|
||||
with self.assertRaisesRegex(ValueError, "out of order"):
|
||||
acceptance.validate(report)
|
||||
report = valid_report()
|
||||
report["scenarios"][0]["evidence"] = ["guest@example.invalid"]
|
||||
with self.assertRaisesRegex(ValueError, "invalid evidence"):
|
||||
acceptance.validate(report)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
x
Reference in New Issue
Block a user