fix(auth): bind mailbox callbacks to pending popup
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
# Phase 7 — Commit and evidence ledger
|
||||
|
||||
Policy: commit each completed checkpoint or coherent repair and push normally to `codex/ui-redesign-phase4`, with direct remote=HEAD verification. The latest P7-07 interface is locally validated but publication is blocked by the configured Git host's connection failure; no current synchronization is claimed. Production main remains `27657f31131cd95d42f0e1290853ec9c9b3959a4`; no force push, main push or automatic deployment.
|
||||
Latest pending publication: [P7-09 separate OAuth sender repair](p7-09/OAUTH_SENDER.md), final39/3 frontend, native3+5+ordinaryP2-D01=9, four type/lint/build gates PASS. [Finite results](p7-09/evidence/oauth-results.json) retain both meaningful RED series, failed fixture attempts,23 held inputs, seven actual final image reviews and89 consumer axe incompletes. Bounded repair CONDITIONAL GO; final staged hygiene/commit/push pending. +16 frontend/0 suites and+3 browser derive807/95 and326; backend1054 unchanged, no integrated run. Actual commit/remote receipt follows observation. Both Settings presentation boundaries remain pending.
|
||||
|
||||
Policy: commit each completed checkpoint or coherent repair and push normally to `codex/ui-redesign-phase4`, with direct remote=HEAD verification. Latest completed P7-08 publication is `c5176e3251c869e13284b994b59b2f7aa9220eb1`, directly verified clean at 2026-10-17T02:03:53.815275 UTC. Earlier Git connection failures are historical and superseded by the recorded successful receipts. Production main remains `27657f31131cd95d42f0e1290853ec9c9b3959a4`; no force push, main push or automatic deployment.
|
||||
|
||||
Entry is the completed Phase6 successor receipt `b5319981ad3d1bd635daff861453d23c0de5320b`. Its [historical ledger](../phase-6/COMMITS.md) retains P6-05 `4669fb42e604c4728e8c420846ba78f298a8d90c` and original runtime provenance. At P7-00 entry, application source was unchanged from P6-03 `bd47214bdcd74336788dca0f294b8ddb9403cac1`; inherited measured export `2LDblja_XnSBWSuY6ys_V`. Later migration rows record their own changed source/build identity.
|
||||
|
||||
@@ -131,3 +133,8 @@ Recent-notification presentation committed and normally pushed **40cfe04df51654c
|
||||
[Operations report](p7-08/OPERATIONS.md) and [portable results](p7-08/evidence/operations-results.json) record fresh **77/7 frontend cases**, app/browser TypeScript, lint and build PASS on **YBJ6UzKUv_ZeDjZzgO3Gh**. Actual Operations5 + Notifications6 + ordinaryP2-D01 = **12/12 native PASS**; no skip/flaky/retry. All25 finite inputs held through2026-10-17T01:59:10.798235UTC; API/testDLLunchanged. All14 Operations PNGs plus one frozen-package PNG actually reviewed (15/31 final retained images); notification16 remain retained with their earlier review distinguished. Operations12 all-enabled axe scans0violations/3incompletes and notification14scans0/102 are contextually triaged, without human/device/zoom/font acceptance. Peer fixes remove the task UUID from its accessible name and restore focus only when an accepted poll removes the focused control. All ordinary REDs, candidate evidence and the test-only typing failure remain retained.
|
||||
|
||||
Eighteen new Operations cases derive **791/95 frontend**; five browser cases derive **323**. Backend stays **1054**, with last whole frontend573/89, backend1033 and sidecar49 inherited untilP7-12. BothP7-08 presentation boundaries are implemented **CONDITIONAL GO**, with Operations final staged hygiene/normal push pending. Recent40cfe04 is already pushed and directly verified. All57groups56PARTIAL/C11DEFERRED and33routes remain; F20/RD11/RD12/RD13/RD21/manual/provider/output/release debt stays. P513withheld/followModedeferred/mainunchanged/productionNO-GO/Phase8notstarted. P7-09 preferences/Connections then data/privacy/plan/backup follows the approved order.
|
||||
|
||||
|
||||
### P7-08 publication and P7-09 entry
|
||||
|
||||
Operations **c5176e3251c869e13284b994b59b2f7aa9220eb1** is normally pushed and directly verified **2026-10-17T02:03:53.815275UTC**: clean HEAD=origin=live development, local/origin/live main27657f3 unchanged. Both P7-08 UI boundaries remain implemented **CONDITIONAL GO and published**; manual/provider/output/release conditions remain. Earlier pending-publication text is superseded by this exact receipt in Operations results. P7-09 follows its approved two boundaries; a separately bounded OAuth sender repair first protects its two existing callback consumers. Fresh entry23/3 frontend and40 unchanged-binary backend cases pass;791/95,323,1054 remain derived inventories, not full-suite passes. No production or Phase8 action.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Phase 7 — Retained debt and mandatory gates
|
||||
|
||||
P7-09's [sender repair](p7-09/evidence/oauth-results.json) confirms/fixes uncorrelated OAuth messages and delayed startup-language feedback. Consumer regression retains89 axe incompletes, including46 roleless-span `aria-label` diagnostics (45 Notes/Tasks,1 Interview) for bounded P7-11 responsible-owner triage. No existing manual/provider/release gate is closed; the Settings presentation findings remain pending.
|
||||
|
||||
P7-07's [final local UI evidence](p7-07/evidence/ui-results.json) adds no whole-group or production acceptance. All RD01–RD28 dispositions remain. Its 15 axe scans retain 101 incomplete nodes: 15 short notification badges, 85 overlapped form backgrounds and one partially obscured Dashboard label inside the navigation scroll container. These require the existing manual contrast/state qualifications; zero automated violations does not close them.
|
||||
|
||||
Two bounded P7-11 residuals are recorded from actual images: a long English action wraps its final letter at 1024px, and the unchanged CV missing-source owner displays raw `Not Found` error text. The Documents attachment-error layout was corrected and reverified. The Git host became unreachable after verified contract push `8e4db12`; later fetch failures are recorded, normal UI push remains pending. Actual MariaDB, human/device/provider/output and production gates remain open.
|
||||
Two bounded P7-11 residuals are recorded from actual images: a long English action wraps its final letter at 1024px, and the unchanged CV missing-source owner displays raw `Not Found` error text. The Documents attachment-error layout was corrected and reverified. Historical Git connection failures were superseded by successful normal pushes, most recently P7-08 `c5176e3`, directly verified clean at 2026-10-17T02:03:53.815275 UTC. Actual MariaDB, human/device/provider/output and production gates remain open.
|
||||
|
||||
This is the current entry disposition of every RD01–RD28 obligation in the [Phase5 debt register](../phase-5/p5-15/READINESS_DEBT.md), reconciled with [successor dispositions](../phase-5/p5-15/p5-15-04/DEBT_DISPOSITION.md), [Phase6 final report](../phase-6/p6-05/README.md) and [exact handoff](../phase-6/p6-05/PHASE7_HANDOFF.md). Historical records stay intact. This table updates scoped outcomes rather than copying obsolete source descriptions as current facts.
|
||||
|
||||
@@ -221,3 +223,8 @@ Recent-notification presentation committed and normally pushed **40cfe04df51654c
|
||||
[Operations report](p7-08/OPERATIONS.md) and [portable results](p7-08/evidence/operations-results.json) record fresh **77/7 frontend cases**, app/browser TypeScript, lint and build PASS on **YBJ6UzKUv_ZeDjZzgO3Gh**. Actual Operations5 + Notifications6 + ordinaryP2-D01 = **12/12 native PASS**; no skip/flaky/retry. All25 finite inputs held through2026-10-17T01:59:10.798235UTC; API/testDLLunchanged. All14 Operations PNGs plus one frozen-package PNG actually reviewed (15/31 final retained images); notification16 remain retained with their earlier review distinguished. Operations12 all-enabled axe scans0violations/3incompletes and notification14scans0/102 are contextually triaged, without human/device/zoom/font acceptance. Peer fixes remove the task UUID from its accessible name and restore focus only when an accepted poll removes the focused control. All ordinary REDs, candidate evidence and the test-only typing failure remain retained.
|
||||
|
||||
Eighteen new Operations cases derive **791/95 frontend**; five browser cases derive **323**. Backend stays **1054**, with last whole frontend573/89, backend1033 and sidecar49 inherited untilP7-12. BothP7-08 presentation boundaries are implemented **CONDITIONAL GO**, with Operations final staged hygiene/normal push pending. Recent40cfe04 is already pushed and directly verified. All57groups56PARTIAL/C11DEFERRED and33routes remain; F20/RD11/RD12/RD13/RD21/manual/provider/output/release debt stays. P513withheld/followModedeferred/mainunchanged/productionNO-GO/Phase8notstarted. P7-09 preferences/Connections then data/privacy/plan/backup follows the approved order.
|
||||
|
||||
|
||||
### P7-08 publication and P7-09 entry
|
||||
|
||||
Operations **c5176e3251c869e13284b994b59b2f7aa9220eb1** is normally pushed and directly verified **2026-10-17T02:03:53.815275UTC**: clean HEAD=origin=live development, local/origin/live main27657f3 unchanged. Both P7-08 UI boundaries remain implemented **CONDITIONAL GO and published**; manual/provider/output/release conditions remain. Earlier pending-publication text is superseded by this exact receipt in Operations results. P7-09 follows its approved two boundaries; a separately bounded OAuth sender repair first protects its two existing callback consumers. Fresh entry23/3 frontend and40 unchanged-binary backend cases pass;791/95,323,1054 remain derived inventories, not full-suite passes. No production or Phase8 action.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Phase 7 — Initial preservation matrix
|
||||
|
||||
Latest P7-07 UI reconciliation: [portable results](p7-07/evidence/ui-results.json) retain all 57 statuses below, with 56 PARTIAL and C11 DEFERRED. The original 32 routes remain alongside the approved `/documents` route (33 current patterns). The final affected 86 component cases and 13 native consumer/journey cases pass; ordinary P2-D01 is included. Human/device/output and whole-group qualifications remain. Publication is pending the configured Git host's recovery; cached remote refs are not live verification.
|
||||
P7-09's [separate sender repair](p7-09/evidence/oauth-results.json) preserves M01/M02/M06/E09 and all57 statuses:39 affected caller cases,3 new local-sender journeys,5 existing Application journeys and ordinary P2-D01 pass. No real provider grant, new permission, route, data or document-output acceptance is claimed. Preferences and data lifecycle remain pending.
|
||||
|
||||
Latest completed P7-08 reconciliation: [Operations results](p7-08/evidence/operations-results.json) retain all 57 statuses below, with 56 PARTIAL and C11 DEFERRED. The original 32 routes remain alongside the approved `/documents` route (33 current patterns). Fresh affected77/7 and native12 pass, including ordinary P2-D01. Human/device/output and whole-group qualifications remain. P7-08 publication `c5176e3251c869e13284b994b59b2f7aa9220eb1` was directly verified clean at 2026-10-17T02:03:53.815275 UTC; older connection failures remain historical evidence.
|
||||
|
||||
All **57 original protected groups** remain in scope. This initial matrix derives the exact IDs/statuses and original evidence/limitations from [Phase6 preservation](../phase-6/p6-05/PRESERVATION.md), reconciled against its [aggregate](../phase-6/p6-05/evidence/validation.json), [original functionality ledger](../audit/FUNCTIONALITY_INVENTORY.md), [Phase2 anchors](../phase-2/PRESERVATION_MATRIX.md) and [Phase3 impacts](../phase-3/PRESERVATION_IMPACT_MATRIX.md). The short rows never replace protected sub-capabilities.
|
||||
|
||||
@@ -229,3 +231,8 @@ Recent-notification presentation committed and normally pushed **40cfe04df51654c
|
||||
[Operations report](p7-08/OPERATIONS.md) and [portable results](p7-08/evidence/operations-results.json) record fresh **77/7 frontend cases**, app/browser TypeScript, lint and build PASS on **YBJ6UzKUv_ZeDjZzgO3Gh**. Actual Operations5 + Notifications6 + ordinaryP2-D01 = **12/12 native PASS**; no skip/flaky/retry. All25 finite inputs held through2026-10-17T01:59:10.798235UTC; API/testDLLunchanged. All14 Operations PNGs plus one frozen-package PNG actually reviewed (15/31 final retained images); notification16 remain retained with their earlier review distinguished. Operations12 all-enabled axe scans0violations/3incompletes and notification14scans0/102 are contextually triaged, without human/device/zoom/font acceptance. Peer fixes remove the task UUID from its accessible name and restore focus only when an accepted poll removes the focused control. All ordinary REDs, candidate evidence and the test-only typing failure remain retained.
|
||||
|
||||
Eighteen new Operations cases derive **791/95 frontend**; five browser cases derive **323**. Backend stays **1054**, with last whole frontend573/89, backend1033 and sidecar49 inherited untilP7-12. BothP7-08 presentation boundaries are implemented **CONDITIONAL GO**, with Operations final staged hygiene/normal push pending. Recent40cfe04 is already pushed and directly verified. All57groups56PARTIAL/C11DEFERRED and33routes remain; F20/RD11/RD12/RD13/RD21/manual/provider/output/release debt stays. P513withheld/followModedeferred/mainunchanged/productionNO-GO/Phase8notstarted. P7-09 preferences/Connections then data/privacy/plan/backup follows the approved order.
|
||||
|
||||
|
||||
### P7-08 publication and P7-09 entry
|
||||
|
||||
Operations **c5176e3251c869e13284b994b59b2f7aa9220eb1** is normally pushed and directly verified **2026-10-17T02:03:53.815275UTC**: clean HEAD=origin=live development, local/origin/live main27657f3 unchanged. Both P7-08 UI boundaries remain implemented **CONDITIONAL GO and published**; manual/provider/output/release conditions remain. Earlier pending-publication text is superseded by this exact receipt in Operations results. P7-09 follows its approved two boundaries; a separately bounded OAuth sender repair first protects its two existing callback consumers. Fresh entry23/3 frontend and40 unchanged-binary backend cases pass;791/95,323,1054 remain derived inventories, not full-suite passes. No production or Phase8 action.
|
||||
|
||||
@@ -17,8 +17,8 @@ The entry commit is `b5319981ad3d1bd635daff861453d23c0de5320b` on `codex/ui-rede
|
||||
| [P7-06](p7-06/README.md) | Remaining Application forms and global correspondence | All three boundaries implemented CONDITIONAL GO: forms4c325/inboxa791 normally pushed; [compose](p7-06/COMPOSE.md) Compose affected52/5 and fresh backend25 PASS; native10+inbox8+ordinaryP2-D01=19 PASS on LFCrTZzDtX5hNBSRBj5Xx; app/browser TypeScript, lint and build PASS. Actual image review 47/47; all-enabled44scans0violations/330incompletes triaged, human/device/provider qualifications remain. +10front/+0suites,+10browser,+7backend derive739/94,301,1041 only; no integrated full-suite claim. Hygiene PASS and normal223dcf5 push directly verified11:03:15UTC;06Fdeferred/57groups32routes/main unchanged; P7-07 contract first next |
|
||||
| P7-06F | Deferred `followMode` reviewed composer | Optional separately reviewed mail slice; **DEFERRED** until mode→draft/review action and fallback are explicitly decided |
|
||||
| [P7-07](p7-07/README.md) | Documents owner-scoped index | Implemented CONDITIONAL GO; contract/UI published and directly verified through9dfea12 at21:01:11UTC. Final affected86/8, native8+CV4+ordinaryP2-D01/types/lint/build PASS;18 reviewed PNGs,15axe0/101 qualified. Original32+Documents=33routes; actual MariaDB/human/device/production qualifications remain |
|
||||
| [P7-08](p7-08/README.md) | Existing notifications and operations | Both presentation boundaries implemented CONDITIONAL GO. UTC9dfea/recent40cfe04 published; Operations77/7+native12/types/lint/build PASS,14Ops+1P2images reviewed,12Opsaxe0/3 and14notifaxe0/102 qualified. Final Operations hygiene/normal push pending;57groups33routes/F20 preserved. P7-09 next |
|
||||
| P7-09 | Remaining Settings, Connections and data lifecycle | Existing independent save scopes and sensitive-action guards; planned |
|
||||
| [P7-08](p7-08/README.md) | Existing notifications and operations | Implemented CONDITIONAL GO and published: UTC9dfea/recent40cfe04/Operationsc5176e3, clean direct refs verified02:03:53UTC. Ops77/7+native12/types/lint/build/14Ops+1P2image reviews PASS;12Opsaxe0/3+14notifaxe0/102 qualified.57groups33routes/F20 preserved |
|
||||
| [P7-09](p7-09/README.md) | Remaining Settings, Connections and data lifecycle | Separate sender repair final39/3+native9+types/lint/build PASS; final publication pending. Preferences/Connections then data/privacy/plan/backup follow. Existing Rules/save owners and all withholding/manual/release qualifications retained |
|
||||
| P7-10 | Admin operational presentation | Existing distinct Users/Audit/System routes and server authorization; planned |
|
||||
| P7-11 | Remaining Analytics/error/empty/dialog integration | Inventory-confirmed residual presentation only; existing reports remain; planned |
|
||||
| P7-12 | Integrated full-migration acceptance | Every executed slice pushed with finite evidence; fresh applicable full regression and all debt dispositions; planned |
|
||||
@@ -107,3 +107,8 @@ Recent-notification presentation committed and normally pushed **40cfe04df51654c
|
||||
[Operations report](p7-08/OPERATIONS.md) and [portable results](p7-08/evidence/operations-results.json) record fresh **77/7 frontend cases**, app/browser TypeScript, lint and build PASS on **YBJ6UzKUv_ZeDjZzgO3Gh**. Actual Operations5 + Notifications6 + ordinaryP2-D01 = **12/12 native PASS**; no skip/flaky/retry. All25 finite inputs held through2026-10-17T01:59:10.798235UTC; API/testDLLunchanged. All14 Operations PNGs plus one frozen-package PNG actually reviewed (15/31 final retained images); notification16 remain retained with their earlier review distinguished. Operations12 all-enabled axe scans0violations/3incompletes and notification14scans0/102 are contextually triaged, without human/device/zoom/font acceptance. Peer fixes remove the task UUID from its accessible name and restore focus only when an accepted poll removes the focused control. All ordinary REDs, candidate evidence and the test-only typing failure remain retained.
|
||||
|
||||
Eighteen new Operations cases derive **791/95 frontend**; five browser cases derive **323**. Backend stays **1054**, with last whole frontend573/89, backend1033 and sidecar49 inherited untilP7-12. BothP7-08 presentation boundaries are implemented **CONDITIONAL GO**, with Operations final staged hygiene/normal push pending. Recent40cfe04 is already pushed and directly verified. All57groups56PARTIAL/C11DEFERRED and33routes remain; F20/RD11/RD12/RD13/RD21/manual/provider/output/release debt stays. P513withheld/followModedeferred/mainunchanged/productionNO-GO/Phase8notstarted. P7-09 preferences/Connections then data/privacy/plan/backup follows the approved order.
|
||||
|
||||
|
||||
### P7-08 publication and P7-09 entry
|
||||
|
||||
Operations **c5176e3251c869e13284b994b59b2f7aa9220eb1** is normally pushed and directly verified **2026-10-17T02:03:53.815275UTC**: clean HEAD=origin=live development, local/origin/live main27657f3 unchanged. Both P7-08 UI boundaries remain implemented **CONDITIONAL GO and published**; manual/provider/output/release conditions remain. Earlier pending-publication text is superseded by this exact receipt in Operations results. P7-09 follows its approved two boundaries; a separately bounded OAuth sender repair first protects its two existing callback consumers. Fresh entry23/3 frontend and40 unchanged-binary backend cases pass;791/95,323,1054 remain derived inventories, not full-suite passes. No production or Phase8 action.
|
||||
|
||||
@@ -85,3 +85,8 @@ Operations implemented scope is **CONDITIONAL GO**, pending final staged hygiene
|
||||
All 57 groups remain **56 PARTIAL / C11 DEFERRED**, with 33 current route patterns. F20 and existing reliability debt, human screen-reader, real zoom, physical touch/software keyboard, production font/photo/print, actual MariaDB/provider execution and PDF-corpus qualifications remain open. P5-13 is withheld with zero qualification calls; followMode is deferred. Production is **NO-GO**, main is unchanged and Phase 8 implementation has not started. Settings follows this boundary in the approved sequence.
|
||||
|
||||
Final staged hygiene passes **2026-10-17T02:03:25.900686UTC**: four secret/whitespace gates, 23 documents/568 local links, 57 groups and 33 routes. All 25 staged canonical-LF hashes match final as-run sources. Operations commit and normal development push follow; no production action.
|
||||
|
||||
|
||||
### P7-08 publication and P7-09 entry
|
||||
|
||||
Operations **c5176e3251c869e13284b994b59b2f7aa9220eb1** is normally pushed and directly verified **2026-10-17T02:03:53.815275UTC**: clean HEAD=origin=live development, local/origin/live main27657f3 unchanged. Both P7-08 UI boundaries remain implemented **CONDITIONAL GO and published**; manual/provider/output/release conditions remain. Earlier pending-publication text is superseded by this exact receipt in Operations results. P7-09 follows its approved two boundaries; a separately bounded OAuth sender repair first protects its two existing callback consumers. Fresh entry23/3 frontend and40 unchanged-binary backend cases pass;791/95,323,1054 remain derived inventories, not full-suite passes. No production or Phase8 action.
|
||||
|
||||
@@ -100,3 +100,8 @@ Fresh **2026-10-17 UTC** evidence in the [portable Operations results](evidence/
|
||||
All **14 final Operations PNGs are actually reviewed** by root/peer, plus root's final P2-D01 image: **15 of 31 final images reviewed**. The 16 notification-consumer images are retained without a fresh review claim. Operations axe records **12 scans, 0 violations / 3 incomplete occurrences** (two short badge-text and one overlapping-background language-button contrast context); notification consumers independently record **14 scans, 0 / 102**, all retained and qualified. These finite checks establish no human screen-reader, physical-device, precise incomplete-contrast or production acceptance. The preliminary [static-check receipt](evidence/operations-static-checks.json) passes at **02:02:20.371383 UTC**: 23 documents, 566 local links, 57 groups, 33 routes and four secret/whitespace commands exiting 0. Final staged hygiene/canonical comparison, commit and verified normal push remain pending.
|
||||
|
||||
The 21 Operations cases add **18** over its three-case baseline without a new suite, deriving frontend **791/95** from 773/95. Five new browser cases derive **323** from 318; backend remains **1,054**. These are inventories, not fresh integrated counts or proof of publication; full frontend **573/89**, backend **1,033** and sidecar **49** remain inherited until P7-12. All 57 preservation statuses and 33 routes remain unchanged. **Both P7-08 presentation boundaries: implemented CONDITIONAL GO, with Operations staged hygiene/commit/publication pending.** Settings remains the next approved checkpoint, and Phase 8 remains separate.
|
||||
|
||||
|
||||
### P7-08 publication and P7-09 entry
|
||||
|
||||
Operations **c5176e3251c869e13284b994b59b2f7aa9220eb1** is normally pushed and directly verified **2026-10-17T02:03:53.815275UTC**: clean HEAD=origin=live development, local/origin/live main27657f3 unchanged. Both P7-08 UI boundaries remain implemented **CONDITIONAL GO and published**; manual/provider/output/release conditions remain. Earlier pending-publication text is superseded by this exact receipt in Operations results. P7-09 follows its approved two boundaries; a separately bounded OAuth sender repair first protects its two existing callback consumers. Fresh entry23/3 frontend and40 unchanged-binary backend cases pass;791/95,323,1054 remain derived inventories, not full-suite passes. No production or Phase8 action.
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"slice": "Operations page",
|
||||
"recordedUtc": "2026-10-17T02:00:33.397472+00:00",
|
||||
"decisions": {
|
||||
"implementedScope": "CONDITIONAL GO; final runtime/visual/peer/staged hygiene PASS; normal push pending",
|
||||
"wholeCheckpoint": "Both presentation boundaries implemented CONDITIONAL GO; final Operations normal push pending",
|
||||
"implementedScope": "CONDITIONAL GO; final runtime/visual/peer/staged hygiene PASS, normally pushed and directly verified",
|
||||
"wholeCheckpoint": "Both presentation boundaries implemented CONDITIONAL GO and published; manual/release qualifications remain",
|
||||
"production": "NO-GO",
|
||||
"phase8": "NOT STARTED"
|
||||
},
|
||||
@@ -4562,7 +4562,6 @@
|
||||
"qualification": "Existing routes,50-item limits,15s polling,serverCanCancel/CanRetry,existing identities and owners preserved. No schema/API/handler/producer/provider/model/renderer changes."
|
||||
},
|
||||
"remaining": [
|
||||
"Final staged hygiene and normal development push/direct verification",
|
||||
"P7-09 throughP7-12",
|
||||
"Human/device/output/provider qualifications and existing reliability debt",
|
||||
"F20,RD11,RD12,RD13,RD21 and historical scaling/unsaved-state debt remain",
|
||||
@@ -4576,5 +4575,34 @@
|
||||
"sha256": "e02d443b53a7b03e77596482db8d5ea15a942cf9e0bddb118c26826786f2fc9c",
|
||||
"inputs": 25,
|
||||
"canonicalLfMatch": true
|
||||
},
|
||||
"pushReceipt": {
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"push",
|
||||
"origin",
|
||||
"HEAD:refs/heads/codex/ui-redesign-phase4"
|
||||
],
|
||||
"startedUtc": "2026-10-17T02:03:51.750936+00:00",
|
||||
"finishedUtc": "2026-10-17T02:03:53.590804+00:00",
|
||||
"exitCode": 0,
|
||||
"head": "c5176e3251c869e13284b994b59b2f7aa9220eb1",
|
||||
"log": ".artifacts/phase7/p7-08/operations-push-receipt.log",
|
||||
"logSha256": "a8093fb40b1c0f99606eb05af96104e9ff31abc16b35b7d45e7c020e583c8687",
|
||||
"observedUtc": "2026-10-17T02:03:53.815275+00:00",
|
||||
"originDevelopment": "c5176e3251c869e13284b994b59b2f7aa9220eb1",
|
||||
"liveDevelopment": "c5176e3251c869e13284b994b59b2f7aa9220eb1",
|
||||
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"liveMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"pendingPaths": [],
|
||||
"workingTreeClean": true,
|
||||
"raw": {
|
||||
"path": ".artifacts/phase7/p7-08/operations-push-receipt.json",
|
||||
"bytes": 861,
|
||||
"sha256": "83ba5d7a669cb47a4b3f71534dc3bbe4db1ea68269db0f911cb0756512b4380c"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# P7-09 — OAuth callback sender repair
|
||||
|
||||
This independently bounded prerequisite protects both existing OAuth-message consumers before [Connections presentation](README.md). It changes client callback admission only; existing provider credentials, server state exchange, owner checks and routes remain authoritative.
|
||||
|
||||
## Confirmed contract and finding
|
||||
|
||||
`EmailProviderConnections` handles Gmail/Microsoft in `/settings/connected-accounts`; `Correspondence` handles Gmail in Job → Timeline. Both currently accept matching payload discriminators without checking `MessageEvent.origin` or its source window. A forged success can cause status reads/toasts and, in correspondence, candidate reads. The existing three Connections and six correspondence-import cases do not exercise popup correlation.
|
||||
|
||||
The expected callback origin comes from **the authenticated connect response's `redirect_uri`**, which the controllers build from `App:PublicBaseUrl`. It is not necessarily the Axios API origin or the Google/Microsoft authorization host. Documented development uses public/UI `localhost:3000` and API `localhost:5202`; production proxies `/api` on the public origin. Native UI3300/API5302 with default public origin3000 does not qualify real OAuth.
|
||||
|
||||
## Bounded repair and evidence requirements
|
||||
|
||||
Validate the callback HTTP(S) origin, absence of URL credentials and exact provider callback path. Bind each pending provider flow to the actual returned popup window, origin and provider discriminator. Accept only supported terminal `connected` / `error` payloads and a valid optional message; consume once before toast/read effects. Ignore unsolicited/wrong-origin/wrong-window/null-source/malformed/duplicate/disposed callbacks. Keep pending correlation through locale changes, invalidate stale job context and late unmounted connect responses, and prevent overlapping starts against a reused named popup.
|
||||
|
||||
The callback posts its message and immediately closes; receiving it must **not** require `popup.closed === false`. Preserve existing popup names/features, backend one-use owner-bound state and its 15-minute expiry. No frontend nonce, server protocol change, new route or provider activation belongs here.
|
||||
|
||||
Add ordinary rendered-caller REDs before the fix, preserving all nine existing consumer cases. Prove valid Gmail/Microsoft/error paths, strict sender/payload/duplicate rejection, canonical-origin compatibility, blocked popup, disposal/job context and current-language feedback. Real local child-window fixtures can verify browser origin/source behavior, but controlled connect URLs/HTML/messages are **client-guard evidence**, not a real provider connection. Canonical status must remain server-owned; no grant or connected status is fabricated.
|
||||
|
||||
## Executed repair history
|
||||
|
||||
The fresh entry is frontend **23/3** and unchanged-binary backend **40/40 PASS**, as recorded by the checkpoint. The first ordinary caller regression run is **23 passing / 12 failing**, followed by **35/3 PASS** after the sender repair. No missing helper, compilation failure, skip or expected failure is used as security regression proof. The helper validates the authenticated response's callback URL and both callers bind messages to the pending provider, callback origin and captured popup proxy. Terminal messages consume that pending attempt once; disposal and changed job context invalidate it.
|
||||
|
||||
| Attempt | Actual result and disposition |
|
||||
|---|---|
|
||||
| First browser TypeScript gate | Three required-window-property cast errors; fixed only the fixture's window type intersection. No build or native execution occurred in that failed batch. Raw capsule `.artifacts/phase7/p7-09/oauth-browser-types-failed/results.json` |
|
||||
| First built native attempt | **2 PASS / 1 FAIL**: the initial Microsoft failure alert locator also selected Next's empty route announcer. Corrected only the locator; actual HTTP500 and all sender assertions retained. Raw capsule `.artifacts/phase7/p7-09/oauth-native-attempt1/results.json` |
|
||||
| Corrected native attempt | **2 PASS / 1 FAIL**: held connect GET, actual account-language PUT204 to Bokmål, then blocked popup still produced the starting English fallback. This is a demonstrated product defect. Raw capsule `.artifacts/phase7/p7-09/oauth-native-startup-red/results.json` |
|
||||
|
||||
These native attempts used build **`7a02hqPN6YYeJA93z8hZP`**. Their two passing cases prove actual local child-window origin/proxy isolation and stale-job callback rejection; the third failed before completing its remaining assertions. No three-case acceptance is inferred from those attempts.
|
||||
|
||||
## Final bounded result
|
||||
|
||||
The startup-language reproducer is **35 PASS / 4 FAIL**, followed by **39/3 PASS** with a current-translation reference used only after delayed startup resolution/rejection. Fresh parent **39/3 PASS**, app/browser TypeScript, lint and production build all pass. Final build **`j_uwah2_BImymFemE3cLg`** executes sender **3/3**, existing Application sections **5/5** and ordinary **P2-D01 1/1**, with zero skips, expected failures or flaky outcomes. The independent read-only review found no actionable introduced defect. The [portable result](evidence/oauth-results.json) identifies every raw command, failed attempt and frozen source/runtime capsule.
|
||||
|
||||
All **23** finite inputs match between **02:20:39.070048** and **08:24:50.986374 UTC**. Final commands executed **02:20–02:23 UTC**; the later collection resumed after the conversation interruption and is not a new runtime run. API/test DLL hashes match the existing backend40-case entry; no backend rebuild or test addition is inferred.
|
||||
|
||||
Seven of76 retained final PNGs were actually displayed and reviewed, covering Connections and representative EN/NB/light/dark/mobile/768px consumers. The P2-D01 image catches an existing dialog transition; its successful archive/readback test is not a settled visual or PDF fidelity claim. Eight consumer axe scans retain **0 violations / 89 incompletes**: eight short badges,15 overlapped form backgrounds,20 partly obscured labels and46 `aria-label` attributes on roleless spans. Full contexts remain raw; the46 span diagnostics are carried to the responsible P7-11 owners. No new sender axe scan or human/device/zoom acceptance is claimed.
|
||||
|
||||
The **16 new ordinary frontend cases / zero suites** derive **807/95**; three new browser cases derive **326**; backend stays **1054**. These are inventories, not fresh integrated passes. All57 groups stay56 PARTIAL/C11 DEFERRED and33 routes remain. Final [staged hygiene](evidence/oauth-static-checks.json) **PASS** at08:27:17 UTC: secret and all whitespace gates,25 docs/583 links,57 groups/33 routes. Normal commit/publication follows this gate. Bounded repair: **CONDITIONAL GO**; both P7-09 presentation boundaries remain pending.
|
||||
|
||||
Actual OAuth grants, provider interoperability and production qualification remain open. Local callback HTML and connect URLs are explicitly controlled client-guard fixtures, and canonical server status remains disconnected. No mailbox, inference, send or grant is contacted. P5-13/worker/model withholding and production **NO-GO** remain unchanged.
|
||||
@@ -0,0 +1,34 @@
|
||||
# P7-09 — Settings, Connections and data lifecycle
|
||||
|
||||
Follow the exact [Phase 6 handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md#migration-order-and-commit-boundaries) and [implementation plan](../../IMPLEMENTATION_PLAN.md). P7-08 is implemented **CONDITIONAL GO** and normally pushed as **`c5176e3251c869e13284b994b59b2f7aa9220eb1`**. Direct verification at **2026-10-17T02:03:53.815275 UTC** proves clean HEAD = origin = live development; local/origin/live main remains **`27657f31131cd95d42f0e1290853ec9c9b3959a4`**. The exact receipt is `.artifacts/phase7/p7-08/operations-push-receipt.json`.
|
||||
|
||||
## Approved boundaries and preservation
|
||||
|
||||
| Boundary | Existing owners / required preservation |
|
||||
|---|---|
|
||||
| Preferences and Connections | Existing Settings tabs/mobile selector/panel associations, parent Rules draft session, local theme/table preferences, account language owner, notification preference, Profile/security links and mailbox-provider owners |
|
||||
| Data/privacy/plan/backup | Existing independent General subcards, privacy, usage/entitlement, import/export and backup/deletion owners; preserve formats, counts, recent-auth and server availability/admission |
|
||||
|
||||
An independently reviewable [OAuth sender repair](OAUTH_SENDER.md) precedes presentation because two live callback listeners accept uncorrelated messages. It hardens the existing client handshake; it does not add a phase, provider, backend protocol, grant or route. Preferences/Connections presentation follows its verified publication, then data/privacy/plan/backup. Do not redo accepted Settings navigation, Rules ownership or P7-01 security presentation.
|
||||
|
||||
Keep Rules' six fields, saved baseline, validation, submitted-snapshot acknowledgement, internal-tab draft retention and explicit discard. Notification preferences are a separate one-boolean account save scope; in-app reminders and email delivery remain distinct. Preserve local preferences versus server-owned settings, dirty retry/discard behavior, explicit writes and account/session disposal.
|
||||
|
||||
No billing, deletion, restore, provider sync/send/grant, worker/model or other withheld capability is activated. Actual provider interoperation and destructive operations retain separate evidence/authority requirements.
|
||||
|
||||
## Fresh entry evidence
|
||||
|
||||
Fresh frontend **23 cases / 3 suites PASS**, **2026-10-17 02:05:04.985979–02:05:19.337394 UTC**, `p709-entry-20261017T020504Z`: Settings14, Connections3 and job-correspondence Gmail import6. The contemporaneous 15-input snapshot `.artifacts/phase7/p7-09/entry/source.json` and `post.json` prove held bytes matching committed c5176e3 canonical source; its capture is not represented as occurring before the command started.
|
||||
|
||||
Fresh unchanged-binary backend **40/40 PASS**, **02:05:30.082119–02:05:33.187997 UTC**, `p709-oauth-entry-20261017T020530Z`, covers ExternalOrigin/Gmail/Microsoft controller cases using `--no-build`. This is finite existing server-contract evidence, not new tests, a Release rebuild, real OAuth or production acceptance. The [portable entry](evidence/entry.json) records raw reports, commands and source/runtime identities.
|
||||
|
||||
Entry inventories are **frontend 791/95, browser 323, backend 1,054**, derived from completed bounded additions. Latest whole frontend573/89, backend1,033 and sidecar49 remain inherited until P7-12. Do not substitute affected subsets for an integrated run.
|
||||
|
||||
## Findings to validate in the presentation boundaries
|
||||
|
||||
Notification reads depend on locale callbacks and can replace an unsaved choice; failed initial reads lack GET recovery, state-only saves permit duplicates, and late acknowledgements replace newer edits. Provider status failures currently resemble disconnection. Privacy/data lifecycle owners require truthful read/retry/pending states and disposal checks; partial import must report completed writes honestly without inventing transactions or resume semantics. These are bounded source findings, not completed repairs.
|
||||
|
||||
All **57 groups remain 56 PARTIAL / C11 DEFERRED**, with **33 current routes**. RD11 language, RD12 notification and RD13 Rules failures remain independently diagnosed; successful reruns do not close them. F20/RD21, other reliability/scaling debt, human screen-reader, physical touch/software keyboard, real zoom and production fonts/photos/print remain qualified. P5-13 is withheld; followMode is deferred. Production remains **NO-GO**, main is unchanged and Phase 8 implementation has not started.
|
||||
|
||||
The [separate sender repair](OAUTH_SENDER.md) now has final39/3, four type/lint/build gates and nine ordinary browser cases passing, including P2-D01. [Finite results](evidence/oauth-results.json) retain the actual REDs,39/3 final source, three new client-guard cases and all qualifications. Derived inventories become807/95 frontend and326 browser; backend1054 unchanged. Sender repair final hygiene/publication remain pending; preferences/Connections then data/privacy/plan/backup are still the two approved presentation boundaries.
|
||||
|
||||
**P7-09 acceptance: PENDING.** Each repair/presentation boundary needs meaningful regression, applicable native/visual evidence, preservation reconciliation, hygiene, commit and normal push/direct verification.
|
||||
@@ -0,0 +1,173 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"checkpoint": "P7-09",
|
||||
"recordedUtc": "2026-10-17T02:09:53.301530+00:00",
|
||||
"precedingPush": {
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"push",
|
||||
"origin",
|
||||
"HEAD:refs/heads/codex/ui-redesign-phase4"
|
||||
],
|
||||
"startedUtc": "2026-10-17T02:03:51.750936+00:00",
|
||||
"finishedUtc": "2026-10-17T02:03:53.590804+00:00",
|
||||
"exitCode": 0,
|
||||
"head": "c5176e3251c869e13284b994b59b2f7aa9220eb1",
|
||||
"log": ".artifacts/phase7/p7-08/operations-push-receipt.log",
|
||||
"logSha256": "a8093fb40b1c0f99606eb05af96104e9ff31abc16b35b7d45e7c020e583c8687",
|
||||
"observedUtc": "2026-10-17T02:03:53.815275+00:00",
|
||||
"originDevelopment": "c5176e3251c869e13284b994b59b2f7aa9220eb1",
|
||||
"liveDevelopment": "c5176e3251c869e13284b994b59b2f7aa9220eb1",
|
||||
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"liveMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"pendingPaths": [],
|
||||
"workingTreeClean": true,
|
||||
"raw": {
|
||||
"path": ".artifacts/phase7/p7-08/operations-push-receipt.json",
|
||||
"bytes": 861,
|
||||
"sha256": "83ba5d7a669cb47a4b3f71534dc3bbe4db1ea68269db0f911cb0756512b4380c"
|
||||
}
|
||||
},
|
||||
"baseline": {
|
||||
"frontend": {
|
||||
"command": {
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"npm.cmd",
|
||||
"test",
|
||||
"--",
|
||||
"--runInBand",
|
||||
"--watchAll=false",
|
||||
"--json",
|
||||
"--outputFile=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\unit\\p709-entry-20261017T020504Z.json",
|
||||
"src/settings-view.test.tsx",
|
||||
"src/email-provider-connections.test.tsx",
|
||||
"src/correspondence-gmail-import.test.tsx"
|
||||
],
|
||||
"startedUtc": "2026-10-17T02:05:04.985979+00:00",
|
||||
"finishedUtc": "2026-10-17T02:05:19.337394+00:00",
|
||||
"exitCode": 0,
|
||||
"counts": {
|
||||
"numPassedTests": 23,
|
||||
"numFailedTests": 0,
|
||||
"numPendingTests": 0,
|
||||
"numTodoTests": 0,
|
||||
"numPassedTestSuites": 3,
|
||||
"numFailedTestSuites": 0
|
||||
}
|
||||
},
|
||||
"commandRaw": {
|
||||
"path": ".artifacts/phase7/current/unit/p709-entry-20261017T020504Z-command.json",
|
||||
"bytes": 728,
|
||||
"sha256": "33aa459a2df3574e78bebd6ca36b038b9254a6d203930f127fea8c05da23d45f"
|
||||
},
|
||||
"reportRaw": {
|
||||
"path": ".artifacts/phase7/current/unit/p709-entry-20261017T020504Z.json",
|
||||
"bytes": 10729,
|
||||
"sha256": "f524e1b154f765096310b42cc41b66613bcf68b6b528ddcb37504e1916b2bd78"
|
||||
},
|
||||
"logRaw": {
|
||||
"path": ".artifacts/phase7/current/unit/p709-entry-20261017T020504Z.log",
|
||||
"bytes": 713,
|
||||
"sha256": "7f26609c1249778f41871893c7314e840889abb5eb4da65af8bfba9ca6d5da33"
|
||||
},
|
||||
"cases": 23,
|
||||
"suites": 3
|
||||
},
|
||||
"backend": {
|
||||
"command": {
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"C:/Users/Connor/.dotnet/dotnet.exe",
|
||||
"test",
|
||||
"JobTrackerApi.Tests/JobTrackerApi.Tests.csproj",
|
||||
"--configuration",
|
||||
"Release",
|
||||
"--no-build",
|
||||
"--filter",
|
||||
"FullyQualifiedName~ExternalOriginTests|FullyQualifiedName~GmailControllerTests|FullyQualifiedName~MicrosoftGraphControllerTests",
|
||||
"--logger",
|
||||
"trx;LogFileName=career.trx",
|
||||
"--results-directory",
|
||||
"E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\backend-career\\p709-oauth-entry-20261017T020530Z"
|
||||
],
|
||||
"startedUtc": "2026-10-17T02:05:30.082119+00:00",
|
||||
"finishedUtc": "2026-10-17T02:05:33.187997+00:00",
|
||||
"exitCode": 0,
|
||||
"logSha256": "639ed43cebf142ef313dbac8d9748626c9a8e73d5f818a89138082f7b81c5980",
|
||||
"counts": {
|
||||
"total": 40,
|
||||
"passed": 40,
|
||||
"failed": 0,
|
||||
"notExecuted": 0
|
||||
},
|
||||
"resultSha256": "babae621336afcefd2f33c893700094c2b4f5abae93599086111d7ba41555fcc"
|
||||
},
|
||||
"commandRaw": {
|
||||
"path": ".artifacts/phase7/current/backend-career/p709-oauth-entry-20261017T020530Z/command.json",
|
||||
"bytes": 980,
|
||||
"sha256": "8488d6faab3ffd334122f768e3f89d314f96355380a57fa56f43ec70da655109"
|
||||
},
|
||||
"trx": {
|
||||
"path": ".artifacts/phase7/current/backend-career/p709-oauth-entry-20261017T020530Z/career.trx",
|
||||
"bytes": 57162,
|
||||
"sha256": "babae621336afcefd2f33c893700094c2b4f5abae93599086111d7ba41555fcc"
|
||||
},
|
||||
"log": {
|
||||
"path": ".artifacts/phase7/current/backend-career/p709-oauth-entry-20261017T020530Z/career.log",
|
||||
"bytes": 613,
|
||||
"sha256": "639ed43cebf142ef313dbac8d9748626c9a8e73d5f818a89138082f7b81c5980"
|
||||
},
|
||||
"cases": 40,
|
||||
"newTests": 0,
|
||||
"qualification": "Existing --no-build server-contract cases; unchanged Release DLLs, no fresh build/provider grants/production acceptance."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
"snapshot": {
|
||||
"path": ".artifacts/phase7/p7-09/entry/source.json",
|
||||
"bytes": 4208,
|
||||
"sha256": "adee9cbaf18b6577527357c4c82d0e15b8afb0c38e2024a9c055d335b584782e"
|
||||
},
|
||||
"post": {
|
||||
"path": ".artifacts/phase7/p7-09/entry/post.json",
|
||||
"bytes": 508,
|
||||
"sha256": "b421d6e0c710f92b11e660b9a86a5663a0f8821b42c4cb7e2c859c2c8bd5e1a6"
|
||||
},
|
||||
"inputs": 15,
|
||||
"held": true,
|
||||
"canonicalMatchesCommit": "c5176e3251c869e13284b994b59b2f7aa9220eb1",
|
||||
"qualification": "Contemporaneous capture after command start, not represented as pre-run; all15 match c517 committed bytes and remain held through post observation."
|
||||
},
|
||||
"boundaries": [
|
||||
"Preferences/Connections",
|
||||
"Data/privacy/plan/backup"
|
||||
],
|
||||
"independentPrerequisite": "OAuth callback sender compatibility/security repair in two live consumers",
|
||||
"inventory": {
|
||||
"frontend": "791/95",
|
||||
"browser": 323,
|
||||
"backend": 1054,
|
||||
"lastWhole": {
|
||||
"frontend": "573/89",
|
||||
"backend": 1033,
|
||||
"sidecar": 49
|
||||
}
|
||||
},
|
||||
"preservation": {
|
||||
"groups": 57,
|
||||
"partial": 56,
|
||||
"deferred": "C11",
|
||||
"routes": 33
|
||||
},
|
||||
"decisions": {
|
||||
"p709": "PENDING",
|
||||
"production": "NO-GO",
|
||||
"phase8": "NOT_STARTED"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"checkpoint": "P7-09",
|
||||
"slice": "Independent OAuth sender repair",
|
||||
"recordedUtc": "2026-10-17T08:26:39.793290+00:00",
|
||||
"decision": "Bounded client repair CONDITIONAL GO; final staged hygiene PASS, normal publication pending",
|
||||
"entry": {
|
||||
"path": "docs/redesign/phase-7/p7-09/evidence/entry.json",
|
||||
"bytes": 6161,
|
||||
"sha256": "1e8f696ca154d04fc041d162402e805b07b18942389d4333770a828b0e9ba473",
|
||||
"hashPolicy": "Exact raw bytes"
|
||||
},
|
||||
"final": {
|
||||
"path": ".artifacts/phase7/p7-09/oauth-final-verified/results.json",
|
||||
"bytes": 142930,
|
||||
"sha256": "4eb2a2ca1f56ecaa6e288b8f18beeac973e1fede85eb79def8ba24c508c275c3",
|
||||
"hashPolicy": "Exact raw bytes"
|
||||
},
|
||||
"buildId": "j_uwah2_BImymFemE3cLg",
|
||||
"source": {
|
||||
"before": {
|
||||
"path": ".artifacts/phase7/p7-09/oauth-final-pre3/source.json",
|
||||
"bytes": 6363,
|
||||
"sha256": "4593a1ef652f33b0db150b006ef53d942c067ecf8a015fbe1188ab37dc43edce",
|
||||
"hashPolicy": "Exact raw bytes"
|
||||
},
|
||||
"after": {
|
||||
"path": ".artifacts/phase7/p7-09/oauth-final-post3/source.json",
|
||||
"bytes": 6363,
|
||||
"sha256": "8fe2c1c7fd16da4195aa47a4691de4cb45f53d48bbd71a282991976f33b4ab9e",
|
||||
"hashPolicy": "Exact raw bytes"
|
||||
},
|
||||
"beforeUtc": "2026-10-17T02:20:39.070048+00:00",
|
||||
"afterUtc": "2026-10-17T08:24:50.986374+00:00",
|
||||
"inputs": 23,
|
||||
"held": true,
|
||||
"records": [
|
||||
{
|
||||
"path": "job-tracker-ui/src/components/SettingsView.tsx",
|
||||
"bytes": 14211,
|
||||
"sha256": "4e70a46d93160a89b223b06676143e3776e6d32a5de590ae0548799d9995920d",
|
||||
"canonicalLfSha256": "39092de5b95cc39b635a2e7c7d9440d155f233522e84f15860bb4fcd913d47bb"
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/settings-view.test.tsx",
|
||||
"bytes": 15052,
|
||||
"sha256": "bfb454de00e11059b42a7249d84b680a371f4e4c4d9ebfe6e56832c43ba3397d",
|
||||
"canonicalLfSha256": "fc625ac490246aa80874532b174fe4ca52258d0916decdb607db9178d7c040d0"
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/components/EmailProviderConnections.tsx",
|
||||
"bytes": 11261,
|
||||
"sha256": "32c8c95304948c4b5ba4bba953480f31d648e1c9ae2c22485656935ae91aeb7f",
|
||||
"canonicalLfSha256": "fd1043648695cd66e3eef6202c2f8ebea07aa4769a8b1af21e414a06bd258a9e"
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/email-provider-connections.test.tsx",
|
||||
"bytes": 14449,
|
||||
"sha256": "b695955e638c89945a56afcba14ec1b2fc8f98c5220aa168d5554d54608b4802",
|
||||
"canonicalLfSha256": "0c887815db98f5fe750a2489bf4a951c5684ec8a254cd5be3284229f58b8f471"
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/components/Correspondence.tsx",
|
||||
"bytes": 42569,
|
||||
"sha256": "f42e7e549c9060f9d4f84111fc5d430eac353832c9cd6f6a56fa14b20c013251",
|
||||
"canonicalLfSha256": "45f0b6b442676e62e8644b57863d595936d43608841d4fadaafa936d5606036a"
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/correspondence-gmail-import.test.tsx",
|
||||
"bytes": 26424,
|
||||
"sha256": "eb1a3172c528df0f9876785e4ef4ae25220227c0801d247f76a61bd779c54653",
|
||||
"canonicalLfSha256": "f59abb397a716951399c64dfa6fbaeba3c63aaddd60e45eed7cdd5caf590a1e8"
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/views/ConnectedAccountsPage.tsx",
|
||||
"bytes": 961,
|
||||
"sha256": "1609c20b9dd1450a885b33cd1236fff734b3e1bdb35f279da5231393ff6f4340",
|
||||
"canonicalLfSha256": "fa52f2c950e3b8938401b390b761d069f504975694967f3c1d774a47020fd40e"
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/i18n/translations.ts",
|
||||
"bytes": 328864,
|
||||
"sha256": "39b41e7b6ecec3ad2cd73f1f8037d6fc5ddc10f3a2093388f7e1983e0a8cae0b",
|
||||
"canonicalLfSha256": "39b41e7b6ecec3ad2cd73f1f8037d6fc5ddc10f3a2093388f7e1983e0a8cae0b"
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/api.ts",
|
||||
"bytes": 4948,
|
||||
"sha256": "bec8dc665fef61f3c88938e9a61dda786a16008d6af9bc7688bdd615e7ba337c",
|
||||
"canonicalLfSha256": "f75ac7be07a743396843ddad5fd3371e2d5fd3d9c5d933e09e26fae2159f2378"
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/auth.ts",
|
||||
"bytes": 4822,
|
||||
"sha256": "0282a19bf34a915d6c4cd3bb32c041103206b1741b737e8a5e7b3f083a839acb",
|
||||
"canonicalLfSha256": "1cbdd59f73c5a961613b2569679f96630cded407974876a4ca8abccf28780b0e"
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/sessionBoundary.ts",
|
||||
"bytes": 2248,
|
||||
"sha256": "09edca77c98294818d2ad0c409ce595f1b6ca0c1f60419fc1078b70f96f5b7b9",
|
||||
"canonicalLfSha256": "09edca77c98294818d2ad0c409ce595f1b6ca0c1f60419fc1078b70f96f5b7b9"
|
||||
},
|
||||
{
|
||||
"path": "JobTrackerApi/Controllers/GmailController.cs",
|
||||
"bytes": 51060,
|
||||
"sha256": "05df8fea68b8c1d3a6b4a17bcd7c4b281b01a86fabf4c853eb495820ee3f4d2a",
|
||||
"canonicalLfSha256": "24eb6093d8067b2fe59b15ceb024f2a368d92ef615abb5c4455b830b914fbcb5"
|
||||
},
|
||||
{
|
||||
"path": "JobTrackerApi/Controllers/MicrosoftGraphController.cs",
|
||||
"bytes": 5394,
|
||||
"sha256": "b386c2e7413fd78ec252b01480e9163acafac24474b48c797e64fc34f660b6f0",
|
||||
"canonicalLfSha256": "232176a4e265cc2c261f08b02aa38719661171cf3859768c097eea4a56be898c"
|
||||
},
|
||||
{
|
||||
"path": "JobTrackerApi.Tests/ExternalOriginTests.cs",
|
||||
"bytes": 5698,
|
||||
"sha256": "ff91757dd21da47708371ac7aa29bc6acc0ad4f9e1e0124a6ed5374c981be434",
|
||||
"canonicalLfSha256": "f619dc7989cba2d492ae317bbc9acd7724317cb9203c8833fdb4a1086ff83189"
|
||||
},
|
||||
{
|
||||
"path": "JobTrackerApi/Controllers/NotificationSettingsController.cs",
|
||||
"bytes": 1481,
|
||||
"sha256": "f668e19c344f506d577dc2b8ec6be19439cef2bea2ad098680e57075456f0299",
|
||||
"canonicalLfSha256": "101130256b15432e41a32312f67bfa2f147c556e996869fced036cba92ad9517"
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/emailOAuth.ts",
|
||||
"bytes": 1769,
|
||||
"sha256": "dfd0f0ac46d295a2c5a0b9a6bb6bfffcceef6b6871cec7c92c429418cdbce8a9",
|
||||
"canonicalLfSha256": "dfd0f0ac46d295a2c5a0b9a6bb6bfffcceef6b6871cec7c92c429418cdbce8a9"
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/e2e/phase7-oauth-sender.spec.ts",
|
||||
"bytes": 26234,
|
||||
"sha256": "ae3600200122855ca7daf2bbf10c26f0eddc5600502b5d697fb6bdfd95b590c1",
|
||||
"canonicalLfSha256": "ae3600200122855ca7daf2bbf10c26f0eddc5600502b5d697fb6bdfd95b590c1"
|
||||
},
|
||||
{
|
||||
"path": "JobTrackerApi.Tests/GmailControllerTests.cs",
|
||||
"bytes": 46879,
|
||||
"sha256": "69ab904ad8f62cb57309d2038651ea1c5301070c5a6b76d2dda4150ca75e86e8",
|
||||
"canonicalLfSha256": "6d552871f5c8b84948671b976f28b3190f0fb9c56fc0345c5ea9d10fe85d3740"
|
||||
},
|
||||
{
|
||||
"path": "JobTrackerApi.Tests/MicrosoftGraphControllerTests.cs",
|
||||
"bytes": 6880,
|
||||
"sha256": "0847a7b630cc5cf52a9eb1196d8c5e7aeb7d7b37b7bf327131ef2e6f32ecde57",
|
||||
"canonicalLfSha256": "fca5fb45a51516801236e437fddd8a84fc0906e2de685d1656134612a1a390e0"
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/i18n/I18nProvider.tsx",
|
||||
"bytes": 3184,
|
||||
"sha256": "d22b5d033d0e405ffb5d581c752d61a24056d1e496f5f6a2f1baecef500f2113",
|
||||
"canonicalLfSha256": "d22b5d033d0e405ffb5d581c752d61a24056d1e496f5f6a2f1baecef500f2113"
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/toast.tsx",
|
||||
"bytes": 3425,
|
||||
"sha256": "83ff8de595c459b9fc403ff1ff9cd91bbdfaad9b85df197418aedbb9b26f5770",
|
||||
"canonicalLfSha256": "0c4d9df47526d5d9a9eddd397872138bb54bc7d736db74dc082056e33fe50d25"
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/playwright.config.ts",
|
||||
"bytes": 2669,
|
||||
"sha256": "01840494c782b2cc3c4c639dd2978a98ffa685b656e6a61fe4c503aeac6798f3",
|
||||
"canonicalLfSha256": "bcf1dc87bd573f251ab3030088801fa5966907c86fdb90c701003c180ed969d3"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase7/current/run-ui.py",
|
||||
"bytes": 7248,
|
||||
"sha256": "6a42b35eb10c27e09a4e24fdfb214c2c5d6e9e48644bc99e17a8f2dc422767d1",
|
||||
"canonicalLfSha256": "6a42b35eb10c27e09a4e24fdfb214c2c5d6e9e48644bc99e17a8f2dc422767d1"
|
||||
}
|
||||
],
|
||||
"qualification": "Endpoint snapshots and frozen-owner confirmation; final runs occurred 02:20-02:23 UTC. Collection resumed at08:24 UTC after the conversation interruption; this is not a new runtime execution."
|
||||
},
|
||||
"backend": {
|
||||
"existingEntryCases": 40,
|
||||
"binariesUnchanged": [
|
||||
{
|
||||
"path": "JobTrackerApi/bin/Release/net9.0/JobTrackerApi.dll",
|
||||
"bytes": 4063744,
|
||||
"sha256": "e1d2c1bfc7ba4d9816d5d7b80503e79d2a647ad0ae2f4777e2703508fd32681c",
|
||||
"hashPolicy": "Exact raw bytes"
|
||||
},
|
||||
{
|
||||
"path": "JobTrackerApi.Tests/bin/Release/net9.0/JobTrackerApi.Tests.dll",
|
||||
"bytes": 1997312,
|
||||
"sha256": "425d098903732802b5b304072c6f2e2f4479d74a56cf292d8c152544592dd99a",
|
||||
"hashPolicy": "Exact raw bytes"
|
||||
}
|
||||
],
|
||||
"qualification": "Existing no-build contract subset at checkpoint entry; no new backend test/build/schema/protocol or integrated backend run."
|
||||
},
|
||||
"regression": {
|
||||
"frontend": 39,
|
||||
"suites": 3,
|
||||
"nativeNew": 3,
|
||||
"nativeConsumers": 6,
|
||||
"skipped": 0,
|
||||
"expectedFailures": 0,
|
||||
"flaky": 0,
|
||||
"derivedInventory": {
|
||||
"frontend": 807,
|
||||
"frontendSuites": 95,
|
||||
"browser": 326,
|
||||
"backend": 1054
|
||||
},
|
||||
"delta": "Entry23 to39 adds16 ordinary caller tests, zero suites; native3 adds3 to323. No integrated count is claimed.",
|
||||
"inheritedWhole": {
|
||||
"frontend": 573,
|
||||
"frontendSuites": 89,
|
||||
"backend": 1033,
|
||||
"sidecar": 49
|
||||
}
|
||||
},
|
||||
"history": [
|
||||
{
|
||||
"path": ".artifacts/phase7/p7-09/oauth-security/p709-oauth-security-red-20261017/results.json",
|
||||
"bytes": 16197,
|
||||
"sha256": "e7221fef89b29caa34c2eaab7830785d07d191834189c849a583c077fc859e18",
|
||||
"hashPolicy": "Exact raw bytes"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase7/p7-09/oauth-security/p709-oauth-security-candidate1-20261017/results.json",
|
||||
"bytes": 4951,
|
||||
"sha256": "0042e2e7ea345bdbc8c9bce0bf5e04ed2071f1d271f196046e8622c4a95c26eb",
|
||||
"hashPolicy": "Exact raw bytes"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase7/p7-09/oauth-browser-types-failed/results.json",
|
||||
"bytes": 2698,
|
||||
"sha256": "3ab28a122342287941afdfd9d3f3203e2b15daf9d7f4353217a80712dfd370ab",
|
||||
"hashPolicy": "Exact raw bytes"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase7/p7-09/oauth-native-attempt1/results.json",
|
||||
"bytes": 34346,
|
||||
"sha256": "7fd1799e3a87b61f22556ecb140270dd598b7b6d4e48789b41537c8613a76af7",
|
||||
"hashPolicy": "Exact raw bytes"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase7/p7-09/oauth-native-startup-red/results.json",
|
||||
"bytes": 33625,
|
||||
"sha256": "08e055e46c480e1d384a73c753f3cebb4f1e0fa7f620d5d8b31be885cd63c05f",
|
||||
"hashPolicy": "Exact raw bytes"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase7/p7-09/oauth-security/p709-oauth-startup-locale-red-20261017/results.json",
|
||||
"bytes": 45175,
|
||||
"sha256": "aec5cb574ca5d58b626842ceb6ad21f0de324fca195a43b3db6d5cf6103e9dd7",
|
||||
"hashPolicy": "Exact raw bytes"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase7/p7-09/oauth-security/p709-oauth-startup-locale-green-20261017/results.json",
|
||||
"bytes": 4955,
|
||||
"sha256": "a39bcd3af0d0b2fcefe3f00d5d393f52e3a78fa04329c5f1e76226f7ba65b69f",
|
||||
"hashPolicy": "Exact raw bytes"
|
||||
}
|
||||
],
|
||||
"visual": {
|
||||
"retained": 76,
|
||||
"actuallyReviewed": [
|
||||
{
|
||||
"raw": {
|
||||
"path": ".artifacts/phase7/p7-09/oauth-final-verified/embedded/oauth-sender/002-p709-client-guard-connections-disconnected.png",
|
||||
"sha256": "9bf6277c770b18767e6d51abf5435983fa300c3a080645d807dae6ace10bc127",
|
||||
"bytes": 90735,
|
||||
"hashPolicy": "Exact measured raw bytes"
|
||||
},
|
||||
"originalEmbeddedReport": {
|
||||
"path": ".artifacts/phase7/p7-09/oauth-final-verified/oauth-sender.json",
|
||||
"sha256": "d692d63ae1b8381914cad8d349981dce77abab099cd2ccf6e597072cd3fa4e56",
|
||||
"bytes": 154261,
|
||||
"hashPolicy": "Exact measured raw bytes"
|
||||
},
|
||||
"lane": "oauth-sender",
|
||||
"test": "[P7-09 OAuth sender] actual local child proxy and callback origin bind one terminal Connections refresh while forged and duplicate messages are ignored",
|
||||
"name": "p709-client-guard-connections-disconnected",
|
||||
"manualReview": "PENDING_ACTUAL_PARENT_IMAGE_RECEIPT",
|
||||
"review": "ACTUALLY DISPLAYED AND REVIEWED by parent",
|
||||
"reviewedUtc": "2026-10-17T08:26:39.793290+00:00"
|
||||
},
|
||||
{
|
||||
"raw": {
|
||||
"path": ".artifacts/phase7/p7-09/oauth-final-verified/application-forms-regression-results/job-sections-presentation--3db37-xplicit-and-recover-locally-chromium/attachments/sections-documents-loading-7849a3a5b4870c2813da5e077971061e408c8086.png",
|
||||
"sha256": "41dbeece6f2a8e6b30bdbffb73f10ad1640be556ba1e3f2d32c2bd8b8647433b",
|
||||
"bytes": 54618,
|
||||
"hashPolicy": "Exact measured raw bytes"
|
||||
},
|
||||
"originalEmbeddedReport": {
|
||||
"path": ".artifacts/phase7/p7-09/oauth-final-verified/application-forms-regression.json",
|
||||
"sha256": "6635854a36655342f2c7c5d2b303fa359907312b66a839358f7e1a69e6585fb9",
|
||||
"bytes": 137941,
|
||||
"hashPolicy": "Exact measured raw bytes"
|
||||
},
|
||||
"lane": "application-forms-regression",
|
||||
"test": "[PRES-W01 W08 M01 E08] Job section loading and partial read failures stay explicit and recover locally",
|
||||
"name": "sections-documents-loading",
|
||||
"manualReview": "PENDING_ACTUAL_PARENT_IMAGE_RECEIPT",
|
||||
"review": "ACTUALLY DISPLAYED AND REVIEWED by parent",
|
||||
"reviewedUtc": "2026-10-17T08:26:39.793290+00:00"
|
||||
},
|
||||
{
|
||||
"raw": {
|
||||
"path": ".artifacts/phase7/p7-09/oauth-final-verified/application-forms-regression-results/job-sections-presentation--6b168-tasks-and-history-en-light--chromium/attachments/sections-timeline-768-en-light-1cd7e78e8c8c2c4afc7af8281944396581e4960d.png",
|
||||
"sha256": "8f352eb32ded467f7dc2c914a67a356da0493246837339430d5732d8d92b1c72",
|
||||
"bytes": 63152,
|
||||
"hashPolicy": "Exact measured raw bytes"
|
||||
},
|
||||
"originalEmbeddedReport": {
|
||||
"path": ".artifacts/phase7/p7-09/oauth-final-verified/application-forms-regression.json",
|
||||
"sha256": "6635854a36655342f2c7c5d2b303fa359907312b66a839358f7e1a69e6585fb9",
|
||||
"bytes": 137941,
|
||||
"hashPolicy": "Exact measured raw bytes"
|
||||
},
|
||||
"lane": "application-forms-regression",
|
||||
"test": "[PRES-W01–W11 E08 E09] native Job sections retain drafts, frozen documents, tasks and history (en, light)",
|
||||
"name": "sections-timeline-768-en-light",
|
||||
"manualReview": "PENDING_ACTUAL_PARENT_IMAGE_RECEIPT",
|
||||
"review": "ACTUALLY DISPLAYED AND REVIEWED by parent",
|
||||
"reviewedUtc": "2026-10-17T08:26:39.793290+00:00"
|
||||
},
|
||||
{
|
||||
"raw": {
|
||||
"path": ".artifacts/phase7/p7-09/oauth-final-verified/application-forms-regression-results/job-sections-presentation--9cba8--tasks-and-history-en-dark--chromium/attachments/sections-communication-390-en-dark-ab586d30b020e90bc0b98c58a2aebff52d631444.png",
|
||||
"sha256": "4602ffe1395f5125ea9df5a1fa49b35764a2fabd43e29edee2be8367ce448962",
|
||||
"bytes": 45456,
|
||||
"hashPolicy": "Exact measured raw bytes"
|
||||
},
|
||||
"originalEmbeddedReport": {
|
||||
"path": ".artifacts/phase7/p7-09/oauth-final-verified/application-forms-regression.json",
|
||||
"sha256": "6635854a36655342f2c7c5d2b303fa359907312b66a839358f7e1a69e6585fb9",
|
||||
"bytes": 137941,
|
||||
"hashPolicy": "Exact measured raw bytes"
|
||||
},
|
||||
"lane": "application-forms-regression",
|
||||
"test": "[PRES-W01–W11 E08 E09] native Job sections retain drafts, frozen documents, tasks and history (en, dark)",
|
||||
"name": "sections-communication-390-en-dark",
|
||||
"manualReview": "PENDING_ACTUAL_PARENT_IMAGE_RECEIPT",
|
||||
"review": "ACTUALLY DISPLAYED AND REVIEWED by parent",
|
||||
"reviewedUtc": "2026-10-17T08:26:39.793290+00:00"
|
||||
},
|
||||
{
|
||||
"raw": {
|
||||
"path": ".artifacts/phase7/p7-09/oauth-final-verified/application-forms-regression-results/job-sections-presentation--9f2d9-tasks-and-history-nb-light--chromium/attachments/sections-documents-320-nb-light-6adbb6be58b8c957e0d943acfeae64c4e7ed3adb.png",
|
||||
"sha256": "325b31938bfcd342ce6c00e563a93796eff5b9054330f8282d38062994f7be6f",
|
||||
"bytes": 45863,
|
||||
"hashPolicy": "Exact measured raw bytes"
|
||||
},
|
||||
"originalEmbeddedReport": {
|
||||
"path": ".artifacts/phase7/p7-09/oauth-final-verified/application-forms-regression.json",
|
||||
"sha256": "6635854a36655342f2c7c5d2b303fa359907312b66a839358f7e1a69e6585fb9",
|
||||
"bytes": 137941,
|
||||
"hashPolicy": "Exact measured raw bytes"
|
||||
},
|
||||
"lane": "application-forms-regression",
|
||||
"test": "[PRES-W01–W11 E08 E09] native Job sections retain drafts, frozen documents, tasks and history (nb, light)",
|
||||
"name": "sections-documents-320-nb-light",
|
||||
"manualReview": "PENDING_ACTUAL_PARENT_IMAGE_RECEIPT",
|
||||
"review": "ACTUALLY DISPLAYED AND REVIEWED by parent",
|
||||
"reviewedUtc": "2026-10-17T08:26:39.793290+00:00"
|
||||
},
|
||||
{
|
||||
"raw": {
|
||||
"path": ".artifacts/phase7/p7-09/oauth-final-verified/application-forms-regression-results/job-sections-presentation--79a07--tasks-and-history-nb-dark--chromium/attachments/sections-sparse-nb-dark-2a16a7ade8be87e69a9284919536ff2606f37745.png",
|
||||
"sha256": "70fff9c80f37808d884b3d7743c4208e604081bcb37f1cb01ad0240779426298",
|
||||
"bytes": 42359,
|
||||
"hashPolicy": "Exact measured raw bytes"
|
||||
},
|
||||
"originalEmbeddedReport": {
|
||||
"path": ".artifacts/phase7/p7-09/oauth-final-verified/application-forms-regression.json",
|
||||
"sha256": "6635854a36655342f2c7c5d2b303fa359907312b66a839358f7e1a69e6585fb9",
|
||||
"bytes": 137941,
|
||||
"hashPolicy": "Exact measured raw bytes"
|
||||
},
|
||||
"lane": "application-forms-regression",
|
||||
"test": "[PRES-W01–W11 E08 E09] native Job sections retain drafts, frozen documents, tasks and history (nb, dark)",
|
||||
"name": "sections-sparse-nb-dark",
|
||||
"manualReview": "PENDING_ACTUAL_PARENT_IMAGE_RECEIPT",
|
||||
"review": "ACTUALLY DISPLAYED AND REVIEWED by parent",
|
||||
"reviewedUtc": "2026-10-17T08:26:39.793290+00:00"
|
||||
},
|
||||
{
|
||||
"raw": {
|
||||
"path": ".artifacts/phase7/p7-09/oauth-final-verified/embedded/application-p2d01/001-native-frozen-package.png",
|
||||
"sha256": "0e50da5bb0832aa07ddb5669555520f57df2d23b29cdcd6ed80044148f06e0aa",
|
||||
"bytes": 93792,
|
||||
"hashPolicy": "Exact measured raw bytes"
|
||||
},
|
||||
"originalEmbeddedReport": {
|
||||
"path": ".artifacts/phase7/p7-09/oauth-final-verified/application-p2d01.json",
|
||||
"sha256": "40fd978b3924cba8da8ad5d3fe396484c274ab7a540ee763e8469fe27003001e",
|
||||
"bytes": 129482,
|
||||
"hashPolicy": "Exact measured raw bytes"
|
||||
},
|
||||
"lane": "application-p2d01",
|
||||
"test": "[PRES-W01 PRES-W05 PRES-W06 PRES-W07 PRES-W08] legacy workspace preserves frozen documents after current sources change",
|
||||
"name": "native-frozen-package",
|
||||
"manualReview": "PENDING_ACTUAL_PARENT_IMAGE_RECEIPT",
|
||||
"review": "ACTUALLY DISPLAYED AND REVIEWED by parent",
|
||||
"reviewedUtc": "2026-10-17T08:26:39.793290+00:00"
|
||||
}
|
||||
],
|
||||
"qualification": "Seven of76 retained PNGs actually displayed: Connections, controlled loading, EN/light768, EN/dark390, NB/light320, NB/dark320 and frozen package. The unchanged P2-D01 screenshot catches dialog transition opacity; passing archive/readback assertions are not a settled visual or PDF-fidelity claim."
|
||||
},
|
||||
"accessibility": {
|
||||
"senderScans": 0,
|
||||
"consumerScans": 8,
|
||||
"violations": 0,
|
||||
"incompleteNodes": 89,
|
||||
"triage": [
|
||||
{
|
||||
"count": 8,
|
||||
"category": "Short notification badge text; manual contrast remains required"
|
||||
},
|
||||
{
|
||||
"count": 15,
|
||||
"category": "Overlapped form backgrounds; manual contrast/state remains required"
|
||||
},
|
||||
{
|
||||
"count": 20,
|
||||
"category": "Partly obscured labels; scroll/state review remains required"
|
||||
},
|
||||
{
|
||||
"count": 46,
|
||||
"category": "aria-label on roleless span (45 Notes/Tasks,1 Interview); retained P7-11 diagnostic requiring responsible-owner review"
|
||||
}
|
||||
],
|
||||
"qualification": "Full node/check vectors retained in raw reports. Consumer scans are inherited workflow regression, not new sender scans or human screen-reader acceptance. No rule is disabled."
|
||||
},
|
||||
"peerReview": "Independent read-only review: no actionable introduced defect across helper, both callers, unit suites and native fixture. Actual provider interoperability remains unqualified.",
|
||||
"preservation": {
|
||||
"groups": 57,
|
||||
"partial": 56,
|
||||
"deferred": [
|
||||
"PRES-C11"
|
||||
],
|
||||
"routes": 33,
|
||||
"targets": [
|
||||
"PRES-M01",
|
||||
"PRES-M02",
|
||||
"PRES-M06",
|
||||
"PRES-E09"
|
||||
],
|
||||
"ordinaryP2D01": "PASS",
|
||||
"qualification": "No provider grants, backend auth/state exchange, IMAP semantics, document renderer, stored data, routes or permissions changed."
|
||||
},
|
||||
"limits": [
|
||||
"Local child URLs/HTML/messages are controlled client-guard projections, not real OAuth grants; actual server status remains disconnected.",
|
||||
"Known Google Fonts stylesheet is intercepted and aborted; no provider/model/mailbox/send call is authorized or executed.",
|
||||
"Native Microsoft connect500 is checked-in unconfigured-service behavior. Anonymous401 is a real separate API request, not a fabricated browser response.",
|
||||
"RD11/RD12/RD13/RD21, other reliability/scaling and manual/device/zoom/production-output gates remain open.",
|
||||
"Preferences/Connections and data/privacy/plan/backup presentation remain pending. P5-13 stays withheld, followMode deferred, main unchanged, production NO-GO, Phase8 not started."
|
||||
],
|
||||
"staticChecks": {
|
||||
"path": "docs/redesign/phase-7/p7-09/evidence/oauth-static-checks.json",
|
||||
"sha256": "2efe01046430d1b9522ec348b0dfb1462d16589dff137ef7068f4b5b6609017e",
|
||||
"checkedUtc": "2026-10-17T08:27:18.310998+00:00"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"checkpoint": "p7-09",
|
||||
"status": "PASS",
|
||||
"checkedUtc": "2026-10-17T08:27:18.310998+00:00",
|
||||
"commands": [
|
||||
{
|
||||
"name": "secret-scan",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"C:\\Users\\Connor\\AppData\\Local\\Programs\\Python\\Python312\\python.exe",
|
||||
"-X",
|
||||
"utf8",
|
||||
"scripts/supply-chain.py",
|
||||
"scan-secrets"
|
||||
],
|
||||
"startedUtc": "2026-10-17T08:27:14.824608+00:00",
|
||||
"finishedUtc": "2026-10-17T08:27:17.557432+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261017T082714Z/secret-scan.log",
|
||||
"logSha256Raw": "c5a88d5ebdc042adba125c1eaacbe44f9f4a5c19898c0f195573c00d511aa28d"
|
||||
},
|
||||
{
|
||||
"name": "working-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-17T08:27:17.557931+00:00",
|
||||
"finishedUtc": "2026-10-17T08:27:17.648521+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261017T082714Z/working-whitespace.log",
|
||||
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
|
||||
},
|
||||
{
|
||||
"name": "staged-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--cached",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-17T08:27:17.649028+00:00",
|
||||
"finishedUtc": "2026-10-17T08:27:17.743329+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261017T082714Z/staged-whitespace.log",
|
||||
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
|
||||
},
|
||||
{
|
||||
"name": "phase7-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check",
|
||||
"b5319981ad3d1bd635daff861453d23c0de5320b"
|
||||
],
|
||||
"startedUtc": "2026-10-17T08:27:17.743826+00:00",
|
||||
"finishedUtc": "2026-10-17T08:27:17.908329+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261017T082714Z/phase7-whitespace.log",
|
||||
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
|
||||
}
|
||||
],
|
||||
"untrackedSecretScan": {
|
||||
"files": 0,
|
||||
"findings": 0
|
||||
},
|
||||
"documentation": {
|
||||
"files": 25,
|
||||
"relativeLinks": 583,
|
||||
"errors": []
|
||||
},
|
||||
"preservation": {
|
||||
"groups": 57,
|
||||
"statuses": {
|
||||
"PARTIAL": 56,
|
||||
"PASS": 0,
|
||||
"FAIL": 0,
|
||||
"DEFERRED": 1
|
||||
},
|
||||
"originalRoutesPreserved": true,
|
||||
"currentConcreteRoutes": 33
|
||||
},
|
||||
"production": {
|
||||
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"status": "NO-GO",
|
||||
"qualification": "Local fetched refs; direct push observation recorded separately."
|
||||
},
|
||||
"hashPolicy": "Portable JSON UTF8 LF; raw log hashes measure exact saved bytes.",
|
||||
"limits": [
|
||||
"Pattern scanner skips binary and files over2MiB; not historical Git/production secrets.",
|
||||
"Local links check target existence, not URL/heading anchors.",
|
||||
"Static evidence is not runtime/manual/device/provider/production acceptance."
|
||||
]
|
||||
}
|
||||
@@ -92,7 +92,7 @@ Follow the approved [Phase 7 handoff](../redesign/phase-6/p6-05/PHASE7_HANDOFF.m
|
||||
- [ ] P7-06F optional reviewed followMode composer: retain deferral unless its required action/fallback decision is established.
|
||||
- [x] P7-07 [owner-scoped Documents index](../redesign/phase-7/p7-07/README.md): implemented CONDITIONAL GO, final hygiene PASS; UI5a67ec3 and evidence84350b7 published through9dfea12, directly verified21:01:11UTC. Finite86/8+native13/types/lint/build/18images/15axe0/101qualified;33routes/57statuses retained. MariaDB/human/device/production qualifications remain.
|
||||
- [x] P7-08 [existing notifications and operations](../redesign/phase-7/p7-08/README.md): bothpresentationboundariesimplementedCONDITIONALGO. UTC9dfea/recent40cfe04 published; Operations77/7+native12+types/lint/build/14Ops+1P2actualimages PASS;12Opsaxe0/3+14notifaxe0/102qualified. FinalOpshygiene/normalpushpending.791/95frontend323browser1054backend derivedonly;57groups33routes/mainunchanged/productionNO-GO. P7-09next.
|
||||
- [ ] P7-09 remaining Settings, Connections and data lifecycle presentation.
|
||||
- [ ] P7-09 [Settings, Connections and data lifecycle](../redesign/phase-7/p7-09/README.md): separate sender repair final39/3+native9+four gates PASS, final publication pending; both approved presentation boundaries remain. Derived807/95 frontend,326 browser,1054 backend; no full-suite claim.
|
||||
- [ ] P7-10 Users, Audit and System administration presentation.
|
||||
- [ ] P7-11 residual Analytics/error/empty/dialog integration.
|
||||
- [ ] P7-12 integrated migration acceptance and Phase 8 handoff.
|
||||
@@ -123,3 +123,8 @@ P7-07 implemented scope is **CONDITIONAL GO and published**. UTC transport repai
|
||||
|
||||
|
||||
Recent-notification presentation committed and normally pushed **40cfe04df51654c5ab9a073c13c8d67e0d90dcae**; direct refs verified **2026-10-16T21:15:02.899052UTC**, clean HEAD=origin=live development and local/origin/live main27657f3 unchanged. First presentation boundary remains implemented **CONDITIONAL GO**; Operations is the next independent boundary. Earlier pending-push statements are chronological observations superseded by this receipt. No production or Phase8 action.
|
||||
|
||||
|
||||
### P7-08 publication and P7-09 entry
|
||||
|
||||
Operations **c5176e3251c869e13284b994b59b2f7aa9220eb1** is normally pushed and directly verified **2026-10-17T02:03:53.815275UTC**: clean HEAD=origin=live development, local/origin/live main27657f3 unchanged. Both P7-08 UI boundaries remain implemented **CONDITIONAL GO and published**; manual/provider/output/release conditions remain. Earlier pending-publication text is superseded by this exact receipt in Operations results. P7-09 follows its approved two boundaries; a separately bounded OAuth sender repair first protects its two existing callback consumers. Fresh entry23/3 frontend and40 unchanged-binary backend cases pass;791/95,323,1054 remain derived inventories, not full-suite passes. No production or Phase8 action.
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { expect, test as base, type APIRequestContext, type APIResponse, type BrowserContext, type Locator, type Page, type Response } from '@playwright/test';
|
||||
import { translations } from '../src/i18n/translations';
|
||||
|
||||
const apiUrl = 'http://localhost:5302/api';
|
||||
const uiOrigin = 'http://localhost:3300';
|
||||
const password = 'E2ePassword123!';
|
||||
type Provider = 'gmail' | 'microsoft-graph';
|
||||
type Owner = { id: string; email: string; headers: Record<string, string> };
|
||||
type RecordedRequest = { method: string; path: string; query: string };
|
||||
type Session = { owner: Owner; requests: RecordedRequest[] };
|
||||
type PopupRecord = { url: string; name: string; features: string; proxy: Window | null; blocked: boolean };
|
||||
type MessageRecord = { origin: string; source: unknown; status: unknown; message: unknown; sourceOpenName: string | null; sourceClosed: boolean | null; sourceIsSelf: boolean };
|
||||
type ClientProof = { opens: PopupRecord[]; messages: MessageRecord[]; blockNext: boolean };
|
||||
type InstrumentedWindow = Window & typeof globalThis & { __clientGuardProof: ClientProof };
|
||||
const boundary = 'CLIENT-GUARD fixture only. Actual Program/local Identity cookies/CSRF/free owners, actual owned jobs and actual disconnected status endpoints are used. Authenticated connect-url GETs are explicitly projected to local authorization HTML carrying an exact redirect_uri callback path on UI3300. Local child pages are fulfilled through browser routing, not new backend/app routes; their real browser WindowProxy sends actual postMessage events and closes immediately. No synthetic MessageEvent is dispatched. Controlled candidate GET returns an empty UI contract after actual owned-job authorization checks; no Gmail/Graph provider request or mailbox connection is performed. Success callback payloads qualify sender correlation only and never imply an actual grant: server status is asserted disconnected throughout. Browser UI3300/API5302/canonical configured AppPublicBaseUrl3000 interoperability, real provider callbacks/state exchange and backend qualification remain separate. All nonlocal requests are aborted; only exact GoogleFonts HTTPS GET css2 stylesheets are separately recorded as aborted resources. No provider/model/SMTP, Docker, deployment, main or Phase8 work. The blocked-popup branch is an explicitly controlled null return from window.open; browser popup-blocker/device/AT qualification is not claimed.';
|
||||
function copy(key: string, language: 'en' | 'nb' = 'en', params: Record<string, string | number> = {}) { return ((translations[language] as Record<string, string>)[key] ?? key).replace(/\{(\w+)\}/g, (_, name: string) => String(params[name] ?? `{${name}}`)); }
|
||||
function hold() { let release!: () => void; const pending = new Promise<void>(resolve => { release = resolve; }); return { pending, release }; }
|
||||
async function json<T>(response: APIResponse | Response, status = 200): Promise<T> { expect(response.status(), response.url()).toBe(status); return response.json(); }
|
||||
function count(session: Session, path: string) { return session.requests.filter(request => request.path === `/api${path}`); }
|
||||
function response(page: Page, method: string, path: string) { return page.waitForResponse(value => value.request().method() === method && new URL(value.url()).pathname === `/api${path}`); }
|
||||
async function keyboard(page: Page, control: Locator) { await expect(control).toBeVisible(); await expect(control).toBeEnabled(); await control.focus(); await expect(control).toBeFocused(); await page.keyboard.press('Enter'); }
|
||||
async function settle(page: Page) { await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); }
|
||||
async function login(api: APIRequestContext, email: string): Promise<Owner> { expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200); const me = await json<{ id: string; sessionContext: string }>(await api.get(`${apiUrl}/auth/me`)); const token = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN'); expect(token).toBeDefined(); return { id: me.id, email, headers: { 'X-CSRF-TOKEN': token!.value, 'X-Session-Context': me.sessionContext } }; }
|
||||
async function disconnected(api: APIRequestContext) { for (const provider of ['gmail', 'microsoft-graph', 'imap']) expect(await json<{ connected: boolean }>(await api.get(`${apiUrl}/${provider}/status`))).toMatchObject({ connected: false }); }
|
||||
async function proof(page: Page) { return page.evaluate(() => { const value = (window as InstrumentedWindow).__clientGuardProof; return { opens: value.opens.map(({ proxy, ...entry }) => ({ ...entry, closed: proxy?.closed ?? null })), messages: value.messages }; }); }
|
||||
async function received(page: Page, marker: string) { await expect.poll(async () => (await proof(page)).messages.filter(message => message.message === marker).length).toBeGreaterThan(0); await settle(page); }
|
||||
const test = base.extend<{ session: Session }>({
|
||||
session: async ({ page, context, playwright }, provide, info) => {
|
||||
expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true'); expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String)); const admin = await playwright.request.newContext(); const requests: RecordedRequest[] = []; const forbidden: string[] = []; const fonts: { url: string; resourceType: string }[] = [];
|
||||
page.on('request', request => { const url = new URL(request.url()); if (url.pathname.startsWith('/api/')) requests.push({ method: request.method(), path: url.pathname, query: url.search }); });
|
||||
await context.route('**/*', route => { const request = route.request(); const url = new URL(request.url()); if (url.protocol === 'https:' && url.hostname === 'fonts.googleapis.com' && url.port === '' && url.pathname === '/css2' && request.method() === 'GET' && request.resourceType() === 'stylesheet') { fonts.push({ url: url.href, resourceType: request.resourceType() }); return route.abort(); } const provider = /^\/api\/(gmail|microsoft-graph|imap)(?:\/|$)/.test(url.pathname); const allowedProviderRead = request.method() === 'GET' && /^\/api\/(gmail|microsoft-graph|imap)\/(status|connect-url)$/.test(url.pathname); if (provider && !allowedProviderRead || /^\/api\/(?:ai|email\/send)(?:\/|$)/.test(url.pathname) || !['blob:', 'data:', 'about:'].includes(url.protocol) && (!['localhost', '127.0.0.1'].includes(url.hostname) || !['3300', '5302'].includes(url.port))) { forbidden.push(`${request.method()} ${url.origin}${url.pathname}`); return route.abort(); } return route.continue(); });
|
||||
await context.addInitScript(() => {
|
||||
const browser = window as InstrumentedWindow; const originalOpen = window.open; const data: ClientProof = { opens: [], messages: [], blockNext: false }; browser.__clientGuardProof = data;
|
||||
window.open = (url, target, features) => { const blocked = data.blockNext; data.blockNext = false; const proxy = blocked ? null : originalOpen.call(window, url, target, features); data.opens.push({ url: String(url), name: target ?? '', features: features ?? '', proxy, blocked }); return proxy; };
|
||||
window.addEventListener('message', event => { const payload = event.data as { source?: unknown; status?: unknown; message?: unknown }; if (!payload || typeof payload.source !== 'string' || !payload.source.startsWith('jobtracker-')) return; data.messages.push({ origin: event.origin, source: payload.source, status: payload.status, message: payload.message, sourceOpenName: data.opens.find(entry => entry.proxy === event.source)?.name ?? null, sourceClosed: event.source ? (event.source as Window).closed : null, sourceIsSelf: event.source === window }); });
|
||||
});
|
||||
try { const administrator = await login(admin, 'e2e@example.test'); const email = `p709-client-guard-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; expect((await admin.post(`${apiUrl}/users`, { headers: administrator.headers, data: { email, password, roles: [] } })).status()).toBe(200); const owner = await login(page.request, email); expect((await json<{ entitlements: { ai: boolean } }>(await page.request.get(`${apiUrl}/auth/me`))).entitlements.ai).toBe(false); await disconnected(page.request); await provide({ owner, requests }); expect(forbidden).toEqual([]); }
|
||||
finally { await info.attach('oauth-sender-client-guard-boundary', { body: JSON.stringify({ boundary, requests, forbiddenExternalProviderModelRequests: forbidden, blockedKnownFontStylesheets: fonts, qualification: 'No external font or provider was fetched. Actual backend callback/state interoperability and production font acceptance remain pending.' }), contentType: 'application/json' }); for (const child of context.pages().filter(candidate => candidate !== page)) await child.close(); await admin.dispose(); }
|
||||
},
|
||||
});
|
||||
function discriminator(provider: Provider) { return provider === 'gmail' ? 'jobtracker-gmail-oauth' : 'jobtracker-microsoft-oauth'; }
|
||||
function callbackPath(provider: Provider) { return `/api/${provider}/oauth/callback`; }
|
||||
function childUrl(provider: Provider, mode: string, marker: string, origin = uiOrigin) { const url = new URL(callbackPath(provider), origin); url.searchParams.set('clientGuard', '1'); url.searchParams.set('mode', mode); url.searchParams.set('marker', marker); return url.href; }
|
||||
async function childRoutes(context: BrowserContext) {
|
||||
await context.route(url => url.pathname.startsWith('/__p709_client_guard__/') || url.searchParams.get('clientGuard') === '1' && ['/api/gmail/oauth/callback', '/api/microsoft-graph/oauth/callback'].includes(url.pathname), route => {
|
||||
const url = new URL(route.request().url()); const provider: Provider = url.pathname.includes('microsoft-graph') ? 'microsoft-graph' : 'gmail'; const mode = url.searchParams.get('mode'); const marker = url.searchParams.get('marker') ?? 'CLIENT-GUARD waiting'; const payload = { source: discriminator(provider), status: 'connected', message: marker }; const send = `window.opener.postMessage(${JSON.stringify(payload)},${JSON.stringify(uiOrigin)});`; const script = mode === 'valid' ? `${send}${send}window.close();` : mode === 'forged' ? `${send}window.close();` : mode === 'wrong-origin' ? send : '';
|
||||
return route.fulfill({ contentType: 'text/html', body: `<!doctype html><html><head><meta charset="utf-8"><title>CLIENT-GUARD fixture</title></head><body><h1>CLIENT-GUARD local sender fixture; no provider grant</h1><script>${script}</script></body></html>` });
|
||||
});
|
||||
}
|
||||
async function projectConnect(page: Page, owner: Owner, provider: Provider, gate?: ReturnType<typeof hold>) {
|
||||
const callback = new URL(callbackPath(provider), uiOrigin); const authorize = new URL('/__p709_client_guard__/authorize', uiOrigin); authorize.searchParams.set('redirect_uri', callback.href); authorize.searchParams.set('provider', provider);
|
||||
await page.route(`${apiUrl}/${provider}/connect-url`, async route => { expect((await json<{ id: string }>(await route.fetch({ url: `${apiUrl}/auth/me` }))).id).toBe(owner.id); if (gate) await gate.pending; return route.fulfill({ status: 200, json: { url: authorize.href } }); }); return { authorize: authorize.href, callbackOrigin: callback.origin, callbackPath: callback.pathname };
|
||||
}
|
||||
async function enter(page: Page, target: string) { await page.addInitScript(() => { localStorage.setItem('uiLanguage', 'en'); localStorage.setItem('jobtracker.themeMode', 'light'); }); await page.setViewportSize({ width: 1440, height: 900 }); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto(target); await expect(page.locator('header').getByRole('button', { name: copy('notifications'), exact: true })).toBeVisible(); }
|
||||
function connectRow(page: Page, provider: Provider) { return page.locator('main').getByText(provider === 'gmail' ? 'Gmail' : 'Outlook / Microsoft 365', { exact: true }).locator('..').locator('..'); }
|
||||
async function openChild(page: Page, button: Locator) { const opened = page.waitForEvent('popup'); await keyboard(page, button); const child = await opened; await child.waitForLoadState('domcontentloaded'); return child; }
|
||||
async function finish(child: Page, target: string) { const closed = child.waitForEvent('close'); await child.evaluate(url => { window.setTimeout(() => { location.href = url; }, 0); }, target); await closed; expect(child.isClosed()).toBe(true); }
|
||||
async function ownedJob(api: APIRequestContext, owner: Owner, title: string) { const company = await json<{ id: number; name: string }>(await api.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: `CLIENT-GUARD company ${title}` } }), 201); const job = await json<{ id: number; jobTitle: string }>(await api.post(`${apiUrl}/jobapplications`, { headers: owner.headers, data: { companyId: company.id, jobTitle: title, status: 'Saved' } }), 201); return { ...job, companyName: company.name }; }
|
||||
async function importDialog(page: Page) { await keyboard(page, page.getByRole('button', { name: copy('correspondenceImportEmail'), exact: true })); const dialog = page.getByRole('dialog', { name: copy('correspondenceImportTitle'), exact: true }); await expect(dialog).toBeVisible(); await keyboard(page, dialog.getByRole('tab', { name: copy('google'), exact: true })); return dialog; }
|
||||
|
||||
test('[P7-09 OAuth sender] actual local child proxy and callback origin bind one terminal Connections refresh while forged and duplicate messages are ignored', async ({ page, context, session }, info) => {
|
||||
test.setTimeout(150_000); await childRoutes(context); const gmailRead = response(page, 'GET', '/gmail/status'); const microsoftRead = response(page, 'GET', '/microsoft-graph/status'); await enter(page, '/settings/connected-accounts'); expect((await gmailRead).status()).toBe(200); expect((await microsoftRead).status()).toBe(200);
|
||||
const initialGmail = count(session, '/gmail/status').length; const initialMicrosoft = count(session, '/microsoft-graph/status').length;
|
||||
await page.evaluate(() => window.postMessage({ source: 'jobtracker-gmail-oauth', status: 'connected', message: 'CLIENT-GUARD unsolicited-self' }, location.origin)); await received(page, 'CLIENT-GUARD unsolicited-self'); expect(count(session, '/gmail/status')).toHaveLength(initialGmail); expect(count(session, '/microsoft-graph/status')).toHaveLength(initialMicrosoft);
|
||||
const fixtures = [];
|
||||
for (const provider of ['gmail', 'microsoft-graph'] as const) {
|
||||
const projection = await projectConnect(page, session.owner, provider); fixtures.push({ provider, ...projection }); const path = `/${provider}/status`; const otherPath = provider === 'gmail' ? '/microsoft-graph/status' : '/gmail/status'; const before = count(session, path).length; const otherBefore = count(session, otherPath).length;
|
||||
const child = await openChild(page, connectRow(page, provider).getByRole('button', { name: copy('emailConnectionsConnect'), exact: true })); const opened = (await proof(page)).opens.at(-1)!; expect(opened).toMatchObject({ url: projection.authorize, name: provider === 'gmail' ? 'jobtracker-gmail-connect' : 'jobtracker-microsoft-connect', features: 'width=620,height=760,resizable=yes,scrollbars=yes', blocked: false }); expect(new URL(opened.url).searchParams.get('redirect_uri')).toBe(`${projection.callbackOrigin}${projection.callbackPath}`);
|
||||
const rogueOpened = page.waitForEvent('popup'); const wrongWindowMarker = `CLIENT-GUARD wrong-window ${provider}`; await page.evaluate(url => { window.open(url, 'client-guard-wrong-window', 'width=360,height=240'); }, childUrl(provider, 'forged', wrongWindowMarker)); const rogue = await rogueOpened; await received(page, wrongWindowMarker); await expect.poll(() => rogue.isClosed()).toBe(true); expect((await proof(page)).messages.find(message => message.message === wrongWindowMarker)).toMatchObject({ origin: uiOrigin, sourceOpenName: 'client-guard-wrong-window', sourceClosed: true }); expect(count(session, path)).toHaveLength(before);
|
||||
const wrongProvider = provider === 'gmail' ? 'microsoft-graph' : 'gmail'; const wrongProviderMarker = `CLIENT-GUARD wrong-provider ${provider}`; await child.evaluate(({ source, marker, origin }) => window.opener.postMessage({ source, status: 'connected', message: marker }, origin), { source: discriminator(wrongProvider), marker: wrongProviderMarker, origin: uiOrigin }); await received(page, wrongProviderMarker); expect((await proof(page)).messages.find(message => message.message === wrongProviderMarker)).toMatchObject({ origin: uiOrigin, sourceOpenName: provider === 'gmail' ? 'jobtracker-gmail-connect' : 'jobtracker-microsoft-connect', sourceClosed: false }); expect(count(session, path)).toHaveLength(before); expect(count(session, otherPath)).toHaveLength(otherBefore);
|
||||
const wrongOriginMarker = `CLIENT-GUARD wrong-origin ${provider}`; await child.goto(childUrl(provider, 'wrong-origin', wrongOriginMarker, 'http://localhost:5302')); await received(page, wrongOriginMarker); expect((await proof(page)).messages.find(message => message.message === wrongOriginMarker)).toMatchObject({ origin: 'http://localhost:5302', sourceOpenName: provider === 'gmail' ? 'jobtracker-gmail-connect' : 'jobtracker-microsoft-connect', sourceClosed: false }); expect(count(session, path)).toHaveLength(before);
|
||||
const validMarker = `CLIENT-GUARD valid-terminal ${provider}`; await finish(child, childUrl(provider, 'valid', validMarker)); await received(page, validMarker); await expect.poll(() => count(session, path).length).toBe(before + 1); expect(count(session, otherPath)).toHaveLength(otherBefore); const matched = (await proof(page)).messages.filter(message => message.message === validMarker); expect(matched).toHaveLength(2); for (const message of matched) expect(message).toMatchObject({ origin: projection.callbackOrigin, sourceOpenName: provider === 'gmail' ? 'jobtracker-gmail-connect' : 'jobtracker-microsoft-connect', sourceClosed: true, sourceIsSelf: false }); await disconnected(page.request); await expect(connectRow(page, provider).getByRole('button', { name: copy('emailConnectionsDisconnect'), exact: true })).toHaveCount(0);
|
||||
}
|
||||
expect(session.requests.filter(request => request.method !== 'GET')).toEqual([]); await info.attach('connections-client-guard-popup-proof', { body: JSON.stringify({ boundary, fixtures, actualWindowMessages: await proof(page), actualServerConnections: 'All three status endpoints remained connected=false before/after. No grant, token exchange or mailbox operation.' }), contentType: 'application/json' }); await info.attach('p709-client-guard-connections-disconnected', { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
|
||||
});
|
||||
|
||||
test('[P7-09 OAuth sender] Timeline ignores forged senders and stale-job callbacks while one matched child starts only the canonical owned-job read flow', async ({ page, context, session }, info) => {
|
||||
test.setTimeout(150_000); const first = await ownedJob(page.request, session.owner, 'CLIENT-GUARD first owned role'); const second = await ownedJob(page.request, session.owner, 'CLIENT-GUARD second owned role'); await childRoutes(context); await projectConnect(page, session.owner, 'gmail'); const candidateQueries: RecordedRequest[] = [];
|
||||
await page.route(`${apiUrl}/gmail/job-candidates?**`, async route => { const url = new URL(route.request().url()); expect(route.request().method()).toBe('GET'); expect(url.searchParams.get('jobApplicationId')).toBe(String(first.id)); expect(url.searchParams.get('maxResultsPerQuery')).toBe('6'); expect(url.searchParams.get('queryOverride')).toBeNull(); expect((await json<{ id: number }>(await page.request.get(`${apiUrl}/jobapplications/${first.id}`))).id).toBe(first.id); candidateQueries.push({ method: 'GET', path: url.pathname, query: url.search }); return route.fulfill({ status: 200, json: { jobApplicationId: first.id, jobTitle: first.jobTitle, companyName: first.companyName, queries: [], threads: [] } }); });
|
||||
await enter(page, `/jobs/${first.id}?section=timeline&sub=correspondence`); const dialog = await importDialog(page); const before = count(session, '/gmail/status').length;
|
||||
await page.evaluate(() => window.postMessage({ source: 'jobtracker-gmail-oauth', status: 'connected', message: 'CLIENT-GUARD timeline unsolicited-self' }, location.origin)); await received(page, 'CLIENT-GUARD timeline unsolicited-self'); expect(candidateQueries).toEqual([]); expect(count(session, '/gmail/status')).toHaveLength(before);
|
||||
const child = await openChild(page, dialog.getByRole('button', { name: copy('correspondenceConnectGmail'), exact: true })); await finish(child, childUrl('gmail', 'valid', 'CLIENT-GUARD timeline valid-terminal')); await received(page, 'CLIENT-GUARD timeline valid-terminal'); await expect.poll(() => candidateQueries.length).toBe(1); await expect.poll(() => count(session, '/gmail/status').length).toBe(before + 1); const matched = (await proof(page)).messages.filter(message => message.message === 'CLIENT-GUARD timeline valid-terminal'); expect(matched).toHaveLength(2); for (const message of matched) expect(message).toMatchObject({ origin: uiOrigin, sourceOpenName: 'jobtracker-gmail-connect', sourceClosed: true }); await disconnected(page.request);
|
||||
const stale = await openChild(page, dialog.getByRole('button', { name: copy('correspondenceConnectGmail'), exact: true })); await page.keyboard.press('Escape'); await expect(dialog).toHaveCount(0); await page.keyboard.press('Control+k'); const palette = page.getByRole('dialog', { name: copy('quickSearch'), exact: true }); await palette.getByRole('textbox', { name: copy('quickSearch'), exact: true }).fill(second.jobTitle); await keyboard(page, palette.getByRole('button').filter({ hasText: second.jobTitle }).first()); await expect.poll(() => new URL(page.url()).pathname).toBe(`/jobs/${second.id}`); const afterNavigation = count(session, '/gmail/status').length;
|
||||
await finish(stale, childUrl('gmail', 'valid', 'CLIENT-GUARD stale first-job terminal')); await received(page, 'CLIENT-GUARD stale first-job terminal'); expect(candidateQueries).toHaveLength(1); expect(count(session, '/gmail/status')).toHaveLength(afterNavigation); await disconnected(page.request); expect(session.requests.filter(request => request.method !== 'GET')).toEqual([]);
|
||||
await info.attach('timeline-client-guard-popup-proof', { body: JSON.stringify({ boundary, firstJobId: first.id, secondJobId: second.id, candidateQueries, actualWindowMessages: await proof(page), qualification: 'Candidate GET is an explicit empty CLIENT-GUARD projection after actual owner authorization; server Gmail remains disconnected and no provider search occurs. Stale callback follows actual command-palette navigation/unmount.' }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P7-09 OAuth sender] actual unconfigured Microsoft failure and anonymous401 stay separate from controlled popup-blocked current-language recovery and session disposal', async ({ page, context, session, playwright }, info) => {
|
||||
test.setTimeout(150_000); await childRoutes(context); await enter(page, '/settings/connected-accounts'); const actualFailure = response(page, 'GET', '/microsoft-graph/connect-url'); await keyboard(page, connectRow(page, 'microsoft-graph').getByRole('button', { name: copy('emailConnectionsConnect'), exact: true })); const unconfigured = await actualFailure; expect(unconfigured.status()).toBe(500); expect((await proof(page)).opens).toEqual([]); await expect(page.getByRole('alert').filter({ hasText: 'An error occurred while' })).toBeVisible();
|
||||
const gate = hold(); try {
|
||||
await projectConnect(page, session.owner, 'gmail', gate); await page.evaluate(() => { (window as InstrumentedWindow).__clientGuardProof.blockNext = true; }); const connectRequest = page.waitForRequest(request => new URL(request.url()).pathname === '/api/gmail/connect-url'); await keyboard(page, connectRow(page, 'gmail').getByRole('button', { name: copy('emailConnectionsConnect'), exact: true })); await connectRequest;
|
||||
const languageSave = response(page, 'PUT', '/auth/preferences/language'); await keyboard(page, page.locator('header').getByRole('button', { name: 'Norsk', exact: true })); expect((await languageSave).status()).toBe(204); gate.release(); await expect(page.getByRole('alert').filter({ hasText: copy('emailConnectionsPopupBlocked', 'nb') })).toBeVisible(); const blocked = (await proof(page)).opens.at(-1)!; expect(blocked).toMatchObject({ blocked: true, closed: null, name: 'jobtracker-gmail-connect' }); await disconnected(page.request);
|
||||
} finally { gate.release(); }
|
||||
const anonymous = await playwright.request.newContext(); try { for (const provider of ['gmail', 'microsoft-graph']) expect((await anonymous.get(`${apiUrl}/${provider}/connect-url`)).status()).toBe(401); } finally { await anonymous.dispose(); }
|
||||
await info.attach('oauth-client-guard-startup-and-counted-block', { body: JSON.stringify({ boundary, actualMicrosoftConnectStatus: unconfigured.status(), configurationQualification: 'MicrosoftGraphOAuthService reads Microsoft:ClientId only; checked-in isolated development config lacks it and default exception handling returns500. No503/Gmail-unconfigured assumption.', actualAnonymousConnectStatuses: [401, 401], popupProofBeforeSessionDisposal: await proof(page), qualification: 'Popup blocking is a controlled null-return fixture. Current-language fallback is an actual UI language PUT204 during held authenticated controlled connect GET. Anonymous401 is real separate API evidence, not a fabricated browser response.' }), contentType: 'application/json' });
|
||||
await page.unroute(`${apiUrl}/gmail/connect-url`); expect((await page.request.post(`${apiUrl}/auth/logout`, { headers: session.owner.headers })).status()).toBe(204); await expect(page).toHaveURL(/\/login(?:\?|$)/); await expect(page.locator('main').getByText('Outlook / Microsoft 365', { exact: true })).toHaveCount(0); await info.attach('oauth-client-guard-session-disposal', { body: JSON.stringify({ qualification: 'Actual logout clears shared cookies. The existing cookie-check session boundary disposes the earlier Connections UI; this is separate from the actual anonymous401 API checks above, not a fabricated browser401 response.', currentPath: new URL(page.url()).pathname }), contentType: 'application/json' });
|
||||
});
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
import { useDialogActions } from "../dialogs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import ViewStateNotice from "./ViewStateNotice";
|
||||
import { emailOAuthCallbackOrigin, emailOAuthMessage, type EmailOAuthAttempt } from "../emailOAuth";
|
||||
|
||||
function parseRawEmail(raw: string): { subject?: string; date?: string; from?: string; to?: string; body: string } {
|
||||
const lines = raw.replace(/\r\n/g, "\n").split("\n");
|
||||
@@ -155,6 +156,16 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
||||
const [manageNote, setManageNote] = useState("");
|
||||
const [manageSaving, setManageSaving] = useState(false);
|
||||
const autoRefreshKeyRef = useRef<string | null>(null);
|
||||
const gmailOAuth = useRef<{ active: boolean; generation: number; starting: boolean; attempt: EmailOAuthAttempt | null }>({ active: true, generation: 0, starting: false, attempt: null });
|
||||
const startupTranslation = useRef(t);
|
||||
|
||||
useEffect(() => { startupTranslation.current = t; }, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
const session = gmailOAuth.current;
|
||||
session.active = true;
|
||||
return () => { session.active = false; session.generation += 1; session.starting = false; session.attempt = null; };
|
||||
}, [jobId]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setMessagesLoading(true);
|
||||
@@ -292,8 +303,11 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
const data = event.data as { source?: string; status?: string; message?: string };
|
||||
if (data?.source !== "jobtracker-gmail-oauth") return;
|
||||
const session = gmailOAuth.current;
|
||||
if (!session.active || !session.attempt) return;
|
||||
const data = emailOAuthMessage(event, session.attempt);
|
||||
if (!data) return;
|
||||
session.attempt = null;
|
||||
if (data.status === "connected") {
|
||||
toast(data.message || t("googleLinkedSuccess"), "success");
|
||||
void loadGmailStatus();
|
||||
@@ -356,12 +370,24 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
||||
};
|
||||
|
||||
const connectGmail = async () => {
|
||||
const session = gmailOAuth.current;
|
||||
if (!session.active || session.starting) return;
|
||||
if (session.attempt && !session.attempt.popup.closed) { session.attempt.popup.focus(); return; }
|
||||
session.attempt = null;
|
||||
session.starting = true;
|
||||
const generation = session.generation;
|
||||
try {
|
||||
const res = await api.get<{ url: string }>("/gmail/connect-url");
|
||||
if (!session.active || generation !== session.generation) return;
|
||||
const origin = emailOAuthCallbackOrigin(res.data.url, "gmail");
|
||||
if (!origin) { toast(startupTranslation.current("correspondenceStartGmailFailed"), "error"); return; }
|
||||
const popup = window.open(res.data.url, "jobtracker-gmail-connect", "width=620,height=760,resizable=yes,scrollbars=yes");
|
||||
if (!popup) toast(t("correspondenceBlockedPopup"), "error");
|
||||
if (!popup) toast(startupTranslation.current("correspondenceBlockedPopup"), "error");
|
||||
else session.attempt = { provider: "gmail", origin, popup };
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("correspondenceStartGmailFailed")), "error");
|
||||
if (session.active && generation === session.generation) toast(getApiErrorMessage(error, startupTranslation.current("correspondenceStartGmailFailed")), "error");
|
||||
} finally {
|
||||
if (generation === session.generation) session.starting = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Box, Button, Checkbox, Chip, Divider, FormControlLabel, Paper, Stack, TextField, Typography } from "@mui/material";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
@@ -7,6 +7,7 @@ import { api, getApiErrorMessage } from "../api";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useToast } from "../toast";
|
||||
import type { GmailStatus, ImapStatus, MicrosoftGraphStatus } from "../types";
|
||||
import { emailOAuthCallbackOrigin, emailOAuthMessage, type EmailOAuthAttempt, type EmailOAuthProvider } from "../emailOAuth";
|
||||
|
||||
// Settings > Account: connect/disconnect each linked-mailbox provider. Gmail and Microsoft use
|
||||
// the same OAuth-popup + postMessage handshake (mirrored server-side in GmailController /
|
||||
@@ -22,6 +23,16 @@ export default function EmailProviderConnections() {
|
||||
|
||||
const [imapForm, setImapForm] = useState({ host: "", port: 993, useSsl: true, username: "", password: "" });
|
||||
const [imapConnecting, setImapConnecting] = useState(false);
|
||||
const oauth = useRef({ mounted: true, generation: 0, starting: new Set<EmailOAuthProvider>(), attempts: new Map<EmailOAuthProvider, EmailOAuthAttempt>() });
|
||||
const startupTranslation = useRef(t);
|
||||
|
||||
useEffect(() => { startupTranslation.current = t; }, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
const session = oauth.current;
|
||||
session.mounted = true;
|
||||
return () => { session.mounted = false; session.generation += 1; session.starting.clear(); session.attempts.clear(); };
|
||||
}, []);
|
||||
|
||||
const loadGmailStatus = useCallback(async () => {
|
||||
try {
|
||||
@@ -58,21 +69,18 @@ export default function EmailProviderConnections() {
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
const data = event.data as { source?: string; status?: string; message?: string };
|
||||
if (data?.source === "jobtracker-gmail-oauth") {
|
||||
if (!oauth.current.mounted) return;
|
||||
for (const [provider, attempt] of oauth.current.attempts) {
|
||||
const data = emailOAuthMessage(event, attempt);
|
||||
if (!data) continue;
|
||||
oauth.current.attempts.delete(provider);
|
||||
if (data.status === "connected") {
|
||||
toast(data.message || t("emailConnectionsGmailConnected"), "success");
|
||||
void loadGmailStatus();
|
||||
toast(data.message || t(provider === "gmail" ? "emailConnectionsGmailConnected" : "emailConnectionsOutlookConnected"), "success");
|
||||
void (provider === "gmail" ? loadGmailStatus() : loadMicrosoftStatus());
|
||||
} else {
|
||||
toast(data.message || t("emailConnectionsGmailFailed"), "error");
|
||||
}
|
||||
} else if (data?.source === "jobtracker-microsoft-oauth") {
|
||||
if (data.status === "connected") {
|
||||
toast(data.message || t("emailConnectionsOutlookConnected"), "success");
|
||||
void loadMicrosoftStatus();
|
||||
} else {
|
||||
toast(data.message || t("emailConnectionsOutlookFailed"), "error");
|
||||
toast(data.message || t(provider === "gmail" ? "emailConnectionsGmailFailed" : "emailConnectionsOutlookFailed"), "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -80,13 +88,26 @@ export default function EmailProviderConnections() {
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, [loadGmailStatus, loadMicrosoftStatus, t, toast]);
|
||||
|
||||
const connectViaPopup = async (connectUrlPath: string, popupName: string, providerLabel: string) => {
|
||||
const connectViaPopup = async (provider: EmailOAuthProvider, connectUrlPath: string, popupName: string, providerLabel: string) => {
|
||||
const session = oauth.current;
|
||||
if (!session.mounted || session.starting.has(provider)) return;
|
||||
const previous = session.attempts.get(provider);
|
||||
if (previous && !previous.popup.closed) { previous.popup.focus(); return; }
|
||||
session.attempts.delete(provider);
|
||||
session.starting.add(provider);
|
||||
const generation = session.generation;
|
||||
try {
|
||||
const res = await api.get<{ url: string }>(connectUrlPath);
|
||||
if (!session.mounted || generation !== session.generation) return;
|
||||
const origin = emailOAuthCallbackOrigin(res.data.url, provider);
|
||||
if (!origin) { toast(startupTranslation.current("emailConnectionsStartFailed", { provider: providerLabel }), "error"); return; }
|
||||
const popup = window.open(res.data.url, popupName, "width=620,height=760,resizable=yes,scrollbars=yes");
|
||||
if (!popup) toast(t("emailConnectionsPopupBlocked"), "error");
|
||||
if (!popup) toast(startupTranslation.current("emailConnectionsPopupBlocked"), "error");
|
||||
else session.attempts.set(provider, { provider, origin, popup });
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("emailConnectionsStartFailed", { provider: providerLabel })), "error");
|
||||
if (session.mounted && generation === session.generation) toast(getApiErrorMessage(error, startupTranslation.current("emailConnectionsStartFailed", { provider: providerLabel })), "error");
|
||||
} finally {
|
||||
if (generation === session.generation) session.starting.delete(provider);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -130,7 +151,7 @@ export default function EmailProviderConnections() {
|
||||
label="Gmail"
|
||||
connected={Boolean(gmailStatus?.connected)}
|
||||
address={gmailStatus?.gmailAddress ?? null}
|
||||
onConnect={() => void connectViaPopup("/gmail/connect-url", "jobtracker-gmail-connect", "Gmail")}
|
||||
onConnect={() => void connectViaPopup("gmail", "/gmail/connect-url", "jobtracker-gmail-connect", "Gmail")}
|
||||
onDisconnect={() => void disconnect("/gmail/connection", loadGmailStatus, "Gmail")}
|
||||
/>
|
||||
|
||||
@@ -140,7 +161,7 @@ export default function EmailProviderConnections() {
|
||||
label="Outlook / Microsoft 365"
|
||||
connected={Boolean(microsoftStatus?.connected)}
|
||||
address={microsoftStatus?.mailAddress ?? null}
|
||||
onConnect={() => void connectViaPopup("/microsoft-graph/connect-url", "jobtracker-microsoft-connect", "Outlook")}
|
||||
onConnect={() => void connectViaPopup("microsoft", "/microsoft-graph/connect-url", "jobtracker-microsoft-connect", "Outlook")}
|
||||
onDisconnect={() => void disconnect("/microsoft-graph/connection", loadMicrosoftStatus, "Outlook")}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { ConfirmProvider } from "./confirm";
|
||||
import { PromptProvider } from "./prompt";
|
||||
import { ToastProvider } from "./toast";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import Correspondence from "./components/Correspondence";
|
||||
import { api } from "./api";
|
||||
import { translations } from "./i18n/translations";
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
@@ -19,31 +20,47 @@ jest.mock("./api", () => ({
|
||||
delete: jest.fn(),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
getApiErrorMessage: (_error: unknown, fallback: string) => fallback,
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
const originalWindowOpen = window.open;
|
||||
const gmailCallbackOrigin = "http://localhost:3000";
|
||||
const gmailAuthorizationUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=test-client&redirect_uri=${encodeURIComponent(`${gmailCallbackOrigin}/api/gmail/oauth/callback`)}&response_type=code&state=opaque-test-state`;
|
||||
|
||||
function renderDialog() {
|
||||
return render(
|
||||
function dialog(jobId = 42) {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<ConfirmProvider>
|
||||
<PromptProvider>
|
||||
<Correspondence
|
||||
jobId={42}
|
||||
jobId={jobId}
|
||||
jobContext={{ jobTitle: "Backend Developer", companyName: "Acme", recruiterEmail: "maria@acme.test" }}
|
||||
/>
|
||||
</PromptProvider>
|
||||
</ConfirmProvider>
|
||||
</I18nProvider>
|
||||
</ToastProvider>,
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDialog() {
|
||||
return render(dialog());
|
||||
}
|
||||
|
||||
async function oauthMessage(data: unknown, source: Window | null, origin = gmailCallbackOrigin) {
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new MessageEvent("message", { data, source, origin }));
|
||||
});
|
||||
}
|
||||
|
||||
describe("correspondence Gmail import", () => {
|
||||
let correspondenceMessages: any[];
|
||||
let gmailConnected: boolean;
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: jest.fn().mockResolvedValue(undefined),
|
||||
@@ -52,6 +69,7 @@ describe("correspondence Gmail import", () => {
|
||||
window.open = jest.fn();
|
||||
|
||||
correspondenceMessages = [];
|
||||
gmailConnected = true;
|
||||
|
||||
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
||||
if (url === "/jobapplications/choices") {
|
||||
@@ -97,7 +115,10 @@ describe("correspondence Gmail import", () => {
|
||||
return Promise.resolve({ data: correspondenceMessages } as any);
|
||||
}
|
||||
if (url === "/gmail/status") {
|
||||
return Promise.resolve({ data: { connected: true, gmailAddress: "user@example.test", lastSyncedAt: new Date().toISOString(), lastSyncAttemptedAt: new Date().toISOString(), lastSyncMode: "list-messages", lastSyncSource: "custom-query", lastSyncStatus: "error", lastSyncError: "Token refresh failed" } } as any);
|
||||
return Promise.resolve({ data: { connected: gmailConnected, gmailAddress: "user@example.test", lastSyncedAt: new Date().toISOString(), lastSyncAttemptedAt: new Date().toISOString(), lastSyncMode: "list-messages", lastSyncSource: "custom-query", lastSyncStatus: "error", lastSyncError: "Token refresh failed" } } as any);
|
||||
}
|
||||
if (url === "/gmail/connect-url") {
|
||||
return Promise.resolve({ data: { url: gmailAuthorizationUrl } } as any);
|
||||
}
|
||||
if (url === "/gmail/job-candidates") {
|
||||
return Promise.resolve({
|
||||
@@ -265,6 +286,8 @@ describe("correspondence Gmail import", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.open = originalWindowOpen;
|
||||
window.localStorage.clear();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -414,4 +437,152 @@ describe("correspondence Gmail import", () => {
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
test("ignores an unsolicited Gmail success without status, candidate reads or feedback", async () => {
|
||||
gmailConnected = false;
|
||||
renderDialog();
|
||||
await screen.findByText(/no messages yet/i);
|
||||
const initialReads = [...mockedApi.get.mock.calls];
|
||||
|
||||
await oauthMessage({ source: "jobtracker-gmail-oauth", status: "connected", message: "Forged Gmail success" }, window);
|
||||
|
||||
expect(mockedApi.get.mock.calls).toEqual(initialReads);
|
||||
expect(screen.queryByRole("alert", { hidden: true })).not.toBeInTheDocument();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("accepts only the pending Gmail popup and callback origin, including a just-closed popup, once", async () => {
|
||||
gmailConnected = false;
|
||||
const popup = { closed: false, focus: jest.fn() } as unknown as Window;
|
||||
(window.open as jest.Mock).mockReturnValue(popup);
|
||||
renderDialog();
|
||||
fireEvent.click(await screen.findByRole("button", { name: /import email/i }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /^google$/i }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: /^connect gmail$/i }));
|
||||
await waitFor(() => expect(window.open).toHaveBeenCalledWith(gmailAuthorizationUrl, "jobtracker-gmail-connect", "width=620,height=760,resizable=yes,scrollbars=yes"));
|
||||
const pendingReads = [...mockedApi.get.mock.calls];
|
||||
const connected = { source: "jobtracker-gmail-oauth", status: "connected" };
|
||||
|
||||
await oauthMessage(connected, popup, "https://forged.example.test");
|
||||
await oauthMessage(connected, window);
|
||||
await oauthMessage(connected, null);
|
||||
await oauthMessage({ source: "jobtracker-microsoft-oauth", status: "connected" }, popup);
|
||||
await oauthMessage({ source: "jobtracker-gmail-oauth", status: "pending" }, popup);
|
||||
await oauthMessage({ source: "jobtracker-gmail-oauth", status: "connected", message: 42 }, popup);
|
||||
await oauthMessage(null, popup);
|
||||
|
||||
expect(mockedApi.get.mock.calls).toEqual(pendingReads);
|
||||
expect(screen.queryByRole("alert", { hidden: true })).not.toBeInTheDocument();
|
||||
gmailConnected = true;
|
||||
Object.assign(popup, { closed: true });
|
||||
await oauthMessage(connected, popup);
|
||||
|
||||
expect(await screen.findByText("Google account linked.")).toBeInTheDocument();
|
||||
await screen.findByLabelText(/search gmail/i);
|
||||
expect(mockedApi.get.mock.calls.filter(([url]) => url === "/gmail/status")).toHaveLength(2);
|
||||
expect(mockedApi.get).toHaveBeenCalledWith("/gmail/job-candidates", expect.objectContaining({ params: expect.objectContaining({ jobApplicationId: 42 }) }));
|
||||
const acceptedReads = [...mockedApi.get.mock.calls];
|
||||
|
||||
await oauthMessage({ ...connected, message: "Duplicate terminal callback" }, popup);
|
||||
|
||||
expect(mockedApi.get.mock.calls).toEqual(acceptedReads);
|
||||
expect(screen.queryByText("Duplicate terminal callback")).not.toBeInTheDocument();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("keeps a pending Gmail popup across locale changes and reports terminal error in the current locale once", async () => {
|
||||
gmailConnected = false;
|
||||
const popup = { closed: false, focus: jest.fn() } as unknown as Window;
|
||||
(window.open as jest.Mock).mockReturnValue(popup);
|
||||
renderDialog();
|
||||
fireEvent.click(await screen.findByRole("button", { name: /import email/i }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /^google$/i }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: /^connect gmail$/i }));
|
||||
await waitFor(() => expect(window.open).toHaveBeenCalledTimes(1));
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" }));
|
||||
});
|
||||
await screen.findByRole("button", { name: /^koble til gmail$/i });
|
||||
const pendingReads = [...mockedApi.get.mock.calls];
|
||||
|
||||
await oauthMessage({ source: "jobtracker-gmail-oauth", status: "error" }, popup);
|
||||
|
||||
expect(await screen.findByText("Google-autentisering mislyktes.")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Google authentication failed.")).not.toBeInTheDocument();
|
||||
expect(mockedApi.get.mock.calls).toEqual(pendingReads);
|
||||
await oauthMessage({ source: "jobtracker-gmail-oauth", status: "connected", message: "Error callback already consumed" }, popup);
|
||||
expect(mockedApi.get.mock.calls).toEqual(pendingReads);
|
||||
expect(screen.queryByText("Error callback already consumed")).not.toBeInTheDocument();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not open a Gmail popup when its connect URL resolves after unmount", async () => {
|
||||
gmailConnected = false;
|
||||
let resolveConnect!: (value: { data: { url: string } }) => void;
|
||||
const connect = new Promise<any>((resolve) => { resolveConnect = resolve; });
|
||||
const get = mockedApi.get.getMockImplementation()!;
|
||||
mockedApi.get.mockImplementation((url: string, config?: any) => url === "/gmail/connect-url" ? connect : get(url, config));
|
||||
const view = renderDialog();
|
||||
fireEvent.click(await screen.findByRole("button", { name: /import email/i }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /^google$/i }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: /^connect gmail$/i }));
|
||||
expect(mockedApi.get).toHaveBeenCalledWith("/gmail/connect-url");
|
||||
view.unmount();
|
||||
const disposedReads = [...mockedApi.get.mock.calls];
|
||||
|
||||
await act(async () => { resolveConnect({ data: { url: gmailAuthorizationUrl } }); });
|
||||
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
expect(mockedApi.get.mock.calls).toEqual(disposedReads);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("invalidates the pending Gmail callback when the correspondence job changes", async () => {
|
||||
gmailConnected = false;
|
||||
const popup = { closed: false, focus: jest.fn() } as unknown as Window;
|
||||
(window.open as jest.Mock).mockReturnValue(popup);
|
||||
const view = renderDialog();
|
||||
fireEvent.click(await screen.findByRole("button", { name: /import email/i }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /^google$/i }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: /^connect gmail$/i }));
|
||||
await waitFor(() => expect(window.open).toHaveBeenCalledTimes(1));
|
||||
|
||||
view.rerender(dialog(43));
|
||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith("/correspondence/43"));
|
||||
const currentJobReads = [...mockedApi.get.mock.calls];
|
||||
await oauthMessage({ source: "jobtracker-gmail-oauth", status: "connected", message: "Previous job Gmail callback" }, popup);
|
||||
|
||||
expect(mockedApi.get.mock.calls).toEqual(currentJobReads);
|
||||
expect(screen.queryByText("Previous job Gmail callback")).not.toBeInTheDocument();
|
||||
expect(mockedApi.get.mock.calls.filter(([url]) => url === "/gmail/job-candidates")).toHaveLength(0);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each(["blocked", "rejected"] as const)("delayed %s Gmail startup feedback follows a locale change while the connect URL is pending", async outcome => {
|
||||
gmailConnected = false;
|
||||
let resolveConnect!: (value: any) => void;
|
||||
let rejectConnect!: (cause: unknown) => void;
|
||||
const pending = new Promise<any>((resolve, reject) => { resolveConnect = resolve; rejectConnect = reject; });
|
||||
const get = mockedApi.get.getMockImplementation()!;
|
||||
mockedApi.get.mockImplementation((url: string, config?: any) => url === "/gmail/connect-url" ? pending : get(url, config));
|
||||
(window.open as jest.Mock).mockReturnValue(null);
|
||||
renderDialog();
|
||||
fireEvent.click(await screen.findByRole("button", { name: /import email/i }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /^google$/i }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: /^connect gmail$/i }));
|
||||
expect(mockedApi.get).toHaveBeenCalledWith("/gmail/connect-url");
|
||||
await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); });
|
||||
await screen.findByRole("button", { name: /^koble til gmail$/i });
|
||||
const reads = [...mockedApi.get.mock.calls];
|
||||
await act(async () => {
|
||||
if (outcome === "blocked") resolveConnect({ data: { url: gmailAuthorizationUrl } });
|
||||
else rejectConnect(new Error("offline"));
|
||||
});
|
||||
const key = outcome === "blocked" ? "correspondenceBlockedPopup" : "correspondenceStartGmailFailed";
|
||||
expect(await screen.findByText(translations.nb[key])).toBeVisible();
|
||||
expect(screen.queryByText(translations.en[key])).not.toBeInTheDocument();
|
||||
expect(mockedApi.get.mock.calls).toEqual(reads); expect(window.open).toHaveBeenCalledTimes(outcome === "blocked" ? 1 : 0);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ToastProvider } from "./toast";
|
||||
import { api } from "./api";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import EmailProviderConnections from "./components/EmailProviderConnections";
|
||||
import { translations } from "./i18n/translations";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: {
|
||||
@@ -19,6 +20,36 @@ jest.mock("./api", () => ({
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (cause: unknown) => void;
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const oauthProviders = [
|
||||
{ name: "Gmail", path: "/gmail", source: "jobtracker-gmail-oauth", popupName: "jobtracker-gmail-connect", auth: "https://accounts.google.com/o/oauth2/v2/auth", failed: "emailConnectionsGmailFailed" },
|
||||
{ name: "Outlook / Microsoft 365", path: "/microsoft-graph", source: "jobtracker-microsoft-oauth", popupName: "jobtracker-microsoft-connect", auth: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", failed: "emailConnectionsOutlookFailed" },
|
||||
] as const;
|
||||
|
||||
function authUrl(provider: typeof oauthProviders[number], callback = `http://localhost:3000/api${provider.path}/oauth/callback`) {
|
||||
return `${provider.auth}?client_id=fixture&state=server-owned-state&redirect_uri=${encodeURIComponent(callback)}`;
|
||||
}
|
||||
|
||||
function popupWindow() { return { closed: false, focus: jest.fn() } as unknown as Window; }
|
||||
function connectButton(name: string) { return within(screen.getByText(name, { exact: true }).parentElement!.parentElement!).getByRole("button", { name: "Connect" }); }
|
||||
function statusReads() { return mockedApi.get.mock.calls.filter(([url]) => String(url).endsWith("/status")); }
|
||||
function message(data: unknown, source: Window | null, origin = "http://localhost:3000") {
|
||||
act(() => { window.dispatchEvent(new MessageEvent("message", { data, source, origin })); });
|
||||
}
|
||||
|
||||
function disconnectedProviderReads() {
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
const provider = oauthProviders.find(value => url === `${value.path}/connect-url`);
|
||||
return Promise.resolve({ data: provider ? { url: authUrl(provider) } : { connected: false } } as any);
|
||||
});
|
||||
}
|
||||
|
||||
function renderComponent() {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
@@ -30,9 +61,12 @@ function renderComponent() {
|
||||
describe("EmailProviderConnections", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedApi.get.mockReset();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it("renders connection controls in Norwegian Bokmål", async () => {
|
||||
window.localStorage.setItem("uiLanguage", "nb");
|
||||
mockedApi.get.mockResolvedValue({ data: { connected: false } } as any);
|
||||
@@ -82,4 +116,109 @@ describe("EmailProviderConnections", () => {
|
||||
password: "secret",
|
||||
})));
|
||||
});
|
||||
|
||||
it("[P7-09 OAuth] unsolicited success and error messages cannot announce or refresh any connection", async () => {
|
||||
disconnectedProviderReads(); renderComponent(); await screen.findByLabelText("IMAP host");
|
||||
await waitFor(() => expect(statusReads()).toHaveLength(3));
|
||||
const foreign = popupWindow();
|
||||
message({ source: "jobtracker-gmail-oauth", status: "connected", message: "Forged Gmail success" }, foreign);
|
||||
message({ source: "jobtracker-microsoft-oauth", status: "error", message: "Forged Outlook failure" }, foreign);
|
||||
expect(screen.queryByText("Forged Gmail success")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Forged Outlook failure")).not.toBeInTheDocument();
|
||||
expect(statusReads()).toHaveLength(3);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(oauthProviders)("[P7-09 OAuth] $name accepts only its captured popup and canonical callback origin once", async provider => {
|
||||
disconnectedProviderReads(); const popup = popupWindow(); const other = popupWindow();
|
||||
const opened = jest.spyOn(window, "open").mockReturnValue(popup);
|
||||
renderComponent(); await screen.findByLabelText("IMAP host");
|
||||
fireEvent.click(connectButton(provider.name));
|
||||
await waitFor(() => expect(opened).toHaveBeenCalledWith(authUrl(provider), provider.popupName, "width=620,height=760,resizable=yes,scrollbars=yes"));
|
||||
const valid = { source: provider.source, status: "connected", message: "Verified mailbox callback" };
|
||||
for (const [data, sender, origin] of [
|
||||
[valid, popup, "https://attacker.example.test"], [valid, popup, "http://localhost:5202"],
|
||||
[valid, other, "http://localhost:3000"], [valid, null, "http://localhost:3000"],
|
||||
[{ ...valid, source: provider.source === "jobtracker-gmail-oauth" ? "jobtracker-microsoft-oauth" : "jobtracker-gmail-oauth" }, popup, "http://localhost:3000"],
|
||||
[{ ...valid, status: "unexpected" }, popup, "http://localhost:3000"],
|
||||
[{ ...valid, message: { unsafe: "not text" } }, popup, "http://localhost:3000"],
|
||||
[null, popup, "http://localhost:3000"], [[valid], popup, "http://localhost:3000"],
|
||||
] as Array<[unknown, Window | null, string]>) {
|
||||
message(data, sender, origin);
|
||||
expect(screen.queryByText(valid.message)).not.toBeInTheDocument(); expect(statusReads()).toHaveLength(3);
|
||||
}
|
||||
expect(screen.queryByText(valid.message)).not.toBeInTheDocument(); expect(statusReads()).toHaveLength(3);
|
||||
Object.defineProperty(popup, "closed", { value: true });
|
||||
message(valid, popup); message(valid, popup);
|
||||
expect(await screen.findByText(valid.message)).toBeVisible();
|
||||
await waitFor(() => expect(statusReads()).toHaveLength(4));
|
||||
expect(statusReads().at(-1)?.[0]).toBe(`${provider.path}/status`);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("[P7-09 OAuth] a pending popup survives a locale change and valid error uses current fallback without refreshing", async () => {
|
||||
disconnectedProviderReads(); const provider = oauthProviders[1]; const popup = popupWindow();
|
||||
jest.spyOn(window, "open").mockReturnValue(popup); renderComponent(); await screen.findByLabelText("IMAP host");
|
||||
fireEvent.click(connectButton(provider.name)); await waitFor(() => expect(window.open).toHaveBeenCalledTimes(1));
|
||||
await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); });
|
||||
message({ source: provider.source, status: "error" }, popup);
|
||||
expect(await screen.findByText(translations.nb.emailConnectionsOutlookFailed)).toBeVisible();
|
||||
message({ source: provider.source, status: "connected", message: "Replayed terminal callback" }, popup);
|
||||
expect(screen.queryByText("Replayed terminal callback")).not.toBeInTheDocument(); expect(statusReads()).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("[P7-09 OAuth] duplicate starts reserve one named popup until it is closed", async () => {
|
||||
disconnectedProviderReads(); const pending = deferred<any>();
|
||||
mockedApi.get.mockImplementation((url: string) => url === "/gmail/connect-url" ? pending.promise : Promise.resolve({ data: { connected: false } } as any));
|
||||
const popup = popupWindow(); jest.spyOn(window, "open").mockReturnValue(popup);
|
||||
renderComponent(); await screen.findByLabelText("IMAP host"); const button = connectButton("Gmail");
|
||||
act(() => { fireEvent.click(button); fireEvent.click(button); });
|
||||
expect(mockedApi.get.mock.calls.filter(([url]) => url === "/gmail/connect-url")).toHaveLength(1);
|
||||
await act(async () => pending.resolve({ data: { url: authUrl(oauthProviders[0]) } }));
|
||||
fireEvent.click(button); expect(window.open).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.get.mock.calls.filter(([url]) => url === "/gmail/connect-url")).toHaveLength(1);
|
||||
expect(popup.focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("[P7-09 OAuth] blocked popups and late disposed connect responses cannot establish a callback sender", async () => {
|
||||
disconnectedProviderReads(); const opened = jest.spyOn(window, "open").mockReturnValue(null);
|
||||
const first = renderComponent(); await screen.findByLabelText("IMAP host"); fireEvent.click(connectButton("Gmail"));
|
||||
expect(await screen.findByText(translations.en.emailConnectionsPopupBlocked)).toBeVisible();
|
||||
message({ source: "jobtracker-gmail-oauth", status: "connected", message: "Blocked popup forged success" }, popupWindow());
|
||||
expect(screen.queryByText("Blocked popup forged success")).not.toBeInTheDocument(); expect(statusReads()).toHaveLength(3);
|
||||
first.unmount();
|
||||
const pending = deferred<any>(); mockedApi.get.mockImplementation((url: string) => url === "/gmail/connect-url" ? pending.promise : Promise.resolve({ data: { connected: false } } as any));
|
||||
const second = renderComponent(); await screen.findByLabelText("IMAP host"); fireEvent.click(connectButton("Gmail")); second.unmount();
|
||||
await act(async () => pending.resolve({ data: { url: authUrl(oauthProviders[0]) } }));
|
||||
expect(opened).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("[P7-09 OAuth] malformed or non-provider callback targets never launch a popup", async () => {
|
||||
disconnectedProviderReads(); const opened = jest.spyOn(window, "open").mockReturnValue(popupWindow());
|
||||
const targets = ["javascript:alert(1)", "https://user:password@jobs.example.test/api/gmail/oauth/callback", "https://jobs.example.test/api/microsoft-graph/oauth/callback", "https://jobs.example.test/wrong-callback", "not a URL"];
|
||||
mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === "/gmail/connect-url" ? { url: authUrl(oauthProviders[0], targets.shift()!) } : { connected: false } } as any));
|
||||
renderComponent(); await screen.findByLabelText("IMAP host");
|
||||
for (let index = 0; index < 5; index += 1) { fireEvent.click(connectButton("Gmail")); await waitFor(() => expect(mockedApi.get.mock.calls.filter(([url]) => url === "/gmail/connect-url")).toHaveLength(index + 1)); await act(async () => {}); }
|
||||
expect(opened).not.toHaveBeenCalled(); expect(statusReads()).toHaveLength(3);
|
||||
});
|
||||
|
||||
it.each(["blocked", "rejected"] as const)("[P7-09 OAuth] delayed %s startup feedback uses the language selected while its connect request was pending", async outcome => {
|
||||
const pending = deferred<any>();
|
||||
mockedApi.get.mockImplementation((url: string) => url === "/gmail/connect-url" ? pending.promise : Promise.resolve({ data: { connected: false } } as any));
|
||||
jest.spyOn(window, "open").mockReturnValue(null);
|
||||
renderComponent(); await screen.findByLabelText("IMAP host"); fireEvent.click(connectButton("Gmail"));
|
||||
expect(mockedApi.get.mock.calls.filter(([url]) => url === "/gmail/connect-url")).toHaveLength(1);
|
||||
await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); });
|
||||
await screen.findByLabelText("IMAP-vert");
|
||||
await act(async () => {
|
||||
if (outcome === "blocked") pending.resolve({ data: { url: authUrl(oauthProviders[0]) } });
|
||||
else pending.reject(new Error("offline"));
|
||||
});
|
||||
const expected = outcome === "blocked" ? translations.nb.emailConnectionsPopupBlocked : translations.nb.emailConnectionsStartFailed.replace("{provider}", "Gmail");
|
||||
expect(await screen.findByText(expected)).toBeVisible();
|
||||
const previous = outcome === "blocked" ? translations.en.emailConnectionsPopupBlocked : translations.en.emailConnectionsStartFailed.replace("{provider}", "Gmail");
|
||||
expect(screen.queryByText(previous)).not.toBeInTheDocument(); expect(statusReads()).toHaveLength(3);
|
||||
expect(window.open).toHaveBeenCalledTimes(outcome === "blocked" ? 1 : 0);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export type EmailOAuthProvider = "gmail" | "microsoft";
|
||||
export type EmailOAuthAttempt = { provider: EmailOAuthProvider; origin: string; popup: Window };
|
||||
type TerminalMessage = { status: "connected" | "error"; message?: string };
|
||||
|
||||
export function emailOAuthCallbackOrigin(authorizationUrl: unknown, provider: EmailOAuthProvider): string | null {
|
||||
if (typeof authorizationUrl !== "string") return null;
|
||||
try {
|
||||
const authorization = new URL(authorizationUrl);
|
||||
if (!["https:", "http:"].includes(authorization.protocol) || authorization.username || authorization.password) return null;
|
||||
const redirects = authorization.searchParams.getAll("redirect_uri");
|
||||
if (redirects.length !== 1) return null;
|
||||
const callback = new URL(redirects[0]);
|
||||
const path = provider === "gmail" ? "/api/gmail/oauth/callback" : "/api/microsoft-graph/oauth/callback";
|
||||
return ["https:", "http:"].includes(callback.protocol) && !callback.username && !callback.password
|
||||
&& callback.pathname === path && !callback.search && !callback.hash ? callback.origin : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function emailOAuthMessage(event: MessageEvent, attempt: EmailOAuthAttempt): TerminalMessage | null {
|
||||
if (event.origin !== attempt.origin || event.source !== attempt.popup) return null;
|
||||
const data = event.data as { source?: unknown; status?: unknown; message?: unknown } | null;
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)
|
||||
|| data.source !== (attempt.provider === "gmail" ? "jobtracker-gmail-oauth" : "jobtracker-microsoft-oauth")
|
||||
|| (data.status !== "connected" && data.status !== "error")
|
||||
|| (data.message !== undefined && typeof data.message !== "string")) return null;
|
||||
return { status: data.status, message: data.message };
|
||||
}
|
||||
Reference in New Issue
Block a user