diff --git a/SECURITY.md b/SECURITY.md index a189f59..0deecd2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -142,6 +142,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | Part of RISK-024 | SEC-001 protects token documents from every browser client. | OAUTH-001 must still add transactional refresh/versioning, scope/account binding, disconnect/revocation, IAM/encryption decision, and redacted lifecycle audit. | | RISK-009, part of RISK-024 | OAUTH-001C1H [#441](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/441) replaces browser-generated/session-storage-authoritative Strava OAuth state with a 32-byte server-issued challenge. Source stores only a SHA-256 digest in one browser-denied per-UID secret record, binds it to exact UID and decoded Auth `auth_time`, expires it after ten minutes, and transactionally deletes one match before provider exchange or connection writes. A later begin overwrites the older record; mismatch, expiry, replay, and the concurrent loser stop with one fixed result before Strava. Synthetic tests use mocks and no real callback or provider value. | This source does not deploy either Function or the website, configure Strava, prove the custom App Check guard is enabled, enable native runtime App Check enforcement, inspect production data, or prove live OAuth behavior. #443 separately supplies only the source/test clean-page readiness handoff while preserving initial callback suppression. Scope/account policy, refresh concurrency, provider/local reconciliation, revoke/audit, IAM/encryption, configured Enterprise evidence, native enforcement, and provider/live evidence remain open under [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88). | | RISK-008, RISK-009, part of RISK-024 | OAUTH-001C1I [#443](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/443) preserves initial capability-callback App Check suppression, then permits only a document that began on a normalized Strava callback to initialize the existing Enterprise provider after both current callback locations are clean, Router page state is `null`, and native history state is absent or the exact matching BrowserRouter index/key/empty-user-state record. One single-flight readiness attempt precedes one existing exchange. Before exchange admission, the callback rechecks the clean path, Router entry key, and exact UID, service, Firebase resources, app, attempt, and page lifecycle; dirty or reinjected paths, extra or mismatched history state, missing configuration, provider/readiness failure, or stale mounted context use one fixed failure and make no exchange, while an unmounted completion is inert. Authentication, registration, and shop callbacks are ineligible and keep their existing flow. The method never receives callback values or returns, inspects, logs, or stores the token-ready result. Local/test remain off and ordinary production startup remains eager. | This is source and synthetic-test ordering evidence only. It does not configure an Enterprise key or domains, prove a provider-backed token or Firebase acceptance, enable native `enforceAppCheck`, change a Function or Rule, publish the website, deploy Firebase, configure or contact Strava, inspect production data, or prove live OAuth behavior. App Check initialization is not reversible; later reinjection is scrubbed/discarded but earlier browser, hosting, provider, and network copies are not erased. A lifecycle or reinjection change after exchange admission cannot cancel server or provider work already started; it blocks another exchange and obsolete UI, but reconciliation may still be required. Complete ABUSE-001A2 only after protected provider and exact-revision staging evidence. Account/scope policy, refresh concurrency, reconciliation, revoke/audit, IAM/encryption, deployment, and live proof keep [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88) open. | +| RISK-009, part of RISK-024 | OAUTH-001C1J [#612](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/612) browser source admits a fulfilled Strava exchange result only when it presents exactly two own fields, each an enumerable data field: primitive `ok` equal to `true` and a positive safe-integer `athleteId`. It returns a fresh frozen confirmation. Every other fulfillment or exceptional result inspection throws one fixed local response error; a current callback uses the existing fixed failure screen without navigating, while existing lifecycle guards keep obsolete completions inert. Callable transport rejection remains uninspected and identity-preserving. Browser JavaScript cannot distinguish a transparent Proxy that faithfully presents the contract, so the received object is never retained. The check relies on the page realm's built-in reflection, numeric, and freeze methods; code that replaces those methods before admission can change what this browser-only boundary observes. Synthetic tests use no real callback, account, token, provider, or Firebase project. | This client acknowledgement check does not prove Strava origin, provider exchange, paired Firestore persistence, athlete ownership, approved scopes, server integrity, Firebase or website deployment, or live behavior. A compromised server or browser realm can forge the exact result. Provider response-byte and timeout limits, scope/account policy, provider/local reconciliation, audit, IAM/encryption, configured Enterprise evidence, native enforcement, release order, and provider/live proof remain open under [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88). | | Part of RISK-027 | #99 blocks App Check, Firebase Analytics, and Sentry in local/test and on initial capability-callback startup. OBS-001A1 [#134](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/134) source disables Sentry replay, tracing, browser session tracking, client reports, member user context, breadcrumbs, attachments, transactions, and unapproved default integrations. OBS-001A2 [#139](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/139) source removes every application runtime Firebase Analytics import, initialization, and emission, preserves existing wrapper calls as provider-free no-ops, and removes the direct Waiver SDK bypass. OBS-001A3 [#142](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/142) routed the remaining render, Auth-email, members-only, and App Check browser diagnostics through five fixed outcome identifiers and prohibited direct console calls elsewhere in application/public runtime source. OBS-001A5 [#494](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/494) removes the dormant members-only identifier after its sole caller was retired, leaving the current helper with four fixed outcomes and requiring the old literal to be dropped as unknown input. Synthetic tests use mocks/canaries and make no provider call. | #110/#111 still own any future approved telemetry purpose, consent, event/parameter schema, provider settings, and residual hosted boundary. Source/tests and merge evidence do not prove website publication, the exact `runmprc.com` revision, provider transport/collection, IP/cookie behavior, console history, historical data deletion, retention, access, consent, deletion, or vendor terms. Sentry provider behavior is likewise unverified. #443 completes only the source/test Strava client handoff; native enforcement and the DATA-001A confirmation handoffs remain with ABUSE-001A and their canonical trackers. | | Part of RISK-035 | The Jest environment is repaired, #124 adds the complete frontend suite as a named blocking hosted step, and #126 adds the standalone SPA callback suite. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) merged the lockfile-bound Node 20/Java 17 `Commerce command journal emulator` job using exact demo project `demo-pay002b2-test`, Firestore only, and explicit opt-in. CI-001B4 [#186](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/186) merged through [PR #189](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/189) as `bec7d5e365eacb418563a172029f241f660d9768`, adding the no-new-dependency exact-record checker, named blocking lint step, and clean-checkout step. Exact PR run `29291402007` and post-merge run `29291515653` passed every GitHub job. CI-001B4A [#227](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/227), CI-001B4B [#239](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/239), and PAY-004C1 [#359](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/359) retire four errors and one warning in focused reviewed changes. [PR #391](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/391) and [PR #392](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/392) add three TypeScript files while retiring four TSX errors during reviewed functional work. CI-001B4C [#449](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/449) retires exactly four stale unknown-rule directive records with comment-only same-line substitutions. #492 removes one finding-free dormant JSX file, #505 adds four finding-free TypeScript/TSX files, and #506 adds four more. | The checker now scans 118 frontend files. Its reviewable baseline deliberately records 113 configured errors and 6 warnings as individual file/line/column/rule/message records; it is not a clean-lint claim. Any added, removed, moved, or changed record requires review, every fatal parser/configuration failure rejects with a fixed diagnostic, and the baseline must not be regenerated just to pass. The clean check covers tracked and non-ignored untracked files; ignored dependency/build outputs are outside this proof. Hosted source checks do not configure branch protection, deploy Firebase, or prove Stripe/live behavior. #169, #173, #182, #206, #226, #232, and #238 expand the focused commerce suite; configure/prove branch-required checks and add the broader TEST-001 suite. | | DATA-001A3 / registration confidentiality | DATA-001A3 [#374](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/374) limits the My Account registration callable to one exact stored-UID query. Source no longer reads token email or verification claims and no longer searches registrations by runner email. Matching-email records with no UID or another account's UID are excluded; exact-UID records retain their existing sanitized summary, event lookup, and sort. The page's empty result says only that no upcoming registration is linked and that a signed-out registration may not appear. Synthetic tests use getter and Proxy canaries and make no write or provider call. | This source containment is not deployed or live and does not associate, migrate, repair, or change a registration. Anonymous, historical, mistyped-email, shared-email, and differently owned records stay hidden until a separately approved, audited server association under [#115](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/115). Keep the email index until separate inventory and migration review. Complete protected Function deployment/readback, website publication, exact `runmprc.com` verification, and a made-up account check before describing the boundary as live. | diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index a1985ab..f89acf4 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -1426,37 +1426,51 @@ This small diagram records the changed account-read boundary. It adds no new dat **Status: NOT AVAILABLE YET** -**Purpose:** give a member one plain next step when a Strava connection fails without showing a provider message, callback detail, or technical error on the page. +**Purpose:** give a member one plain next step when a Strava connection fails or the website receives an invalid success answer, without showing a provider message, callback detail, or technical error on the page or falsely returning to My Account. **Approver:** membership lead plus platform/security owner. -**Prerequisites:** issue [#242](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/242) must be merged for source review. Calling the wording live also requires a protected website publication and an exact revision check on `runmprc.com`. This source change does not deploy Firebase, contact Strava, change provider settings, use production data, or prove live behavior. +**Prerequisites:** issues [#242](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/242) and [#612](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/612) must be merged for the current source review. Calling this protection live also requires a protected website publication and an exact revision check on `runmprc.com`. These source changes do not deploy Firebase, contact Strava, change provider settings, use production data, or prove live behavior. + +```mermaid +flowchart LR + A["Made-up request is rejected"] --> F["Show the fixed failure and stay on the cleaned callback page"] + B["Made-up answer arrives without a request error"] --> C{"Exactly the two approved visible plain fields?"} + C -- "No or check fails" --> F + C -- "Yes" --> D["Copy the two approved values into a new result that cannot change"] + D --> E["Return to My Account"] +``` + +Text alternative: a rejected made-up request uses the fixed failure and stays on the cleaned callback page; an answer delivered without a request error does the same when it is invalid or its check fails, and returns to My Account only when it has exactly the two approved visible plain fields, which the website copies into a new result that cannot change. Officer review steps after the source merge: 1. Keep the callback wording marked **NOT AVAILABLE YET**. -2. Ask the platform owner for the exact #242 issue, pull request, merged commit, and synthetic frontend test result. -3. Confirm the tests use only made-up callback values and a mocked exchange result. +2. Ask the platform owner for the exact #242 and #612 issues, pull requests, merged commits, and synthetic frontend test results. +3. Confirm the tests use only made-up callback values and mocked rejected, valid, or invalid exchange answers. 4. Confirm a signed-out visitor sees only the fixed sign-in instruction. 5. Confirm a made-up provider query failure shows `We could not connect Strava. Please return to My Account and try again.` -6. Confirm a made-up exchange failure shows the same sentence. -7. Confirm no made-up provider detail appears on the page or in browser console output. -8. Confirm a missing code or state stops in the page before exchange, and a rejected server state uses the same fixed connection-failure result. -9. Confirm only a successful exchange returns to My Account, and the visible `Back to account` link still works without an exchange. -10. Confirm the failure sentence is announced as an urgent screen-reader alert. -11. Record website publication, `runmprc.com`, Firebase, Strava, production-data, and live-behavior evidence as separate results. +6. Confirm a made-up request error, or an answer delivered without a request error that is not exactly `ok` set to `true` plus a positive whole-number Strava athlete field named `athleteId` that the website can represent exactly, shows the same sentence and stays on the cleaned callback page. +7. Confirm missing fields, extra fields, hidden fields, fields that run code when read, invalid athlete numbers, and exceptional answer checks use the same fixed failure without navigating. +8. Confirm no made-up provider detail, invalid answer detail, or technical error appears on the page or in browser console output. +9. Confirm a missing code or state stops in the page before exchange, and a rejected server state uses the same fixed connection-failure result. +10. Confirm only the exact made-up two-field confirmation returns to My Account. +11. Confirm the website copies those two approved values into a new result that cannot be changed instead of keeping the received answer. +12. Confirm the visible `Back to account` link still works without an exchange. +13. Confirm the failure sentence is announced as an urgent screen-reader alert. +14. Record source, tests, merge, website publication, `runmprc.com`, Firebase, Strava, production-data, and live-behavior evidence as separate results. -**Expected result:** the reviewed source uses one fixed, actionable sentence for both a callback query failure and an exchange failure. It does not inspect, display, or log the rejected exchange value. Existing sign-in, missing-code, missing-state, server-rejection, success, and Back-to-account behavior stays in place. The separate OAUTH-001C1G child adds source-only cleanup of the current browser entry before callback-specific checks or exchange, #443 adds source-only App Check readiness after that cleanup, and #441 adds the source-only server state decision. None erases earlier browser, provider, hosting, or network copies or completes issue #88. +**Expected result:** the reviewed source uses one fixed, actionable sentence for a callback query failure, a request error, or an answer delivered without a request error that does not present exactly the two approved success fields. It does not open, inspect, display, or log a rejected request value. For an answer delivered without a request error, it checks only the structure needed to admit that answer, copies the approved values into a new result that cannot be changed, and does not keep the received answer. Only that exact result returns to My Account. Existing sign-in, missing-code, missing-state, server-rejection, exact-success, and Back-to-account behavior stays in place. A valid-looking browser answer is not independent proof that Strava exchanged the code or that Firebase saved the connection. The separate OAUTH-001C1G child adds source-only cleanup of the current browser entry before callback-specific checks or exchange, #443 adds source-only App Check readiness after that cleanup, and #441 adds the source-only server state decision. None erases earlier browser, provider, hosting, or network copies or completes issue #88. -**Stop conditions:** any real member or Strava account; a request for a callback URL, authorization code, state value, provider error, private browser history, or screenshot containing private values; a real provider call; a production Firebase or Strava change; a raw detail in the page or console; or a claim that source, tests, merge, or a green workflow proves the wording is live. +**Stop conditions:** any real member or Strava account; a request for a callback URL, authorization code, state value, provider answer, provider error, private browser history, or screenshot containing private values; a real provider call; a production Firebase or Strava change; an invalid fulfilled answer that returns to My Account; a raw detail in the page or console; or a claim that source, tests, merge, or a green workflow proves the wording or connection is live. -**Success proof:** for source completion, record the exact #242 issue, reviewed pull request, merged commit, intended old-source failures, green synthetic callback tests, relevant full checks, and independent privacy review. For live availability, separately record the approved website publication, the published revision, and a dated `runmprc.com` check that uses no real account or callback value. Record Firebase deployment, Strava/provider configuration, and production-data actions as **not performed** for this frontend-only change. +**Success proof:** for source completion, record the exact #242 and #612 issues, reviewed pull requests, merged commits, intended old-source failures, green synthetic service and callback tests, relevant full checks, and independent security, lifecycle, privacy, test-quality, and backup-officer reviews. For live availability, separately record the approved website publication, the published revision, and a dated `runmprc.com` check that uses no real account or callback value. Record Firebase deployment, Strava/provider configuration, and production-data actions as **not performed** for this frontend-only change. **Undo:** before publication, use one reviewed frontend revert or safe roll-forward. After publication, use the same protected website release path and verify the replacement revision on `runmprc.com`. Do not undo by changing a member account, callback value, Firebase record, or Strava setting. **Escalation:** membership lead plus platform/security owner. Add the privacy owner and use the private incident path if any callback or provider detail appeared. Do not copy the detail into an issue, message, screenshot, or AI tool. -For #242 alone, page structure, data movement, permissions, account ownership, and deployment topology were unchanged. The separate #335 procedure and diagram below record the later current-address data-order change. +Issue #612 changes the local path from a received answer to navigation or failure, so the small diagram above records that decision. It does not change the full system's page structure, service-to-service data movement, permissions, account ownership, provider configuration, or deployment topology. The separate #335 procedure and diagram below record the current-address data-order change. ## Strava callback current-address cleanup — SOURCE ONLY, NOT LIVE diff --git a/src/pages/account/Account.test.tsx b/src/pages/account/Account.test.tsx index d2b4a3f..bf29ad6 100644 --- a/src/pages/account/Account.test.tsx +++ b/src/pages/account/Account.test.tsx @@ -1864,7 +1864,7 @@ describe('Strava authorization service boundary', () => { test('sends the captured code and state in one exact exchange request', async () => { const callable = jest.fn().mockResolvedValue({ - data: { ok: true, athleteId: null }, + data: { ok: true, athleteId: 123456 }, }); (httpsCallable as jest.Mock).mockReturnValue(callable); @@ -1872,7 +1872,7 @@ describe('Strava authorization service boundary', () => { app, 'synthetic-code', STRAVA_AUTHORIZATION_STATE, - )).resolves.toEqual({ ok: true, athleteId: null }); + )).resolves.toEqual({ ok: true, athleteId: 123456 }); expect(getFunctions).toHaveBeenCalledWith(app); expect(httpsCallable).toHaveBeenCalledWith(functions, 'stravaExchangeCode'); @@ -1884,6 +1884,336 @@ describe('Strava authorization service boundary', () => { }); }); +describe('OAUTH-001C1J exact Strava exchange confirmation service contract', () => { + const functions = { name: 'synthetic-functions' }; + + beforeEach(() => { + jest.clearAllMocks(); + (getFunctions as jest.Mock).mockReturnValue(functions); + }); + + test.each([ + ['minimum', { ok: true, athleteId: 1 }, 1], + [ + 'maximum in reverse key order', + { athleteId: Number.MAX_SAFE_INTEGER, ok: true }, + Number.MAX_SAFE_INTEGER, + ], + ])('sends one exact request and projects a fresh frozen %s confirmation', async ( + _case, + received, + athleteId, + ) => { + const callable = jest.fn().mockResolvedValue({ data: received }); + (httpsCallable as jest.Mock).mockReturnValue(callable); + + const confirmation = await ActualStravaService.stravaExchangeCode( + app, + 'synthetic-code', + STRAVA_AUTHORIZATION_STATE, + ); + + expect(confirmation).toEqual({ ok: true, athleteId }); + expect(confirmation).not.toBe(received); + expect(Object.isFrozen(confirmation)).toBe(true); + expect(Object.getPrototypeOf(confirmation)).toBe(Object.prototype); + expect(Reflect.ownKeys(confirmation)).toEqual(['ok', 'athleteId']); + expect(getFunctions).toHaveBeenCalledWith(app); + expect(httpsCallable).toHaveBeenCalledWith(functions, 'stravaExchangeCode'); + expect(callable).toHaveBeenCalledTimes(1); + expect(callable).toHaveBeenCalledWith({ + code: 'synthetic-code', + state: STRAVA_AUTHORIZATION_STATE, + }); + }); + + test('returns independent safe confirmations across repeated valid calls', async () => { + const received = { ok: true, athleteId: 612 }; + const callable = jest.fn().mockResolvedValue({ data: received }); + (httpsCallable as jest.Mock).mockReturnValue(callable); + + const first = await ActualStravaService.stravaExchangeCode( + app, + 'synthetic-code', + STRAVA_AUTHORIZATION_STATE, + ); + const second = await ActualStravaService.stravaExchangeCode( + app, + 'synthetic-code', + STRAVA_AUTHORIZATION_STATE, + ); + + expect(first).toEqual({ ok: true, athleteId: 612 }); + expect(second).toEqual({ ok: true, athleteId: 612 }); + expect(first).not.toBe(second); + expect(first).not.toBe(received); + expect(second).not.toBe(received); + expect(Object.isFrozen(first)).toBe(true); + expect(Object.isFrozen(second)).toBe(true); + expect(callable).toHaveBeenCalledTimes(2); + }); + + test.each([ + ['undefined data', undefined], + ['null data', null], + ['a true primitive', true], + ['a false primitive', false], + ['a string', 'confirmed'], + ['a number', 612], + ['a bigint', BigInt(612)], + ['a symbol', Symbol('exchange-confirmation')], + ['a function', () => true], + ['a boxed Boolean', Object(true)], + ['a missing confirmation', {}], + ['a missing athlete ID', { ok: true }], + ['a false confirmation', { ok: false, athleteId: 612 }], + ['a string confirmation', { ok: 'true', athleteId: 612 }], + ['a numeric confirmation', { ok: 1, athleteId: 612 }], + ['a null confirmation', { ok: null, athleteId: 612 }], + ['a null athlete ID', { ok: true, athleteId: null }], + ['a string athlete ID', { ok: true, athleteId: '612' }], + ['a bigint athlete ID', { ok: true, athleteId: BigInt(612) }], + ['a zero athlete ID', { ok: true, athleteId: 0 }], + ['a negative athlete ID', { ok: true, athleteId: -1 }], + ['a fractional athlete ID', { ok: true, athleteId: 1.5 }], + ['a NaN athlete ID', { ok: true, athleteId: Number.NaN }], + ['an infinite athlete ID', { ok: true, athleteId: Number.POSITIVE_INFINITY }], + [ + 'an unsafe athlete ID', + { ok: true, athleteId: Number.MAX_SAFE_INTEGER + 1 }, + ], + ['an array', [{ ok: true, athleteId: 612 }]], + ['a Date', new Date(0)], + [ + 'a null-prototype record', + Object.assign(Object.create(null), { ok: true, athleteId: 612 }), + ], + [ + 'a custom-prototype record', + Object.assign(Object.create({}), { ok: true, athleteId: 612 }), + ], + [ + 'inherited confirmation fields', + Object.create({ ok: true, athleteId: 612 }), + ], + ['an extra enumerable key', { ok: true, athleteId: 612, extra: true }], + [ + 'an extra non-enumerable key', + Object.defineProperty({ ok: true, athleteId: 612 }, 'extra', { value: true }), + ], + [ + 'an extra symbol key', + { ok: true, athleteId: 612, [Symbol('extra')]: true }, + ], + [ + 'a non-enumerable confirmation', + Object.defineProperty({ athleteId: 612 }, 'ok', { value: true }), + ], + [ + 'a non-enumerable athlete ID', + Object.defineProperty({ ok: true }, 'athleteId', { value: 612 }), + ], + ])('rejects %s with one fixed result', async (_case, data) => { + const callable = jest.fn().mockResolvedValue({ data }); + (httpsCallable as jest.Mock).mockReturnValue(callable); + + await expect(ActualStravaService.stravaExchangeCode( + app, + 'synthetic-code', + STRAVA_AUTHORIZATION_STATE, + )).rejects.toThrow('Invalid Strava exchange response.'); + + expect(callable).toHaveBeenCalledTimes(1); + }); + + test.each(['ok', 'athleteId'])('rejects an own %s getter without invoking it', async (field) => { + const selectedGetter = jest.fn(() => (field === 'ok' ? true : 612)); + const data = { ok: true, athleteId: 612 } as Record; + Object.defineProperty(data, field, { + enumerable: true, + get: selectedGetter, + }); + const callable = jest.fn().mockResolvedValue({ data }); + (httpsCallable as jest.Mock).mockReturnValue(callable); + + await expect(ActualStravaService.stravaExchangeCode( + app, + 'synthetic-code', + STRAVA_AUTHORIZATION_STATE, + )).rejects.toThrow('Invalid Strava exchange response.'); + + expect(selectedGetter).not.toHaveBeenCalled(); + }); + + test('rejects extra and inherited getters without invoking them', async () => { + const extraGetter = jest.fn(() => 'private-extra-canary'); + const inheritedGetter = jest.fn(() => 'private-inherited-canary'); + const extra = Object.defineProperty({ ok: true, athleteId: 612 }, 'extra', { + enumerable: true, + get: extraGetter, + }); + const inherited = Object.assign(Object.create(Object.defineProperty({}, 'extra', { + enumerable: true, + get: inheritedGetter, + })), { ok: true, athleteId: 612 }); + const callable = jest.fn() + .mockResolvedValueOnce({ data: extra }) + .mockResolvedValueOnce({ data: inherited }); + (httpsCallable as jest.Mock).mockReturnValue(callable); + + await expect(ActualStravaService.stravaExchangeCode( + app, + 'synthetic-code', + STRAVA_AUTHORIZATION_STATE, + )).rejects.toThrow('Invalid Strava exchange response.'); + await expect(ActualStravaService.stravaExchangeCode( + app, + 'synthetic-code', + STRAVA_AUTHORIZATION_STATE, + )).rejects.toThrow('Invalid Strava exchange response.'); + + expect(extraGetter).not.toHaveBeenCalled(); + expect(inheritedGetter).not.toHaveBeenCalled(); + }); + + test('does not coerce selected confirmation fields', async () => { + const hooks = { + toString: jest.fn(() => '612'), + valueOf: jest.fn(() => 612), + [Symbol.toPrimitive]: jest.fn(() => 612), + }; + const callable = jest.fn().mockResolvedValue({ + data: { ok: true, athleteId: hooks }, + }); + (httpsCallable as jest.Mock).mockReturnValue(callable); + + await expect(ActualStravaService.stravaExchangeCode( + app, + 'synthetic-code', + STRAVA_AUTHORIZATION_STATE, + )).rejects.toThrow('Invalid Strava exchange response.'); + + expect(hooks.toString).not.toHaveBeenCalled(); + expect(hooks.valueOf).not.toHaveBeenCalled(); + expect(hooks[Symbol.toPrimitive]).not.toHaveBeenCalled(); + }); + + test.each([ + ['an undefined callable result', undefined], + ['a null callable result', null], + ['a callable result without data', {}], + ])('contains %s inside the fixed response boundary', async (_case, result) => { + const callable = jest.fn().mockResolvedValue(result); + (httpsCallable as jest.Mock).mockReturnValue(callable); + + await expect(ActualStravaService.stravaExchangeCode( + app, + 'synthetic-code', + STRAVA_AUTHORIZATION_STATE, + )).rejects.toThrow('Invalid Strava exchange response.'); + }); + + test('contains an exceptional callable data read inside the fixed response boundary', async () => { + const dataGetter = jest.fn(() => { + throw new Error('exchange-data-getter-canary token=private-canary'); + }); + const result = Object.defineProperty({}, 'data', { + enumerable: true, + get: dataGetter, + }); + const callable = jest.fn().mockResolvedValue(result); + (httpsCallable as jest.Mock).mockReturnValue(callable); + + await expect(ActualStravaService.stravaExchangeCode( + app, + 'synthetic-code', + STRAVA_AUTHORIZATION_STATE, + )).rejects.toThrow('Invalid Strava exchange response.'); + + expect(dataGetter).toHaveBeenCalledTimes(1); + }); + + test.each([ + [ + 'prototype reflection', + () => new Proxy({ ok: true, athleteId: 612 }, { + getPrototypeOf: () => { + throw new Error('exchange-prototype-canary token=private-canary'); + }, + }), + ], + [ + 'own-key reflection', + () => new Proxy({ ok: true, athleteId: 612 }, { + ownKeys: () => { + throw new Error('exchange-keys-canary token=private-canary'); + }, + }), + ], + [ + 'descriptor reflection', + () => new Proxy({ ok: true, athleteId: 612 }, { + getOwnPropertyDescriptor: () => { + throw new Error('exchange-descriptor-canary token=private-canary'); + }, + }), + ], + [ + 'revoked Proxy reflection', + () => { + const { proxy, revoke } = Proxy.revocable({ ok: true, athleteId: 612 }, {}); + revoke(); + return proxy; + }, + ], + ])('contains exceptional %s', async (_case, makeData) => { + const callable = jest.fn().mockResolvedValue({ data: makeData() }); + (httpsCallable as jest.Mock).mockReturnValue(callable); + + await expect(ActualStravaService.stravaExchangeCode( + app, + 'synthetic-code', + STRAVA_AUTHORIZATION_STATE, + )).rejects.toThrow('Invalid Strava exchange response.'); + }); + + test('projects but never retains a transparent Proxy that presents the exact contract', async () => { + const data = new Proxy({ ok: true, athleteId: 612 }, {}); + const callable = jest.fn().mockResolvedValue({ data }); + (httpsCallable as jest.Mock).mockReturnValue(callable); + + const confirmation = await ActualStravaService.stravaExchangeCode( + app, + 'synthetic-code', + STRAVA_AUTHORIZATION_STATE, + ); + + expect(confirmation).toEqual({ ok: true, athleteId: 612 }); + expect(confirmation).not.toBe(data); + expect(Object.isFrozen(confirmation)).toBe(true); + }); + + test('preserves a transport rejection without inspecting it', async () => { + const messageGetter = jest.fn(() => { + throw new Error('exchange-transport-message-getter-canary'); + }); + const rejection = Object.defineProperty({}, 'message', { + configurable: true, + get: messageGetter, + }); + const callable = jest.fn().mockRejectedValue(rejection); + (httpsCallable as jest.Mock).mockReturnValue(callable); + + await expect(ActualStravaService.stravaExchangeCode( + app, + 'synthetic-code', + STRAVA_AUTHORIZATION_STATE, + )).rejects.toBe(rejection); + + expect(messageGetter).not.toHaveBeenCalled(); + }); +}); + describe('OAUTH-001B10 exact Strava disconnect confirmation service contract', () => { const functions = { name: 'synthetic-functions' }; @@ -3250,7 +3580,7 @@ describe('Strava callback error boundary', () => { }); (stravaExchangeCode as jest.Mock).mockResolvedValue({ ok: true, - athleteId: null, + athleteId: 123456, }); }); @@ -3684,7 +4014,7 @@ describe('Strava callback error boundary', () => { test('makes one exchange across StrictMode replay and ordinary rerenders', async () => { let finishExchange: (() => void) | undefined; (stravaExchangeCode as jest.Mock).mockReturnValueOnce(new Promise((resolve) => { - finishExchange = () => resolve({ ok: true, athleteId: null }); + finishExchange = () => resolve({ ok: true, athleteId: 123456 }); })); const page = renderBrowserStravaCallback(undefined, true, true); @@ -4005,7 +4335,7 @@ describe('Strava callback error boundary', () => { }); (stravaExchangeCode as jest.Mock).mockReturnValueOnce(new Promise((resolve, reject) => { settleExchange = outcome === 'resolve' - ? () => resolve({ ok: true, athleteId: null }) + ? () => resolve({ ok: true, athleteId: 123456 }) : () => reject(Object.defineProperty({}, 'message', { get() { throw new Error('obsolete-context-private-canary'); @@ -4074,7 +4404,7 @@ describe('Strava callback error boundary', () => { isLoading: false, }); (stravaExchangeCode as jest.Mock).mockReturnValueOnce(new Promise((resolve) => { - finishExchange = () => resolve({ ok: true, athleteId: null }); + finishExchange = () => resolve({ ok: true, athleteId: 123456 }); })); const page = renderBrowserStravaCallback(); await waitFor(() => expect(stravaExchangeCode).toHaveBeenCalledTimes(1)); @@ -4132,7 +4462,7 @@ describe('Strava callback error boundary', () => { const consoleSpies = ['debug', 'error', 'info', 'log', 'warn'] .map((method) => jest.spyOn(console, method as any).mockImplementation(() => undefined)); (stravaExchangeCode as jest.Mock).mockReturnValueOnce(new Promise((resolve) => { - finishExchange = () => resolve({ ok: true, athleteId: null }); + finishExchange = () => resolve({ ok: true, athleteId: 123456 }); })); const page = renderBrowserStravaCallback(); await waitFor(() => expect(stravaExchangeCode).toHaveBeenCalledTimes(1)); @@ -4227,7 +4557,7 @@ describe('Strava callback error boundary', () => { const calls: string[] = []; (stravaExchangeCode as jest.Mock).mockImplementation(async () => { calls.push('exchange'); - return { ok: true, athleteId: null }; + return { ok: true, athleteId: 123456 }; }); renderStravaCallback(); @@ -4243,6 +4573,45 @@ describe('Strava callback error boundary', () => { expect(screen.getByTestId('callback-navigation-type')).toHaveTextContent('REPLACE'); }); + test('OAUTH-001C1J keeps the callback on its fixed failure screen for an actual-service non-confirmation', async () => { + const functions = { name: 'exchange-composition-functions' }; + const callable = jest.fn().mockResolvedValue({ + data: { ok: false, athleteId: 612 }, + }); + const consoleSpies = ['debug', 'error', 'info', 'log', 'warn'] + .map((method) => jest.spyOn(console, method as any).mockImplementation(() => undefined)); + (getFunctions as jest.Mock).mockReturnValue(functions); + (httpsCallable as jest.Mock).mockReturnValue(callable); + (stravaExchangeCode as jest.Mock).mockImplementationOnce( + (firebaseApp, code, state) => ActualStravaService.stravaExchangeCode( + firebaseApp, + code, + state, + ), + ); + + renderStravaCallback(); + + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toBe(STRAVA_CALLBACK_FAILURE); + expect(alert).toHaveAttribute('aria-live', 'assertive'); + expect(alert).toHaveAttribute('aria-atomic', 'true'); + expect(screen.queryByText('Account destination')).not.toBeInTheDocument(); + expect(document.body).not.toHaveTextContent('Invalid Strava exchange response.'); + expect(window.location.pathname).toBe('/account/strava/callback'); + expect(window.location.search).toBe(''); + expect(window.location.hash).toBe(''); + expect(getFunctions).toHaveBeenCalledWith(app); + expect(httpsCallable).toHaveBeenCalledWith(functions, 'stravaExchangeCode'); + expect(callable).toHaveBeenCalledTimes(1); + expect(callable).toHaveBeenCalledWith({ + code: 'synthetic-code', + state: 'synthetic-state', + }); + expect(stravaExchangeCode).toHaveBeenCalledTimes(1); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + }); + test('does not navigate while the exchange result is pending', async () => { (stravaExchangeCode as jest.Mock).mockReturnValue(new Promise(() => { // Keep this synthetic exchange pending until the test unmounts the page. diff --git a/src/services/strava/stravaService.ts b/src/services/strava/stravaService.ts index 8d0d770..626a1b8 100644 --- a/src/services/strava/stravaService.ts +++ b/src/services/strava/stravaService.ts @@ -13,6 +13,11 @@ export type StravaAuthorizationChallenge = Readonly<{ expiresInSeconds: 600; }>; +export type StravaExchangeConfirmation = Readonly<{ + ok: true; + athleteId: number; +}>; + export type StravaDisconnectConfirmation = Readonly<{ ok: true; }>; @@ -96,6 +101,47 @@ function readStravaAuthorizationChallenge(value: unknown): StravaAuthorizationCh } } +function readStravaExchangeConfirmation(value: unknown): StravaExchangeConfirmation { + try { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('invalid'); + } + if (Object.getPrototypeOf(value) !== Object.prototype) { + throw new Error('invalid'); + } + const keys = Reflect.ownKeys(value); + if ( + keys.length !== 2 + || !keys.includes('ok') + || !keys.includes('athleteId') + ) { + throw new Error('invalid'); + } + const okDescriptor = Object.getOwnPropertyDescriptor(value, 'ok'); + const athleteIdDescriptor = Object.getOwnPropertyDescriptor(value, 'athleteId'); + if ( + okDescriptor === undefined + || athleteIdDescriptor === undefined + || okDescriptor.enumerable !== true + || athleteIdDescriptor.enumerable !== true + || !Object.prototype.hasOwnProperty.call(okDescriptor, 'value') + || !Object.prototype.hasOwnProperty.call(athleteIdDescriptor, 'value') + || okDescriptor.value !== true + || typeof athleteIdDescriptor.value !== 'number' + || !Number.isSafeInteger(athleteIdDescriptor.value) + || athleteIdDescriptor.value <= 0 + ) { + throw new Error('invalid'); + } + return Object.freeze({ + ok: true, + athleteId: athleteIdDescriptor.value, + }); + } catch { + throw new Error('Invalid Strava exchange response.'); + } +} + function readStravaDisconnectConfirmation(value: unknown): StravaDisconnectConfirmation { try { if (value === null || typeof value !== 'object' || Array.isArray(value)) { @@ -156,14 +202,18 @@ export async function stravaExchangeCode( app: FirebaseApp, code: string, state: string, -): Promise<{ ok: boolean; athleteId: number | null }> { +): Promise { const functions = getFunctions(app); - const callable = httpsCallable<{ code: string; state: string }, any>( + const callable = httpsCallable<{ code: string; state: string }, unknown>( functions, 'stravaExchangeCode', ); const result = await callable({ code, state }); - return result.data; + try { + return readStravaExchangeConfirmation(result.data); + } catch { + throw new Error('Invalid Strava exchange response.'); + } } export async function stravaFetchStats(app: FirebaseApp): Promise {