diff --git a/packages/js/images/myyoast-logo.svg b/packages/js/images/myyoast-logo.svg new file mode 100644 index 00000000000..f51bde0393d --- /dev/null +++ b/packages/js/images/myyoast-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/js/src/integrations-page.js b/packages/js/src/integrations-page.js index e5e41620eed..db07f1e13a2 100644 --- a/packages/js/src/integrations-page.js +++ b/packages/js/src/integrations-page.js @@ -3,12 +3,15 @@ import domReady from "@wordpress/dom-ready"; import { Root } from "@yoast/ui-library"; import IntegrationsGrid from "./integrations-page/integrations-grid"; +import { registerMyyoastStore } from "./integrations-page/myyoast-connection/store"; import { registerReactComponent, renderReactRoot } from "./helpers/reactRoot"; window.YoastSEO = window.YoastSEO || {}; window.YoastSEO._registerReactComponent = registerReactComponent; domReady( () => { + registerMyyoastStore(); + const context = { isRtl: Boolean( get( window, "wpseoScriptData.metabox.isRtl", false ) ), }; diff --git a/packages/js/src/integrations-page/myyoast-connection/constants.js b/packages/js/src/integrations-page/myyoast-connection/constants.js new file mode 100644 index 00000000000..7c4061c974e --- /dev/null +++ b/packages/js/src/integrations-page/myyoast-connection/constants.js @@ -0,0 +1,9 @@ +/** + * Keep constants centralized to avoid circular dependency problems. + */ + +/** + * The Redux store name of the MyYoast connection. + * @type {string} + */ +export const MYYOAST_STORE_NAME = "yoast-seo/myyoast-connection"; diff --git a/packages/js/src/integrations-page/myyoast-connection/myyoast-disconnect-modal.js b/packages/js/src/integrations-page/myyoast-connection/myyoast-disconnect-modal.js new file mode 100644 index 00000000000..e5dc47884b1 --- /dev/null +++ b/packages/js/src/integrations-page/myyoast-connection/myyoast-disconnect-modal.js @@ -0,0 +1,60 @@ +import ExclamationIcon from "@heroicons/react/outline/ExclamationIcon"; +import { __ } from "@wordpress/i18n"; +import { Button, Modal, useSvgAria } from "@yoast/ui-library"; +import { noop } from "lodash"; +import PropTypes from "prop-types"; + +/** + * Confirm modal for disconnecting the site from MyYoast. + * + * @param {boolean} isOpen Whether the modal is open. + * @param {function} onClose Cancel handler. + * @param {function} onConfirm Confirm handler. + * @returns {JSX.Element} The modal element. + */ +export const MyyoastConnectionDisconnectModal = ( { + isOpen, + onClose = noop, + onConfirm = noop, +} ) => { + const svgAriaProps = useSvgAria(); + + return ( + + +
+
+ +
+
+ + { __( "Disconnect this site from MyYoast?", "wordpress-seo" ) } + + + { __( "All connected users will be signed out and the site stops working with MyYoast until you connect it again.", "wordpress-seo" ) } + +
+
+
+ + +
+
+
+ ); +}; + +MyyoastConnectionDisconnectModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func, + onConfirm: PropTypes.func, +}; diff --git a/packages/js/src/integrations-page/myyoast-connection/myyoast-integration.js b/packages/js/src/integrations-page/myyoast-connection/myyoast-integration.js new file mode 100644 index 00000000000..5e80240e317 --- /dev/null +++ b/packages/js/src/integrations-page/myyoast-connection/myyoast-integration.js @@ -0,0 +1,522 @@ +import ArrowSmRightIcon from "@heroicons/react/solid/ArrowSmRightIcon"; +import CheckIcon from "@heroicons/react/solid/CheckIcon"; +import ExclamationCircleIcon from "@heroicons/react/solid/ExclamationCircleIcon"; +import ExclamationIcon from "@heroicons/react/solid/ExclamationIcon"; +import { dispatch, select, useSelect } from "@wordpress/data"; +import { useCallback, useEffect, useId, useRef, useState } from "@wordpress/element"; +import { __, _n, sprintf } from "@wordpress/i18n"; +import { addQueryArgs } from "@wordpress/url"; +import { Alert, Button, Link, Notifications, TooltipContainer, TooltipTrigger, TooltipWithContext, useSvgAria, useToggleState } from "@yoast/ui-library"; +import PropTypes from "prop-types"; +import { ReactComponent as MyYoastLogo } from "../../../images/myyoast-logo.svg"; +import { safeCreateInterpolateElement } from "../../helpers/i18n"; +import { MyyoastConnectionDisconnectModal } from "./myyoast-disconnect-modal"; +import { MYYOAST_STORE_NAME } from "./constants"; +import { Card } from "../tailwind-components/card"; + +const LEARN_MORE_LINK = "https://yoa.st/integrations-myyoast"; + +/** + * Resolves the user-facing message for a machine code the backend emits + * (`error_code` for errors, `message_key` for successes). + * + * A switch rather than a map so only the matched string is translated, and + * `__()` runs at call time rather than module load — keeping locale switching + * working. Unknown codes fall through to the generic error. + * + * @param {string} code The backend code. + * @returns {string} The translated message. + */ +// eslint-disable-next-line complexity +const messageFor = ( code ) => { + switch ( code ) { + case "not_provisioned": + return __( "Your server doesn't support the MyYoast connection. Update Yoast SEO to the latest version. If the issue persists after updating, contact support.", "wordpress-seo" ); + case "registration_gone": + return __( "MyYoast no longer recognizes this site. Connect this site to MyYoast again to restore the connection.", "wordpress-seo" ); + case "rate_limited": + return __( "MyYoast has had a lot of connection attempts from this site or network. Please wait a few minutes and try again.", "wordpress-seo" ); + case "server_capability": + return __( "MyYoast doesn't support a feature this version of Yoast SEO needs. Update Yoast SEO to the latest version. If the issue persists, contact support.", "wordpress-seo" ); + case "myyoast_unreachable": + return __( "Couldn't reach MyYoast from this server. Check your server's outbound network access, then try again. If MyYoast is having issues, wait a few minutes and retry.", "wordpress-seo" ); + case "token_request_failed_invalid_grant": + return __( "MyYoast rejected the credentials stored for this site. Disconnect and connect this site again to restore the connection.", "wordpress-seo" ); + case "token_request_failed": + return __( "Something went wrong while talking to MyYoast. Try again in a moment. If the problem keeps happening, update Yoast SEO or contact support.", "wordpress-seo" ); + case "token_storage_failed": + return __( "Couldn't save the new credentials on this site. Make sure your WordPress database is writable, then try again.", "wordpress-seo" ); + case "invalid_resource": + return __( "Something went wrong. Refresh the page and try again. If the problem keeps happening, contact support.", "wordpress-seo" ); + case "registration_failed": + return __( "Couldn't connect this site to MyYoast. Try again in a moment. If the problem keeps happening, update Yoast SEO or contact support.", "wordpress-seo" ); + case "unknown_redirect_uri": + return __( "Couldn't verify this site because it's no longer recognized. Refresh the page and try again.", "wordpress-seo" ); + case "invalid_user": + return __( "You need to be signed in to verify this site.", "wordpress-seo" ); + case "connection_cancelled": + return __( "Connection cancelled. You can try again whenever you're ready.", "wordpress-seo" ); + case "timeout": + return __( "Request to MyYoast timed out. Please try again.", "wordpress-seo" ); + case "connect_success": + return __( "This site is now connected to MyYoast.", "wordpress-seo" ); + case "update_success": + return __( "Connection updated to match this site's current URL.", "wordpress-seo" ); + case "disconnect_success": + return __( "This site is no longer connected to MyYoast.", "wordpress-seo" ); + case "verify_success": + // Emitted by the OAuth callback for both first-time setup and a + // standalone re-verify, so the copy describes the end state rather + // than the "verify" action. + return __( "Your MyYoast connection is now active.", "wordpress-seo" ); + default: + return __( "Something went wrong. Try again in a moment. If the problem keeps happening, update Yoast SEO or contact support.", "wordpress-seo" ); + } +}; + +// Success keys the backend may send. Used to gate success feedback so an +// unrecognized key doesn't fall through to `messageFor`'s generic error string. +const SUCCESS_MESSAGE_KEYS = new Set( [ "connect_success", "update_success", "disconnect_success", "verify_success" ] ); + +/** + * Formats the rate-limit message in minutes or hours, with the correct + * singular/plural form. Sub-minute values round up to one minute. + * + * @param {number} seconds The retry-after value in seconds. + * @returns {string} The localised message. + */ +const formatRateLimitedMessage = ( seconds ) => { + const minutes = Math.ceil( seconds / 60 ); + if ( minutes >= 60 ) { + const hours = Math.ceil( seconds / 3600 ); + /* translators: %d is a number of hours. */ + return sprintf( _n( "MyYoast has had a lot of connection attempts from this site or network. Please wait about %d hour and try again.", "MyYoast has had a lot of connection attempts from this site or network. Please wait about %d hours and try again.", hours, "wordpress-seo" ), hours ); + } + /* translators: %d is a number of minutes. */ + return sprintf( _n( "MyYoast has had a lot of connection attempts from this site or network. Please wait about %d minute and try again.", "MyYoast has had a lot of connection attempts from this site or network. Please wait about %d minutes and try again.", minutes, "wordpress-seo" ), minutes ); +}; + +const ACTION_DISPATCHERS = { + refreshStatus: "refreshMyyoastConnectionStatus", + connect: "connectMyyoastConnection", + update: "updateMyyoastConnection", + disconnect: "disconnectMyyoastConnection", +}; + +// Sentinel returned by runAction when another action is already in flight. Not a +// real backend failure — the action was deliberately dropped, so callers ignore +// it rather than surfacing it as an error. +const ACTION_IN_FLIGHT = "action_in_flight"; + +/** + * Resolves the user-facing message for a given error code. + * + * @param {string} code The backend error code. + * @param {Object} [details] Extra detail from the backend payload. + * @returns {string} The translated message. + */ +const resolveErrorMessage = ( code, details ) => { + if ( code === "rate_limited" ) { + const seconds = Number( details?.retry_after_seconds ); + if ( Number.isFinite( seconds ) && seconds > 0 ) { + return formatRateLimitedMessage( seconds ); + } + } + return messageFor( code ); +}; + +/** + * Runs a MyYoast management action: dispatches the slice action and, unless + * silent, surfaces the outcome as a toast. + * + * @param {string} actionName The action (refreshStatus/connect/update/disconnect). + * @param {Object} [body] The request body. + * @param {Object} [options] Options. + * @param {boolean} [options.silent] When true, suppress feedback. + * @param {function} [options.onFeedback] Receives `{ variant, message }` to show as a toast. + * @returns {Promise} The slice action's result. + */ +// eslint-disable-next-line complexity +const runAction = async( actionName, body, options ) => { + // Serialize actions: they all mutate the same server-side registration, so a + // second action started while one is in flight (e.g. the mount-time status + // refresh overlapping a user click) would race on the shared status. Ignore it. + if ( select( MYYOAST_STORE_NAME ).selectMyyoastConnectionActionInFlight() ) { + return { ok: false, errorCode: ACTION_IN_FLIGHT }; + } + const store = dispatch( MYYOAST_STORE_NAME ); + const result = await store[ ACTION_DISPATCHERS[ actionName ] ]( body ); + + if ( options?.silent ) { + return result; + } + + if ( result.ok && SUCCESS_MESSAGE_KEYS.has( result.messageKey ) ) { + options?.onFeedback?.( { variant: "success", message: messageFor( result.messageKey ) } ); + } else if ( ! result.ok ) { + const message = resolveErrorMessage( result.errorCode, result.details ); + store.setMyyoastActionError( { actionName, errorCode: result.errorCode, message } ); + options?.onFeedback?.( { variant: "error", message } ); + } + + return result; +}; + +/** + * Starts the verify-site flow: asks the backend for an authorization URL and + * navigates the browser there. The backend resolves which registered redirect + * URI to use. Errors surface as a toast. + * + * The current page is passed as the return URL so the OAuth callback sends the + * user back to the integrations page. The backend validates it and ignores it + * when off-site or invalid. + * + * @param {function} onFeedback Receives `{ variant, message }` to show as a toast. + * @returns {Promise} True once a redirect has been kicked off (the page + * is navigating away); false when we stay on the page. + */ +const runAuthorize = async( onFeedback ) => { + if ( select( MYYOAST_STORE_NAME ).selectMyyoastConnectionActionInFlight() ) { + return false; + } + const store = dispatch( MYYOAST_STORE_NAME ); + const result = await store.authorizeMyyoastSite( { returnUrl: window.location.href } ); + + if ( result.ok && result.authorizeUrl ) { + window.location.assign( result.authorizeUrl ); + // The navigation is async — the page keeps rendering until MyYoast loads. + // Report that a redirect started so callers don't restore in-page state. + return true; + } + + const message = resolveErrorMessage( result.errorCode, result.details ); + onFeedback?.( { variant: "error", message } ); + return false; +}; + +/** + * The footer status line of the card. Mirrors the three registered states from + * the design: connected (green check), connection lost (red error icon, URL no + * longer matches), and verification needed (amber warning icon, a connected + * site still needs an authorization-code flow). Not rendered when the site is + * not registered — the footer shows the connect button in that case instead. + * + * @param {Object} props The component props. + * @param {boolean} props.connectionLost Whether the registered URL no longer matches. + * @param {boolean} props.verificationNeeded Whether a connected site still needs verification. + * @returns {JSX.Element} The status line. + */ +const StatusFooter = ( { connectionLost, verificationNeeded } ) => { + const svgAriaProps = useSvgAria(); + const tooltipId = `myyoast-verification-${ useId() }`; + const iconClass = "yst-h-5 yst-w-5 yst-flex-shrink-0"; + + if ( connectionLost ) { + return ( +

+ { __( "Site connection lost", "wordpress-seo" ) } + +

+ ); + } + + if ( verificationNeeded ) { + return ( +

+ { __( "Site connected", "wordpress-seo" ) } + + + + + + { __( "Sign in to MyYoast to finish setting up this connection so everything works as expected.", "wordpress-seo" ) } + + +

+ ); + } + + return ( +

+ { __( "Site connected", "wordpress-seo" ) } + +

+ ); +}; + +StatusFooter.propTypes = { + connectionLost: PropTypes.bool.isRequired, + verificationNeeded: PropTypes.bool.isRequired, +}; + +/** + * The MyYoast connection card on the integrations page. + * + * @returns {JSX.Element} The card element. + */ +// eslint-disable-next-line complexity +export const MyyoastIntegration = () => { + const status = useSelect( s => s( MYYOAST_STORE_NAME ).selectMyyoastConnectionStatus(), [] ); + const actionInFlight = useSelect( s => s( MYYOAST_STORE_NAME ).selectMyyoastConnectionActionInFlight(), [] ); + const pendingCallbackOutcome = useSelect( s => s( MYYOAST_STORE_NAME ).selectMyyoastConnectionPendingCallbackOutcome(), [] ); + // UTM/tracking params localized by the page; appended to outbound links so + // they carry the same attribution as the other integration cards' links. + const linkParams = useSelect( s => s( MYYOAST_STORE_NAME ).selectMyyoastConnectionLinkParams(), [] ); + const learnMoreLink = addQueryArgs( LEARN_MORE_LINK, linkParams ); + // The OAuth flow outcome (connect/verify/disconnect/refresh and the callback + // return) surfaces as a transient toast. Each new outcome carries a fresh id + // so the toast remounts and re-animates even when the message is unchanged. + const [ feedback, setFeedback ] = useState( null ); + const feedbackId = useRef( 0 ); + const showFeedback = useCallback( ( next ) => { + feedbackId.current += 1; + setFeedback( { ...next, id: feedbackId.current } ); + }, [] ); + const dismissFeedback = useCallback( () => setFeedback( null ), [] ); + // True only while the connect → authorize auto-flow is running: registration + // succeeds (status becomes registered-but-unverified) before we redirect to + // MyYoast, and we don't want to flash the "Verification needed" notice for + // that gap. Scoped to this flow so a deliberate verify click still shows it. + const [ isConnecting, setIsConnecting ] = useState( false ); + const [ isDisconnectOpen, , , openDisconnect, closeDisconnect ] = useToggleState( false ); + + // Auto-fired status refresh on mount: confirms with MyYoast that the stored + // registration is still valid. Errors are silent; the response refreshes + // the local status, so Registration_Not_Found clears state and the card + // re-renders as "not connected". + useEffect( () => { + if ( status.isRegistered ) { + runAction( "refreshStatus", null, { silent: true } ); + } + }, [] ); + + // Surfaces the one-shot outcome stashed by the OAuth callback handler + // (success or error) as a toast. The transient is consumed server-side on + // read, so after we've shown it we clear it from the slice to avoid + // re-firing on subsequent renders. + useEffect( () => { + if ( ! pendingCallbackOutcome ) { + return; + } + const { kind, key } = pendingCallbackOutcome; + showFeedback( { variant: kind === "success" ? "success" : "error", message: messageFor( key ) } ); + dispatch( MYYOAST_STORE_NAME ).clearMyyoastCallbackOutcome(); + }, [ pendingCallbackOutcome, showFeedback ] ); + + // Connecting only registers the site as an OAuth client; the connection is + // not usable until one user completes an authorization-code grant. So on a + // successful registration we continue straight into that flow, sending the + // user to MyYoast to sign in rather than leaving them on the unverified + // "Verification needed" state. A failed registration just shows its error. + const handleConnect = useCallback( async() => { + // Mark the combined flow so the registered-but-unverified gap between + // registration and the redirect doesn't flash the verification notice. + setIsConnecting( true ); + // Register silently: on success we redirect to MyYoast immediately, so a + // transient "connected" message would only flash before the page leaves. + // A failure still needs to be shown, so surface only that. + const result = await runAction( "connect", null, { silent: true } ); + if ( ! result.ok ) { + // Another action was already running (e.g. a double-click before the + // button re-rendered as disabled). The first action stays in flight, so + // drop this one silently rather than flashing a spurious error. + if ( result.errorCode === ACTION_IN_FLIGHT ) { + setIsConnecting( false ); + return; + } + const message = resolveErrorMessage( result.errorCode, result.details ); + showFeedback( { variant: "error", message } ); + setIsConnecting( false ); + return; + } + // Registration succeeded; continue straight into the authorization-code + // flow. The backend resolves which registered redirect URI to use. + const redirecting = await runAuthorize( showFeedback ); + // Only clear when we're staying on the page: `window.location.assign` is + // async, so the page keeps rendering while MyYoast loads. Clearing now + // would flash the verification notice during that tail. + if ( ! redirecting ) { + setIsConnecting( false ); + } + }, [ showFeedback ] ); + const handleReconnect = useCallback( () => runAction( "update", null, { onFeedback: showFeedback } ), [ showFeedback ] ); + const handleDisconnectConfirm = useCallback( () => { + closeDisconnect(); + runAction( "disconnect", null, { onFeedback: showFeedback } ); + }, [ showFeedback ] ); + + const redirectUris = Array.isArray( status.redirectUris ) ? status.redirectUris : []; + // "Connection lost" takes precedence over "verification needed": once the + // registered URL no longer matches, reconnecting is the only fix and any + // unverified-site notice would be premature. + const connectionLost = status.isRegistered && status.redirectUrisMatch === false; + // Show a single verification notice for the first unverified site, even when + // several are connected — verifying them all clears it. Suppressed while the + // connection is lost. + const firstUnverified = connectionLost ? null : redirectUris.find( ( entry ) => ! entry.isVerified ) ?? null; + // Suppressed during the connect → authorize auto-flow: registration leaves the + // site registered-but-unverified for the moment before the redirect fires. + const verificationNeeded = Boolean( firstUnverified ) && ! isConnecting; + + const handleVerify = useCallback( () => { + if ( firstUnverified ) { + runAuthorize( showFeedback ); + } + }, [ firstUnverified, showFeedback ] ); + + return ( + <> + { /* The purple outline flags the card as a call to action; once connected it's dropped so the card blends in with the others. */ } + + + + + +
+

+ { safeCreateInterpolateElement( + sprintf( + /* translators: 1: bold open tag; 2: bold close tag. */ + __( "Unlock more from Yoast with %1$sMyYoast%2$s", "wordpress-seo" ), + "", + "" + ), + { strong: } + ) } +

+

+ { __( "Connect your site to MyYoast so Yoast AI works even when your site is offline, behind a firewall, or with the REST API disabled.", "wordpress-seo" ) } +

+ + + { __( "Learn more", "wordpress-seo" ) } + + { + /* translators: Hidden accessibility text. */ + __( "(Opens in a new browser tab)", "wordpress-seo" ) + } + + + + + { ! status.isProvisioned && ( + + { __( "MyYoast connection is not configured on this build of Yoast SEO. Site features that depend on it are unavailable.", "wordpress-seo" ) } + + ) } + + { status.isProvisioned && status.isRegistered && ( +
+
+
+ { __( "Site connection", "wordpress-seo" ) } +
+ { redirectUris.length > 0 && ( +
    + { redirectUris.map( ( entry ) => ( +
  • { entry.origin }
  • + ) ) } +
+ ) } +
+ + + { connectionLost && ( + +
+

{ __( "Connection lost", "wordpress-seo" ) }

+

{ __( "Your site's URL changed since the connection with MyYoast was made. Please reconnect.", "wordpress-seo" ) }

+ +
+
+ ) } + + { verificationNeeded && ( + +
+

{ __( "Verification needed", "wordpress-seo" ) }

+

{ __( "Sign in to MyYoast to finish setting up this connection.", "wordpress-seo" ) }

+ +
+
+ ) } +
+ ) } +
+
+ { status.isProvisioned && ( + + { status.isRegistered ? ( + + ) : ( + + ) } + + ) } +
+ + + + + { feedback && ( + + ) } + + + ); +}; diff --git a/packages/js/src/integrations-page/myyoast-connection/store/index.js b/packages/js/src/integrations-page/myyoast-connection/store/index.js new file mode 100644 index 00000000000..244ead5eab5 --- /dev/null +++ b/packages/js/src/integrations-page/myyoast-connection/store/index.js @@ -0,0 +1,70 @@ +import { combineReducers, createReduxStore, register } from "@wordpress/data"; +import { get, merge } from "lodash"; +import { MYYOAST_STORE_NAME } from "../constants"; +import { + getInitialMyyoastConnectionState, + MYYOAST_CONNECTION_NAME, + myyoastConnectionActions, + myyoastConnectionControls, + myyoastConnectionReducer, + myyoastConnectionSelectors, + transformStatus, +} from "./myyoast-connection"; + +/** @typedef {import("@wordpress/data/src/types").WPDataStore} WPDataStore */ + +/** + * Builds the MyYoast connection store descriptor. + * + * The slice state is nested under the `myyoastConnection` key so the selectors + * keep the same shape they had inside the settings store. + * + * @param {Object} initialState Initial state, merged over the slice defaults. + * @returns {WPDataStore} The WP data store. + */ +const createStore = ( { initialState } ) => { + return createReduxStore( MYYOAST_STORE_NAME, { + actions: { + ...myyoastConnectionActions, + }, + selectors: { + ...myyoastConnectionSelectors, + }, + controls: { + ...myyoastConnectionControls, + }, + reducer: combineReducers( { + [ MYYOAST_CONNECTION_NAME ]: myyoastConnectionReducer, + } ), + initialState: merge( + {}, + { [ MYYOAST_CONNECTION_NAME ]: getInitialMyyoastConnectionState() }, + initialState + ), + } ); +}; + +/** + * Registers the standalone MyYoast connection store, seeded from the + * `wpseoIntegrationsData.myyoast_connection` payload the integrations page + * localizes. No-ops when the payload is absent (feature flag disabled) — + * the card is not rendered in that case either. + * + * @returns {void} + */ +export const registerMyyoastStore = () => { + const data = get( window, "wpseoIntegrationsData.myyoast_connection", null ); + if ( ! data ) { + return; + } + + register( createStore( { + initialState: { + [ MYYOAST_CONNECTION_NAME ]: { + status: transformStatus( data.initialStatus ), + pendingCallbackOutcome: data.callbackOutcome || null, + linkParams: data.linkParams || {}, + }, + }, + } ) ); +}; diff --git a/packages/js/src/integrations-page/myyoast-connection/store/myyoast-connection.js b/packages/js/src/integrations-page/myyoast-connection/store/myyoast-connection.js new file mode 100644 index 00000000000..0abada51194 --- /dev/null +++ b/packages/js/src/integrations-page/myyoast-connection/store/myyoast-connection.js @@ -0,0 +1,223 @@ +import { createSlice } from "@reduxjs/toolkit"; +import apiFetch from "@wordpress/api-fetch"; +import { get } from "lodash"; + +export const MYYOAST_CONNECTION_NAME = "myyoastConnection"; + +const REQUEST_TIMEOUT_MS = 30000; + +const ENDPOINTS = { + refreshStatus: { actionType: "refreshMyyoastConnectionStatus", method: "POST", path: "/yoast/v1/myyoast/refresh-status" }, + connect: { actionType: "connectMyyoastConnection", method: "POST", path: "/yoast/v1/myyoast/register" }, + update: { actionType: "updateMyyoastConnection", method: "PUT", path: "/yoast/v1/myyoast/registration" }, + disconnect: { actionType: "disconnectMyyoastConnection", method: "DELETE", path: "/yoast/v1/myyoast/registration" }, + authorize: { actionType: "authorizeMyyoastSite", method: "POST", path: "/yoast/v1/myyoast/authorize" }, +}; + +/** + * @returns {Object} The initial myyoastConnection state. + */ +export const getInitialMyyoastConnectionState = () => ( { + status: null, + actionInFlight: null, + actionError: null, + pendingCallbackOutcome: null, + linkParams: {}, +} ); + +const DEFAULT_STATUS = { + isProvisioned: false, + isRegistered: false, + registeredAt: null, + registeredAtIso: null, + redirectUris: [], + redirectUrisMatch: true, +}; + +/** + * Transforms a snake_case status payload from the backend into the camelCase + * shape used inside the React app. + * + * @param {Object} payload The backend payload. + * @returns {Object} The camelCase status. + */ +export const transformStatus = ( payload ) => { + if ( ! payload ) { + return DEFAULT_STATUS; + } + const redirectUris = Array.isArray( payload.redirect_uris ) + ? payload.redirect_uris.map( ( entry ) => ( { + uri: entry?.uri ?? "", + origin: entry?.origin ?? "", + isVerified: Boolean( entry?.is_verified ), + } ) ) + : []; + return { + isProvisioned: Boolean( payload.is_provisioned ), + isRegistered: Boolean( payload.is_registered ), + registeredAt: payload.registered_at ?? null, + registeredAtIso: payload.registered_at_iso ?? null, + redirectUris, + redirectUrisMatch: payload.redirect_uris_match !== false, + }; +}; + +const slice = createSlice( { + name: MYYOAST_CONNECTION_NAME, + initialState: getInitialMyyoastConnectionState(), + reducers: { + setMyyoastStatus: ( state, { payload } ) => { + if ( payload ) { + state.status = payload; + } + }, + startMyyoastAction: ( state, { payload } ) => { + // Guard against starting a second action while one is already running: + // every action mutates the same registration, so they must serialize. + if ( state.actionInFlight ) { + return; + } + state.actionInFlight = payload; + state.actionError = null; + }, + setMyyoastActionError: ( state, { payload } ) => { + state.actionError = payload; + }, + finishMyyoastAction: state => { + state.actionInFlight = null; + }, + clearMyyoastCallbackOutcome: state => { + state.pendingCallbackOutcome = null; + }, + }, +} ); + +const { setMyyoastStatus, startMyyoastAction, setMyyoastActionError, finishMyyoastAction } = slice.actions; + +/** + * Builds a generator action that performs a MyYoast management request through + * the matching control, mirrors the response into the slice, and returns a + * result object the caller can use to drive UI notifications. + * + * @param {string} name The action name (refreshStatus/connect/update/disconnect). + * @returns {GeneratorFunction} The generator action. + */ +// eslint-disable-next-line complexity +const createMyyoastAction = ( name ) => function* ( body ) { + yield startMyyoastAction( name ); + try { + const payload = yield{ type: ENDPOINTS[ name ].actionType, payload: body }; + if ( payload?.status ) { + yield setMyyoastStatus( transformStatus( payload.status ) ); + } + // The backend signals failure with `error_code` in the body — HTTP status + // stays 200 for upstream/precondition failures. + if ( payload?.error_code ) { + yield setMyyoastActionError( { actionName: name, errorCode: payload.error_code, message: "" } ); + return { ok: false, errorCode: payload.error_code, details: payload.details }; + } + return { ok: true, messageKey: payload?.message_key }; + } catch ( error ) { + const errorCode = error?.name === "AbortError" ? "timeout" : "unexpected_error"; + yield setMyyoastActionError( { actionName: name, errorCode, message: "" } ); + return { ok: false, errorCode }; + } finally { + yield finishMyyoastAction(); + } +}; + +const refreshMyyoastConnectionStatus = createMyyoastAction( "refreshStatus" ); +const connectMyyoastConnection = createMyyoastAction( "connect" ); +const updateMyyoastConnection = createMyyoastAction( "update" ); +const disconnectMyyoastConnection = createMyyoastAction( "disconnect" ); + +/** + * Starts the authorization-code flow for the site's registration. + * + * The backend resolves which registered redirect URI to use, so no URI is + * sent. The optional `returnUrl` tells the backend where to send the browser + * once the flow completes — pass the page the flow was started from, since the + * flow can be kicked off from several admin pages. It is validated server-side + * and ignored when off-site or invalid. On success the action returns an + * `authorize_url` the browser should be navigated to; the caller decides how to + * do that. On failure the slice's actionError is set, mirroring the other actions. + * + * @param {Object} [options] The action options. + * @param {string} [options.returnUrl] The URL to return to after the flow completes. + * @returns {GeneratorFunction} The generator action. + */ +// eslint-disable-next-line complexity +const authorizeMyyoastSite = function* ( { returnUrl } = {} ) { + yield startMyyoastAction( "authorize" ); + try { + // eslint-disable-next-line camelcase -- snake_case matches the REST endpoint's request contract. + const body = returnUrl ? { return_url: returnUrl } : {}; + const payload = yield{ type: ENDPOINTS.authorize.actionType, payload: body }; + if ( payload?.status ) { + yield setMyyoastStatus( transformStatus( payload.status ) ); + } + if ( payload?.error_code ) { + yield setMyyoastActionError( { actionName: "authorize", errorCode: payload.error_code, message: "" } ); + return { ok: false, errorCode: payload.error_code, details: payload.details }; + } + if ( ! payload?.authorize_url ) { + yield setMyyoastActionError( { actionName: "authorize", errorCode: "unexpected_error", message: "" } ); + return { ok: false, errorCode: "unexpected_error" }; + } + return { ok: true, authorizeUrl: payload.authorize_url }; + } catch ( error ) { + const errorCode = error?.name === "AbortError" ? "timeout" : "unexpected_error"; + yield setMyyoastActionError( { actionName: "authorize", errorCode, message: "" } ); + return { ok: false, errorCode }; + } finally { + yield finishMyyoastAction(); + } +}; + +/** + * Calls a MyYoast endpoint via apiFetch with a client-side timeout. + * + * @param {Object} endpoint The endpoint config. + * @param {Object} body The request body. + * @returns {Promise} The parsed response payload. + */ +const callEndpoint = async( endpoint, body ) => { + const controller = new AbortController(); + const timeoutId = setTimeout( () => controller.abort(), REQUEST_TIMEOUT_MS ); + try { + return await apiFetch( { + method: endpoint.method, + path: endpoint.path, + data: body, + signal: controller.signal, + } ); + } finally { + clearTimeout( timeoutId ); + } +}; + +export const myyoastConnectionActions = { + ...slice.actions, + refreshMyyoastConnectionStatus, + connectMyyoastConnection, + updateMyyoastConnection, + disconnectMyyoastConnection, + authorizeMyyoastSite, +}; + +export const myyoastConnectionControls = { + [ ENDPOINTS.refreshStatus.actionType ]: ( { payload } ) => callEndpoint( ENDPOINTS.refreshStatus, payload ), + [ ENDPOINTS.connect.actionType ]: ( { payload } ) => callEndpoint( ENDPOINTS.connect, payload ), + [ ENDPOINTS.update.actionType ]: ( { payload } ) => callEndpoint( ENDPOINTS.update, payload ), + [ ENDPOINTS.disconnect.actionType ]: ( { payload } ) => callEndpoint( ENDPOINTS.disconnect, payload ), + [ ENDPOINTS.authorize.actionType ]: ( { payload } ) => callEndpoint( ENDPOINTS.authorize, payload ), +}; + +export const myyoastConnectionSelectors = { + selectMyyoastConnectionStatus: state => get( state, "myyoastConnection.status", DEFAULT_STATUS ) ?? DEFAULT_STATUS, + selectMyyoastConnectionActionInFlight: state => get( state, "myyoastConnection.actionInFlight", null ), + selectMyyoastConnectionPendingCallbackOutcome: state => get( state, "myyoastConnection.pendingCallbackOutcome", null ), + selectMyyoastConnectionLinkParams: state => get( state, "myyoastConnection.linkParams", {} ), +}; + +export const myyoastConnectionReducer = slice.reducer; diff --git a/packages/js/src/integrations-page/recommended-integrations.js b/packages/js/src/integrations-page/recommended-integrations.js index 00da7184317..23bc0c0cd60 100644 --- a/packages/js/src/integrations-page/recommended-integrations.js +++ b/packages/js/src/integrations-page/recommended-integrations.js @@ -4,6 +4,7 @@ import { safeCreateInterpolateElement } from "../helpers/i18n"; import { ReactComponent as SemrushLogo } from "../../images/semrush-logo.svg"; import { ReactComponent as WincherLogo } from "../../images/wincher-logo.svg"; import { getInitialState, getIsMultisiteAvailable, getIsNetworkControlEnabled, updateIntegrationState } from "./helper"; +import { MyyoastIntegration } from "./myyoast-connection/myyoast-integration"; import { SiteKitIntegration } from "./site-kit-integration"; import { ToggleableIntegration } from "./toggleable-integration"; @@ -78,6 +79,12 @@ const RecommendedIntegrations = [ } ), ]; +// The payload is only localized when the MyYoast connection feature flag is on. +const isMyyoastConnectionAvailable = get( window, "wpseoIntegrationsData.myyoast_connection", null ) !== null; +if ( isMyyoastConnectionAvailable ) { + RecommendedIntegrations.unshift( ); +} + const isSiteKitFeatureEnabled = get( window, "wpseoIntegrationsData.site_kit_configuration.isFeatureEnabled", false ); if ( isSiteKitFeatureEnabled ) { RecommendedIntegrations.push( ( +export const Card = ( { children = null, className = "" } ) => (
{ children }
); Card.propTypes = { children: PropTypes.node, + className: PropTypes.string, }; Card.Header = Header; diff --git a/packages/js/tests/integrations-page/myyoast-connection/store/myyoast-connection.test.js b/packages/js/tests/integrations-page/myyoast-connection/store/myyoast-connection.test.js new file mode 100644 index 00000000000..d99b433c262 --- /dev/null +++ b/packages/js/tests/integrations-page/myyoast-connection/store/myyoast-connection.test.js @@ -0,0 +1,296 @@ +/* eslint-disable camelcase -- snake_case keys model the backend's REST request/response contract. */ +import { describe, expect, it } from "@jest/globals"; +import { + getInitialMyyoastConnectionState, + MYYOAST_CONNECTION_NAME, + myyoastConnectionActions, + myyoastConnectionReducer, + myyoastConnectionSelectors, + transformStatus, +} from "../../../../src/integrations-page/myyoast-connection/store/myyoast-connection"; + +it( "MYYOAST_CONNECTION_NAME is myyoastConnection", () => { + expect( MYYOAST_CONNECTION_NAME ).toBe( "myyoastConnection" ); +} ); + +describe( "transformStatus", () => { + it( "returns the default status when the payload is empty", () => { + expect( transformStatus( null ) ).toEqual( { + isProvisioned: false, + isRegistered: false, + registeredAt: null, + registeredAtIso: null, + redirectUris: [], + redirectUrisMatch: true, + } ); + } ); + + it( "maps the snake_case payload to the camelCase shape", () => { + const status = transformStatus( { + is_provisioned: true, + is_registered: true, + registered_at: 1700000000, + registered_at_iso: "2023-11-14T22:13:20+00:00", + redirect_uris: [ + { uri: "https://example.com/callback", origin: "https://example.com", is_verified: true }, + { uri: "https://example.com/other", origin: "https://example.com", is_verified: false }, + ], + redirect_uris_match: true, + } ); + + expect( status ).toEqual( { + isProvisioned: true, + isRegistered: true, + registeredAt: 1700000000, + registeredAtIso: "2023-11-14T22:13:20+00:00", + redirectUris: [ + { uri: "https://example.com/callback", origin: "https://example.com", isVerified: true }, + { uri: "https://example.com/other", origin: "https://example.com", isVerified: false }, + ], + redirectUrisMatch: true, + } ); + } ); + + it( "defaults each redirect URI field when the entry is malformed", () => { + const status = transformStatus( { redirect_uris: [ {}, null ] } ); + + expect( status.redirectUris ).toEqual( [ + { uri: "", origin: "", isVerified: false }, + { uri: "", origin: "", isVerified: false }, + ] ); + } ); + + it( "treats a non-array redirect_uris as an empty list", () => { + expect( transformStatus( { redirect_uris: "nope" } ).redirectUris ).toEqual( [] ); + } ); + + it( "only reports redirectUrisMatch false when explicitly false", () => { + // A missing or truthy value is treated as a match; only an explicit `false` flips it. + expect( transformStatus( {} ).redirectUrisMatch ).toBe( true ); + expect( transformStatus( { redirect_uris_match: false } ).redirectUrisMatch ).toBe( false ); + } ); +} ); + +describe( "initial state", () => { + it( "is the empty connection state", () => { + expect( myyoastConnectionReducer( undefined, { type: "" } ) ).toEqual( getInitialMyyoastConnectionState() ); + } ); +} ); + +describe( "reducer", () => { + describe( "startMyyoastAction", () => { + it( "records the in-flight action and clears any previous error", () => { + const state = { ...getInitialMyyoastConnectionState(), actionError: { errorCode: "boom" } }; + const next = myyoastConnectionReducer( state, myyoastConnectionActions.startMyyoastAction( "connect" ) ); + + expect( next.actionInFlight ).toBe( "connect" ); + expect( next.actionError ).toBeNull(); + } ); + + it( "ignores a second action while one is already in flight", () => { + // Every action mutates the same registration, so a started action must + // not be overwritten by another that begins before it finishes. + const state = { ...getInitialMyyoastConnectionState(), actionInFlight: "connect" }; + const next = myyoastConnectionReducer( state, myyoastConnectionActions.startMyyoastAction( "disconnect" ) ); + + expect( next.actionInFlight ).toBe( "connect" ); + } ); + } ); + + describe( "finishMyyoastAction", () => { + it( "clears the in-flight action", () => { + const state = { ...getInitialMyyoastConnectionState(), actionInFlight: "connect" }; + const next = myyoastConnectionReducer( state, myyoastConnectionActions.finishMyyoastAction() ); + + expect( next.actionInFlight ).toBeNull(); + } ); + } ); + + describe( "setMyyoastStatus", () => { + it( "stores the status payload", () => { + const status = transformStatus( { is_registered: true } ); + const next = myyoastConnectionReducer( getInitialMyyoastConnectionState(), myyoastConnectionActions.setMyyoastStatus( status ) ); + + expect( next.status ).toEqual( status ); + } ); + + it( "leaves the status untouched for a falsy payload", () => { + const state = { ...getInitialMyyoastConnectionState(), status: { isRegistered: true } }; + const next = myyoastConnectionReducer( state, myyoastConnectionActions.setMyyoastStatus( null ) ); + + expect( next.status ).toEqual( { isRegistered: true } ); + } ); + } ); + + describe( "clearMyyoastCallbackOutcome", () => { + it( "clears the pending callback outcome", () => { + const state = { ...getInitialMyyoastConnectionState(), pendingCallbackOutcome: { kind: "success", key: "connect_success" } }; + const next = myyoastConnectionReducer( state, myyoastConnectionActions.clearMyyoastCallbackOutcome() ); + + expect( next.pendingCallbackOutcome ).toBeNull(); + } ); + } ); +} ); + +describe( "selectors", () => { + const status = transformStatus( { is_registered: true } ); + const state = { + [ MYYOAST_CONNECTION_NAME ]: { + status, + actionInFlight: "connect", + actionError: { errorCode: "boom" }, + pendingCallbackOutcome: { kind: "success", key: "connect_success" }, + linkParams: { php_version: "8.2" }, + }, + }; + + it( "selectMyyoastConnectionStatus returns the stored status", () => { + expect( myyoastConnectionSelectors.selectMyyoastConnectionStatus( state ) ).toEqual( status ); + } ); + + it( "selectMyyoastConnectionStatus falls back to the default status when absent", () => { + expect( myyoastConnectionSelectors.selectMyyoastConnectionStatus( {} ) ).toEqual( transformStatus( null ) ); + } ); + + it( "selectMyyoastConnectionActionInFlight returns the in-flight action", () => { + expect( myyoastConnectionSelectors.selectMyyoastConnectionActionInFlight( state ) ).toBe( "connect" ); + } ); + + it( "selectMyyoastConnectionPendingCallbackOutcome returns the pending outcome", () => { + expect( myyoastConnectionSelectors.selectMyyoastConnectionPendingCallbackOutcome( state ) ).toEqual( { kind: "success", key: "connect_success" } ); + } ); + + it( "selectMyyoastConnectionLinkParams returns the link params", () => { + expect( myyoastConnectionSelectors.selectMyyoastConnectionLinkParams( state ) ).toEqual( { php_version: "8.2" } ); + } ); + + it( "selectMyyoastConnectionLinkParams falls back to an empty object when absent", () => { + expect( myyoastConnectionSelectors.selectMyyoastConnectionLinkParams( {} ) ).toEqual( {} ); + } ); +} ); + +describe( "management actions", () => { + // The generator yields plain control objects and slice actions, so it can be + // driven by hand: `next( value )` feeds back what a control would have resolved + // to, and the final `return` value is the result the UI layer consumes. + const REQUEST_STATUS = { is_registered: true }; + + describe( "connectMyyoastConnection", () => { + it( "starts the action, dispatches the control, mirrors the status and finishes on success", () => { + const generator = myyoastConnectionActions.connectMyyoastConnection( { foo: "bar" } ); + + expect( generator.next().value ).toEqual( { type: "myyoastConnection/startMyyoastAction", payload: "connect" } ); + + expect( generator.next().value ).toEqual( { type: "connectMyyoastConnection", payload: { foo: "bar" } } ); + + // Feed back the control's resolved payload; the status mirror is yielded next. + expect( generator.next( { status: REQUEST_STATUS, message_key: "connect_success" } ).value ) + .toEqual( { type: "myyoastConnection/setMyyoastStatus", payload: transformStatus( REQUEST_STATUS ) } ); + + expect( generator.next().value ).toEqual( { type: "myyoastConnection/finishMyyoastAction" } ); + + const final = generator.next(); + expect( final.done ).toBe( true ); + expect( final.value ).toEqual( { ok: true, messageKey: "connect_success" } ); + } ); + + it( "returns the error code and details when the body carries an error_code", () => { + const generator = myyoastConnectionActions.connectMyyoastConnection(); + generator.next(); + generator.next(); + + // A precondition/upstream failure arrives as a 200 with an error_code body. + expect( generator.next( { error_code: "rate_limited", details: { retry_after_seconds: 120 } } ).value ) + .toEqual( { type: "myyoastConnection/setMyyoastActionError", payload: { actionName: "connect", errorCode: "rate_limited", message: "" } } ); + + expect( generator.next().value ).toEqual( { type: "myyoastConnection/finishMyyoastAction" } ); + + const final = generator.next(); + expect( final.done ).toBe( true ); + expect( final.value ).toEqual( { ok: false, errorCode: "rate_limited", details: { retry_after_seconds: 120 } } ); + } ); + + it( "maps an aborted request to the timeout error and still finishes", () => { + const generator = myyoastConnectionActions.connectMyyoastConnection(); + generator.next(); + generator.next(); + + const abortError = new Error( "aborted" ); + abortError.name = "AbortError"; + + expect( generator.throw( abortError ).value ) + .toEqual( { type: "myyoastConnection/setMyyoastActionError", payload: { actionName: "connect", errorCode: "timeout", message: "" } } ); + + expect( generator.next().value ).toEqual( { type: "myyoastConnection/finishMyyoastAction" } ); + + const final = generator.next(); + expect( final.done ).toBe( true ); + expect( final.value ).toEqual( { ok: false, errorCode: "timeout" } ); + } ); + + it( "maps any other thrown error to unexpected_error", () => { + const generator = myyoastConnectionActions.connectMyyoastConnection(); + generator.next(); + generator.next(); + + expect( generator.throw( new Error( "network down" ) ).value ) + .toEqual( { type: "myyoastConnection/setMyyoastActionError", payload: { actionName: "connect", errorCode: "unexpected_error", message: "" } } ); + + generator.next(); + expect( generator.next().value ).toEqual( { ok: false, errorCode: "unexpected_error" } ); + } ); + } ); + + describe( "authorizeMyyoastSite", () => { + it( "sends the return_url and returns the authorize URL on success", () => { + const generator = myyoastConnectionActions.authorizeMyyoastSite( { returnUrl: "https://example.com/admin" } ); + + expect( generator.next().value ).toEqual( { type: "myyoastConnection/startMyyoastAction", payload: "authorize" } ); + + expect( generator.next().value ).toEqual( { type: "authorizeMyyoastSite", payload: { return_url: "https://example.com/admin" } } ); + + expect( generator.next( { authorize_url: "https://my.yoast.com/authorize" } ).value ) + .toEqual( { type: "myyoastConnection/finishMyyoastAction" } ); + + const final = generator.next(); + expect( final.done ).toBe( true ); + expect( final.value ).toEqual( { ok: true, authorizeUrl: "https://my.yoast.com/authorize" } ); + } ); + + it( "omits the body when no return URL is given", () => { + const generator = myyoastConnectionActions.authorizeMyyoastSite(); + generator.next(); + + expect( generator.next().value ).toEqual( { type: "authorizeMyyoastSite", payload: {} } ); + } ); + + it( "fails with unexpected_error when the success response has no authorize_url", () => { + const generator = myyoastConnectionActions.authorizeMyyoastSite(); + generator.next(); + generator.next(); + + expect( generator.next( {} ).value ) + .toEqual( { type: "myyoastConnection/setMyyoastActionError", payload: { actionName: "authorize", errorCode: "unexpected_error", message: "" } } ); + + expect( generator.next().value ).toEqual( { type: "myyoastConnection/finishMyyoastAction" } ); + + const final = generator.next(); + expect( final.done ).toBe( true ); + expect( final.value ).toEqual( { ok: false, errorCode: "unexpected_error" } ); + } ); + + it( "returns the error code and details when the body carries an error_code", () => { + const generator = myyoastConnectionActions.authorizeMyyoastSite(); + generator.next(); + generator.next(); + + expect( generator.next( { error_code: "invalid_user" } ).value ) + .toEqual( { type: "myyoastConnection/setMyyoastActionError", payload: { actionName: "authorize", errorCode: "invalid_user", message: "" } } ); + + generator.next(); + const final = generator.next(); + expect( final.done ).toBe( true ); + expect( final.value ).toEqual( { ok: false, errorCode: "invalid_user", details: undefined } ); + } ); + } ); +} ); diff --git a/src/ai/authentication/application/ai-request-sender.php b/src/ai/authentication/application/ai-request-sender.php index fd6b50353f8..e9aca9e1790 100644 --- a/src/ai/authentication/application/ai-request-sender.php +++ b/src/ai/authentication/application/ai-request-sender.php @@ -194,6 +194,10 @@ public function send( Request $request, WP_User $user ): Response { throw $exception; } if ( $this->fallback === null ) { + $this->logger->warning( + 'Primary AI auth strategy failed ({error_id}, HTTP {status}: {message}); no fallback configured, giving up.', + $this->error_context( $exception ), + ); throw $exception; } $this->logger->warning( diff --git a/src/integrations/admin/helpscout-beacon.php b/src/integrations/admin/helpscout-beacon.php index c17c1befa44..a05d5b68ade 100644 --- a/src/integrations/admin/helpscout-beacon.php +++ b/src/integrations/admin/helpscout-beacon.php @@ -98,7 +98,7 @@ class HelpScout_Beacon implements Integration_Interface { 'wpseo_tools', Plans_Page_Integration::PAGE, 'wpseo_workouts', - 'wpseo_integrations', + Integrations_Page::PAGE, ]; /** diff --git a/src/integrations/admin/integrations-page.php b/src/integrations/admin/integrations-page.php index 34f073b236b..8a444931f75 100644 --- a/src/integrations/admin/integrations-page.php +++ b/src/integrations/admin/integrations-page.php @@ -10,6 +10,7 @@ use Yoast\WP\SEO\Dashboard\Infrastructure\Integrations\Site_Kit; use Yoast\WP\SEO\Helpers\Options_Helper; use Yoast\WP\SEO\Integrations\Integration_Interface; +use Yoast\WP\SEO\MyYoast_Client\User_Interface\Integrations_Page_Script_Data as MyYoast_Connection_Script_Data; use Yoast\WP\SEO\Schema\Application\Configuration\Schema_Configuration; /** @@ -17,6 +18,13 @@ */ class Integrations_Page implements Integration_Interface { + /** + * The page identifier of the integrations page. + * + * @var string + */ + public const PAGE = 'wpseo_integrations'; + /** * The admin asset manager. * @@ -66,6 +74,13 @@ class Integrations_Page implements Integration_Interface { */ private $schema_configuration; + /** + * The MyYoast connection script-data provider. + * + * @var MyYoast_Connection_Script_Data + */ + private $myyoast_connection_script_data; + /** * {@inheritDoc} */ @@ -85,6 +100,8 @@ public static function get_conditionals() { * @param Site_Kit_Consent_Management_Endpoint $site_kit_consent_management_endpoint The site kit consent * management endpoint. * @param Schema_Configuration $schema_configuration The schema configuration. + * @param MyYoast_Connection_Script_Data $myyoast_connection_script_data The MyYoast connection + * script-data provider. */ public function __construct( WPSEO_Admin_Asset_Manager $admin_asset_manager, @@ -93,7 +110,8 @@ public function __construct( Jetpack_Conditional $jetpack_conditional, Site_Kit $site_kit_integration_data, Site_Kit_Consent_Management_Endpoint $site_kit_consent_management_endpoint, - Schema_Configuration $schema_configuration + Schema_Configuration $schema_configuration, + MyYoast_Connection_Script_Data $myyoast_connection_script_data ) { $this->admin_asset_manager = $admin_asset_manager; $this->options_helper = $options_helper; @@ -102,6 +120,7 @@ public function __construct( $this->site_kit_integration_data = $site_kit_integration_data; $this->site_kit_consent_management_endpoint = $site_kit_consent_management_endpoint; $this->schema_configuration = $schema_configuration; + $this->myyoast_connection_script_data = $myyoast_connection_script_data; } /** @@ -125,7 +144,7 @@ public function add_submenu_page( $submenu_pages ) { '', \__( 'Integrations', 'wordpress-seo' ), 'wpseo_manage_options', - 'wpseo_integrations', + self::PAGE, [ $this, 'render_target' ], ]; @@ -141,7 +160,7 @@ public function add_submenu_page( $submenu_pages ) { */ public function enqueue_assets() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Date is not processed or saved. - if ( ! isset( $_GET['page'] ) || $_GET['page'] !== 'wpseo_integrations' ) { + if ( ! isset( $_GET['page'] ) || $_GET['page'] !== self::PAGE ) { return; } @@ -226,6 +245,7 @@ public function enqueue_assets() { 'site_kit_configuration' => $this->site_kit_integration_data->to_array(), 'site_kit_consent_management_url' => $this->site_kit_consent_management_endpoint->get_url(), 'schema_framework_enabled' => $this->options_helper->get( 'enable_schema', true ) === true && ! $this->schema_configuration->is_schema_disabled_programmatically(), + 'myyoast_connection' => $this->myyoast_connection_script_data->present(), ], ); } diff --git a/src/myyoast-client/application/authorization-code-handler.php b/src/myyoast-client/application/authorization-code-handler.php index 8a3f84a2aac..9a54cfb103f 100644 --- a/src/myyoast-client/application/authorization-code-handler.php +++ b/src/myyoast-client/application/authorization-code-handler.php @@ -224,12 +224,12 @@ public function exchange_code( int $user_id, string $code, string $state ): Toke // Validate state (CSRF protection). if ( ! \hash_equals( $flow_state->get_state(), $state ) ) { $this->logger->warning( 'Authorization code exchange failed: state parameter mismatch for user {user_id} (potential CSRF).', [ 'user_id' => $user_id ] ); - $this->expiring_store->delete_for_user( self::CURRENT_AUTH_FLOW_STATE_KEY, $user_id ); + $this->discard_flow_state( $user_id ); throw new Token_Request_Failed_Exception( 'invalid_request', 'State parameter mismatch.' ); } // Clean up the stored flow state. - $this->expiring_store->delete_for_user( self::CURRENT_AUTH_FLOW_STATE_KEY, $user_id ); + $this->discard_flow_state( $user_id ); $resource_indicator = $flow_state->get_resource_indicator(); $grant = new Authorization_Code_Grant( $code, $flow_state->get_redirect_uri(), $flow_state->get_code_verifier() ); @@ -258,6 +258,20 @@ public function get_return_url( int $user_id ): ?string { } } + /** + * Discards any pending authorization-flow state for a user. + * + * Used when the provider returns an error (e.g. the user denied consent) so a + * stale flow can't be resumed. A no-op when no flow is pending. + * + * @param int $user_id The WordPress user ID. + * + * @return void + */ + public function discard_flow_state( int $user_id ): void { + $this->expiring_store->delete_for_user( self::CURRENT_AUTH_FLOW_STATE_KEY, $user_id ); + } + /** * Validates the nonce claim in the ID token against the stored nonce. * diff --git a/src/myyoast-client/application/callback-outcome.php b/src/myyoast-client/application/callback-outcome.php new file mode 100644 index 00000000000..2fce1120a34 --- /dev/null +++ b/src/myyoast-client/application/callback-outcome.php @@ -0,0 +1,192 @@ +is_success = $is_success; + $this->is_no_op = $is_no_op; + $this->error_phase = $error_phase; + $this->error_code = $error_code; + } + + /** + * Creates a successful outcome (the code was exchanged and tokens stored). + * + * @return self + */ + public static function success(): self { + return new self( true, false, self::PHASE_NONE, null ); + } + + /** + * Creates a no-op outcome (empty code/state — not a real callback). + * + * @return self + */ + public static function no_op(): self { + return new self( false, true, self::PHASE_NONE, null ); + } + + /** + * Creates an outcome for an error reported by the authorization endpoint + * redirect (the `error` query parameter on the callback). + * + * @param string $oauth_error_code The native OAuth error code. + * + * @return self + */ + public static function provider_error( string $oauth_error_code ): self { + return new self( false, false, self::PHASE_PROVIDER, $oauth_error_code ); + } + + /** + * Creates an outcome for a failure while exchanging the code at the token + * endpoint. + * + * @param string|null $oauth_error_code The native OAuth error code, or null + * when the failure produced no OAuth + * response at all. + * + * @return self + */ + public static function exchange_error( ?string $oauth_error_code ): self { + return new self( false, false, self::PHASE_EXCHANGE, $oauth_error_code ); + } + + /** + * Whether the callback completed successfully. + * + * @return bool + */ + public function is_success(): bool { + return $this->is_success; + } + + /** + * Whether the request carried no actionable callback parameters. + * + * @return bool + */ + public function is_no_op(): bool { + return $this->is_no_op; + } + + /** + * Whether the callback failed. + * + * @return bool + */ + public function is_failure(): bool { + return ! $this->is_success && ! $this->is_no_op; + } + + /** + * Returns the OAuth phase that produced a failure. + * + * @return string A PHASE_* constant. + */ + public function get_error_phase(): string { + return $this->error_phase; + } + + /** + * Returns the native OAuth error code on failure. + * + * @return string|null The OAuth error code, or null when there is none. + */ + public function get_error_code(): ?string { + return $this->error_code; + } + + /** + * Converts the outcome to an associative array for storage. + * + * @return array{is_success: bool, is_no_op: bool, error_phase: string, error_code: string|null} + */ + public function to_array(): array { + return [ + 'is_success' => $this->is_success, + 'is_no_op' => $this->is_no_op, + 'error_phase' => $this->error_phase, + 'error_code' => $this->error_code, + ]; + } + + /** + * Creates a Callback_Outcome from a stored array. + * + * @param array $data The stored array data. + * + * @return self + */ + public static function from_array( array $data ): self { + $error_phase = ( isset( $data['error_phase'] ) && \is_string( $data['error_phase'] ) ) ? $data['error_phase'] : self::PHASE_NONE; + $error_code = ( isset( $data['error_code'] ) && \is_string( $data['error_code'] ) ) ? $data['error_code'] : null; + + return new self( + ! empty( $data['is_success'] ), + ! empty( $data['is_no_op'] ), + $error_phase, + $error_code, + ); + } +} diff --git a/src/myyoast-client/application/exceptions/rate-limited-exception.php b/src/myyoast-client/application/exceptions/rate-limited-exception.php new file mode 100644 index 00000000000..da3b6b8114e --- /dev/null +++ b/src/myyoast-client/application/exceptions/rate-limited-exception.php @@ -0,0 +1,78 @@ +retry_after_seconds = $retry_after_seconds; + } + + /** + * Returns the parsed `Retry-After` value in seconds, or null when absent. + * + * @return int|null + */ + public function get_retry_after_seconds(): ?int { + return $this->retry_after_seconds; + } + + /** + * Parses an HTTP `Retry-After` value into seconds-until-retry. + * + * Accepts either the delta-seconds form (`"120"`) or the HTTP-date form + * (`"Wed, 27 May 2026 14:30:00 GMT"`) per RFC 9110 §10.2.3. + * + * phpcs:disable SlevomatCodingStandard.TypeHints.DisallowMixedTypeHint.DisallowedMixedTypeHint -- Raw header value is heterogeneous (string, numeric, array, or null). + * + * @param mixed $retry_after The raw header value (string, numeric, array, or null). + * + * @return int|null Seconds until retry (clamped to >= 0), or null when unparseable. + * + * phpcs:enable SlevomatCodingStandard.TypeHints.DisallowMixedTypeHint.DisallowedMixedTypeHint + */ + public static function parse_retry_after( $retry_after ): ?int { + if ( \is_array( $retry_after ) ) { + $retry_after = \reset( $retry_after ); + } + + if ( $retry_after === null || $retry_after === '' ) { + return null; + } + + if ( \is_numeric( $retry_after ) ) { + return \max( 0, (int) $retry_after ); + } + + $timestamp = \strtotime( (string) $retry_after ); + if ( $timestamp === false ) { + return null; + } + + return \max( 0, ( $timestamp - \time() ) ); + } +} diff --git a/src/myyoast-client/application/exceptions/registration-not-found-exception.php b/src/myyoast-client/application/exceptions/registration-not-found-exception.php new file mode 100644 index 00000000000..cd234f74805 --- /dev/null +++ b/src/myyoast-client/application/exceptions/registration-not-found-exception.php @@ -0,0 +1,14 @@ + The registration metadata. * * @throws Registration_Failed_Exception If the read fails. */ - public function verify_registration(): array { + public function refresh_registration_status(): array { return $this->client_registration->read_registration(); } diff --git a/src/myyoast-client/application/oauth-callback-handler.php b/src/myyoast-client/application/oauth-callback-handler.php new file mode 100644 index 00000000000..a55acaad47c --- /dev/null +++ b/src/myyoast-client/application/oauth-callback-handler.php @@ -0,0 +1,174 @@ +myyoast_client = $myyoast_client; + $this->auth_code_handler = $auth_code_handler; + $this->expiring_store = $expiring_store; + $this->logger = new NullLogger(); + } + + /** + * Handles an OAuth authorization-code callback. + * + * The outcome is persisted for the user (except for a no-op, which is not a + * real callback) so a later page load can surface it once, and also returned + * for the caller to act on immediately. + * + * @param int $user_id The WordPress user ID the flow belongs to. + * @param string $code The authorization code from the callback (empty if absent). + * @param string $state The state parameter from the callback (empty if absent). + * @param string $error The provider error code from the callback (empty if none). + * + * @return Callback_Outcome The outcome of the callback. + */ + public function handle( int $user_id, string $code, string $state, string $error ): Callback_Outcome { + $outcome = $this->resolve( $user_id, $code, $state, $error ); + + if ( ! $outcome->is_no_op() && $user_id > 0 ) { + $this->expiring_store->persist_for_user( + self::OUTCOME_KEY, + $outcome->to_array(), + self::OUTCOME_TTL, + $user_id, + ); + } + + return $outcome; + } + + /** + * Reads and consumes the pending callback outcome for a user. + * + * Consumed-on-read so the outcome is surfaced exactly once. + * + * @param int $user_id The WordPress user ID. + * + * @return Callback_Outcome|null The outcome, or null when none is pending. + */ + public function consume_outcome( int $user_id ): ?Callback_Outcome { + if ( $user_id <= 0 ) { + return null; + } + + try { + $stored = $this->expiring_store->get_for_user( self::OUTCOME_KEY, $user_id ); + } catch ( Key_Not_Found_Exception | Corrupted_Value_Exception $e ) { + return null; + } + + $this->expiring_store->delete_for_user( self::OUTCOME_KEY, $user_id ); + + if ( ! \is_array( $stored ) ) { + return null; + } + + return Callback_Outcome::from_array( $stored ); + } + + /** + * Performs the callback orchestration and classifies the result. + * + * @param int $user_id The WordPress user ID the flow belongs to. + * @param string $code The authorization code from the callback (empty if absent). + * @param string $state The state parameter from the callback (empty if absent). + * @param string $error The provider error code from the callback (empty if none). + * + * @return Callback_Outcome The outcome of the callback. + */ + private function resolve( int $user_id, string $code, string $state, string $error ): Callback_Outcome { + if ( $error !== '' ) { + // The provider returned an error: drop the pending flow so it can't be resumed. + $this->auth_code_handler->discard_flow_state( $user_id ); + return Callback_Outcome::provider_error( $error ); + } + + if ( $code === '' || $state === '' ) { + // Stale bookmark or someone hitting the callback URL directly: not a real callback. + return Callback_Outcome::no_op(); + } + + try { + $this->myyoast_client->exchange_authorization_code( $user_id, $code, $state ); + } catch ( Token_Request_Failed_Exception $e ) { + return Callback_Outcome::exchange_error( $e->get_error_code() ); + } catch ( Throwable $e ) { + $this->logger->error( + 'Unexpected error during MyYoast OAuth callback exchange for user {user_id}: {error}', + [ + 'user_id' => $user_id, + 'error' => $e->getMessage(), + ], + ); + // No OAuth response was produced, so there is no native error code to surface. + return Callback_Outcome::exchange_error( null ); + } + + return Callback_Outcome::success(); + } +} diff --git a/src/myyoast-client/infrastructure/registration/client-registration.php b/src/myyoast-client/infrastructure/registration/client-registration.php index 92ffd749f51..4e5a02cf238 100644 --- a/src/myyoast-client/infrastructure/registration/client-registration.php +++ b/src/myyoast-client/infrastructure/registration/client-registration.php @@ -7,10 +7,13 @@ use Yoast\WP\SEO\Exceptions\Locking\Lock_Timeout_Exception; use Yoast\WP\SEO\Helpers\Lock_Helper; use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Discovery_Failed_Exception; +use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Rate_Limited_Exception; use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Registration_Failed_Exception; +use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Registration_Not_Found_Exception; use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Server_Capability_Exception; use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Client_Registration_Interface; use Yoast\WP\SEO\MyYoast_Client\Domain\Auth_Token_Type; +use Yoast\WP\SEO\MyYoast_Client\Domain\HTTP_Response; use Yoast\WP\SEO\MyYoast_Client\Domain\Registered_Client; use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Encryption; use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Encryption_Exception; @@ -224,7 +227,9 @@ public function ensure_registered( array $redirect_uris ): Registered_Client { * * @return array The registration metadata. * - * @throws Registration_Failed_Exception If the read fails. + * @throws Registration_Not_Found_Exception If the server reports the registration is gone (HTTP 401/404). + * @throws Rate_Limited_Exception If the server rate-limited the request (HTTP 429). + * @throws Registration_Failed_Exception If the read fails for any other reason. */ public function read_registration(): array { $registered_client = $this->get_registered_client(); @@ -252,12 +257,18 @@ public function read_registration(): array { if ( $result->get_status() === 401 || $result->get_status() === 404 ) { $this->logger->warning( 'Registration is no longer valid (HTTP {status}), clearing local registration.', [ 'status' => $result->get_status() ] ); $this->forget_registration(); - throw new Registration_Failed_Exception( + throw new Registration_Not_Found_Exception( // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. 'Registration is no longer valid (HTTP ' . $result->get_status() . ').', ); } + if ( $result->get_status() === 429 ) { + $this->logger->warning( 'Registration read was rate-limited (HTTP 429).' ); + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. + throw new Rate_Limited_Exception( 'Registration read was rate-limited (HTTP 429).', $this->get_retry_after_seconds( $result ) ); + } + if ( ! $result->is_successful() ) { $error_message = (string) $result->get_body_value( 'error_description', $result->get_body_value( 'error', '' ) ); throw new Registration_Failed_Exception( @@ -348,7 +359,9 @@ public function rotate_registration_keys(): Registered_Client { * * @return Registered_Client The updated credentials. * - * @throws Registration_Failed_Exception If the update fails. + * @throws Registration_Not_Found_Exception If the server reports the registration is gone (HTTP 401/404). + * @throws Rate_Limited_Exception If the server rate-limited the request (HTTP 429). + * @throws Registration_Failed_Exception If the update fails for any other reason. */ private function update_redirect_uris( array $redirect_uris ): Registered_Client { $registered_client = $this->get_registered_client(); @@ -382,6 +395,21 @@ private function update_redirect_uris( array $redirect_uris ): Registered_Client ], ); + if ( $result->get_status() === 401 || $result->get_status() === 404 ) { + $this->logger->warning( 'Registration is no longer valid on update (HTTP {status}), clearing local registration.', [ 'status' => $result->get_status() ] ); + $this->forget_registration(); + throw new Registration_Not_Found_Exception( + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. + 'Registration is no longer valid (HTTP ' . $result->get_status() . ').', + ); + } + + if ( $result->get_status() === 429 ) { + $this->logger->warning( 'Registration update was rate-limited (HTTP 429).' ); + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. + throw new Rate_Limited_Exception( 'Registration update was rate-limited (HTTP 429).', $this->get_retry_after_seconds( $result ) ); + } + if ( ! $result->is_successful() ) { $error_message = (string) $result->get_body_value( 'error_description', $result->get_body_value( 'error', '' ) ); throw new Registration_Failed_Exception( @@ -568,6 +596,18 @@ private function get_option_key(): string { return self::OPTION_KEY_PREFIX . $this->issuer_config->get_issuer_key(); } + /** + * Extracts the `Retry-After` value (in seconds) from a 429 response, if any. + * + * @param HTTP_Response $result The 429 response. + * + * @return int|null Seconds until retry, or null when absent or unparseable. + */ + private function get_retry_after_seconds( HTTP_Response $result ): ?int { + $headers = $result->get_headers(); + return Rate_Limited_Exception::parse_retry_after( ( $headers['retry-after'] ?? null ) ); + } + /** * Performs the actual DCR registration request. * @@ -575,7 +615,8 @@ private function get_option_key(): string { * * @return Registered_Client The registration result. * - * @throws Registration_Failed_Exception If registration fails. + * @throws Rate_Limited_Exception If the server rate-limited the request (HTTP 429). + * @throws Registration_Failed_Exception If registration fails for any other reason. */ private function do_register( array $redirect_uris ): Registered_Client { try { @@ -631,6 +672,12 @@ private function do_register( array $redirect_uris ): Registered_Client { throw new Registration_Failed_Exception( 'DCR request failed: ' . $error_message ); } + if ( $result->get_status() === 429 ) { + $this->logger->warning( 'DCR was rate-limited (HTTP 429).' ); + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. + throw new Rate_Limited_Exception( 'DCR was rate-limited (HTTP 429).', $this->get_retry_after_seconds( $result ) ); + } + if ( $result->get_status() !== 201 ) { $error_message = (string) $result->get_body_value( 'error_description', $result->get_body_value( 'error', '' ) ); throw new Registration_Failed_Exception( diff --git a/src/myyoast-client/infrastructure/wordpress/redirect-uri-provider.php b/src/myyoast-client/infrastructure/wordpress/redirect-uri-provider.php index e45fb7ae0d3..991d13ed5e9 100644 --- a/src/myyoast-client/infrastructure/wordpress/redirect-uri-provider.php +++ b/src/myyoast-client/infrastructure/wordpress/redirect-uri-provider.php @@ -3,17 +3,17 @@ // phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure. namespace Yoast\WP\SEO\MyYoast_Client\Infrastructure\WordPress; -use Yoast\WP\SEO\General\User_Interface\General_Page_Integration; use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Redirect_URI_Provider_Interface; use Yoast\WP\SEO\MyYoast_Client\Domain\Registered_Client; use Yoast\WP\SEO\MyYoast_Client\Domain\Resource_Indicator; +use Yoast\WP\SEO\MyYoast_Client\User_Interface\OAuth_Callback_Integration; /** * WordPress implementation of the redirect URI provider. * - * The canonical redirect URI is the Yoast admin page with the OAuth callback marker query - * var. Site owners and plugins can register additional URIs and override the per-flow embed - * URI through filters. + * The canonical redirect URI is this site's `admin-post.php` OAuth callback endpoint, exposed + * by `OAuth_Callback_Integration::get_callback_url()`. Site owners and plugins can register + * additional URIs and override the per-flow embed URI through filters. */ class Redirect_URI_Provider implements Redirect_URI_Provider_Interface { @@ -124,10 +124,7 @@ public function get_authorization_redirect_uri( * @return string The canonical redirect URI. */ private function get_canonical_redirect_uri(): string { - return \get_admin_url( - null, - 'admin.php?page=' . General_Page_Integration::PAGE . '&yoast_myyoast_oauth_callback=1', - ); + return OAuth_Callback_Integration::get_callback_url(); } /** diff --git a/src/myyoast-client/user-interface/auth-command.php b/src/myyoast-client/user-interface/auth-command.php index 2ad361b1f33..672b4ce391f 100644 --- a/src/myyoast-client/user-interface/auth-command.php +++ b/src/myyoast-client/user-interface/auth-command.php @@ -266,7 +266,7 @@ public function register( $args = null, $assoc_args = null ): void { } /** - * Verifies the client registration with the server. + * Refreshes the client registration status against the server. * * Reads the current registration from the authorization server to * confirm it is still valid and shows the registration metadata. @@ -284,8 +284,10 @@ public function register( $args = null, $assoc_args = null ): void { * * ## EXAMPLES * - * wp yoast auth verify - * wp yoast auth verify --format=json + * wp yoast auth refresh-status + * wp yoast auth refresh-status --format=json + * + * @subcommand refresh-status * * @when after_wp_load * @@ -294,17 +296,17 @@ public function register( $args = null, $assoc_args = null ): void { * * @return void * - * @throws ExitException When verification fails. + * @throws ExitException When the status refresh fails. */ - public function verify( $args = null, $assoc_args = null ): void { + public function refresh_status( $args = null, $assoc_args = null ): void { if ( ! $this->myyoast_client->is_registered() ) { WP_CLI::error( 'Not registered. Run "wp yoast auth register" first.' ); } try { - $metadata = $this->myyoast_client->verify_registration(); + $metadata = $this->myyoast_client->refresh_registration_status(); } catch ( Exception $e ) { - WP_CLI::error( 'Verification failed: ' . $e->getMessage() ); + WP_CLI::error( 'Status refresh failed: ' . $e->getMessage() ); return; } diff --git a/src/myyoast-client/user-interface/integrations-page-script-data.php b/src/myyoast-client/user-interface/integrations-page-script-data.php new file mode 100644 index 00000000000..03adf31775f --- /dev/null +++ b/src/myyoast-client/user-interface/integrations-page-script-data.php @@ -0,0 +1,145 @@ +status_presenter = $status_presenter; + $this->myyoast_connection_conditional = $myyoast_connection_conditional; + $this->callback_handler = $callback_handler; + $this->short_link_helper = $short_link_helper; + } + + /** + * Returns the MyYoast connection payload, or `null` when the feature flag + * is disabled so the Integrations page can omit the key entirely. + * + * The `callbackOutcome` slot is populated (and consumed) when an OAuth + * callback finished for this user since the last time the Integrations page + * was rendered, so the React app can surface a one-shot notification. + * + * @return array{initialStatus: array{is_provisioned: bool, is_registered: bool, registered_at: int|null, registered_at_iso: string|null, redirect_uris: array, redirect_uris_match: bool}, callbackOutcome: array{kind: string, key: string}|null, linkParams: array}|null + */ + public function present(): ?array { + if ( ! $this->myyoast_connection_conditional->is_met() ) { + return null; + } + + return [ + 'initialStatus' => $this->status_presenter->present(), + 'callbackOutcome' => $this->consume_callback_outcome(), + 'linkParams' => $this->short_link_helper->get_query_params(), + ]; + } + + /** + * Reads and consumes the pending OAuth callback outcome for the current user + * and shapes it for the React app. + * + * @return array{kind: string, key: string}|null The outcome, or null when none is pending. + */ + private function consume_callback_outcome(): ?array { + $outcome = $this->callback_handler->consume_outcome( \get_current_user_id() ); + if ( $outcome === null ) { + return null; + } + + if ( $outcome->is_success() ) { + return [ + 'kind' => 'success', + 'key' => 'verify_success', + ]; + } + + return [ + 'kind' => 'error', + 'key' => $this->error_message_key( $outcome ), + ]; + } + + /** + * Maps a failed callback outcome to the front-end message key. + * + * Translates the neutral, native-OAuth outcome into the message keys the + * integrations-page JS understands (see `messageFor()` in + * `myyoast-integration.js`). The same missing code means different things per + * OAuth phase: a provider error other than `access_denied` is unexpected, + * while a token-endpoint error other than `invalid_grant` is a generic token + * failure. + * + * @param Callback_Outcome $outcome The failed callback outcome. + * + * @return string The message key the front-end maps to copy. + */ + private function error_message_key( Callback_Outcome $outcome ): string { + if ( $outcome->get_error_phase() === Callback_Outcome::PHASE_PROVIDER ) { + return ( $outcome->get_error_code() === 'access_denied' ) ? 'connection_cancelled' : 'unexpected_error'; + } + + if ( $outcome->get_error_code() === 'invalid_grant' ) { + return 'token_request_failed_invalid_grant'; + } + + if ( $outcome->get_error_code() === null ) { + return 'unexpected_error'; + } + + return 'token_request_failed'; + } +} diff --git a/src/myyoast-client/user-interface/management-route.php b/src/myyoast-client/user-interface/management-route.php new file mode 100644 index 00000000000..1ed572778ee --- /dev/null +++ b/src/myyoast-client/user-interface/management-route.php @@ -0,0 +1,566 @@ +myyoast_client = $myyoast_client; + $this->status_presenter = $status_presenter; + $this->issuer_config = $issuer_config; + $this->client_registration = $client_registration; + $this->logger = new NullLogger(); + } + + /** + * Returns the conditionals on which this route should be registered. + * + * @return array + */ + public static function get_conditionals() { + return [ MyYoast_Connection_Conditional::class ]; + } + + /** + * Registers the routes with WordPress. + * + * @return void + */ + public function register_routes() { + $permission_callback = [ $this, 'can_manage' ]; + + \register_rest_route( + Main::API_V1_NAMESPACE, + self::ROUTE_PREFIX . self::STATUS_ROUTE, + [ + 'methods' => 'GET', + 'callback' => [ $this, 'get_status' ], + 'permission_callback' => $permission_callback, + ], + ); + + \register_rest_route( + Main::API_V1_NAMESPACE, + self::ROUTE_PREFIX . self::REFRESH_STATUS_ROUTE, + [ + 'methods' => 'POST', + 'callback' => [ $this, 'refresh_status' ], + 'permission_callback' => $permission_callback, + ], + ); + + \register_rest_route( + Main::API_V1_NAMESPACE, + self::ROUTE_PREFIX . self::REGISTER_ROUTE, + [ + 'methods' => 'POST', + 'callback' => [ $this, 'register' ], + 'permission_callback' => $permission_callback, + ], + ); + + \register_rest_route( + Main::API_V1_NAMESPACE, + self::ROUTE_PREFIX . self::REGISTRATION_ROUTE, + [ + [ + 'methods' => 'PUT', + 'callback' => [ $this, 'update_registration' ], + 'permission_callback' => $permission_callback, + ], + [ + 'methods' => 'DELETE', + 'callback' => [ $this, 'deregister' ], + 'permission_callback' => $permission_callback, + ], + ], + ); + + \register_rest_route( + Main::API_V1_NAMESPACE, + self::ROUTE_PREFIX . self::AUTHORIZE_ROUTE, + [ + 'methods' => 'POST', + 'callback' => [ $this, 'authorize' ], + 'permission_callback' => $permission_callback, + 'args' => [ + 'return_url' => [ + 'type' => 'string', + 'required' => false, + 'description' => 'URL to send the browser back to once the flow completes. Validated against the site host; an invalid or off-site URL is ignored.', + 'sanitize_callback' => 'esc_url_raw', + ], + ], + ], + ); + } + + /** + * Permission callback for every endpoint. + * + * @return bool + */ + public function can_manage() { + return \current_user_can( 'wpseo_manage_options' ); + } + + /** + * GET /myyoast/status — returns the current status payload. + * + * @return WP_REST_Response + */ + public function get_status() { + return $this->respond_with_connection_status( 200, null ); + } + + /** + * POST /myyoast/refresh-status — refreshes the registration status against the server. + * + * Throttled: a successful upstream refresh suppresses further upstream calls + * for an hour. Within that window the call is skipped and the locally-derived + * status is returned unchanged, so a page reload does not hit MyYoast's rate + * limit. The upstream response body is never stored — only the throttle marker. + * + * @return WP_REST_Response + */ + public function refresh_status() { + if ( \get_transient( $this->get_refresh_throttle_key() ) !== false ) { + return $this->respond_with_connection_status( 200, null ); + } + + try { + $this->myyoast_client->refresh_registration_status(); + } catch ( Registration_Failed_Exception $e ) { + return $this->handle_exception( $e ); + } + + // Mark only on success: a failed or rate-limited attempt must not suppress the next retry. + \set_transient( $this->get_refresh_throttle_key(), 1, self::REFRESH_THROTTLE_TTL_IN_SECONDS ); + + return $this->respond_with_connection_status( 200, null ); + } + + /** + * POST /myyoast/register — connects the site to MyYoast. + * + * @return WP_REST_Response + */ + public function register() { + $gate = $this->require_provisioned(); + if ( $gate !== null ) { + return $gate; + } + + try { + $this->myyoast_client->ensure_registered(); + } catch ( Registration_Failed_Exception $e ) { + return $this->handle_exception( $e ); + } + + $this->clear_refresh_throttle(); + + return $this->respond_with_connection_status( 200, 'connect_success' ); + } + + /** + * PUT /myyoast/registration — re-syncs the connection's redirect URIs. + * + * Used to recover the connection after the site's URL has changed. The client + * resolves the current redirect URIs itself and updates the registration in + * place (RFC 7592 PUT) when the set differs from what is stored. + * + * @return WP_REST_Response + */ + public function update_registration() { + $gate = $this->require_provisioned(); + if ( $gate !== null ) { + return $gate; + } + + try { + $this->myyoast_client->ensure_registered(); + } catch ( Throwable $e ) { + return $this->handle_exception( $e ); + } + + $this->clear_refresh_throttle(); + + return $this->respond_with_connection_status( 200, 'update_success' ); + } + + /** + * POST /myyoast/authorize — starts the authorization-code flow and returns + * the URL the browser should be sent to. + * + * Completing the round-trip verifies that the site's redirect URI is + * reachable and that the user is who they claim to be. The client resolves + * the redirect URI itself, and the authorization-code handler marks it + * validated once the returning code is exchanged. + * + * The optional `return_url` is where the browser is sent once the flow + * completes; the caller supplies it because the flow can be started from + * different admin pages. It is validated against the site's own host, so an + * off-site or tampered value is dropped (and the callback then surfaces a + * standalone outcome rather than redirecting anywhere). + * + * @param WP_REST_Request $request The REST request. + * + * @return WP_REST_Response + */ + public function authorize( WP_REST_Request $request ): WP_REST_Response { + if ( $this->client_registration->get_registered_client() === null ) { + return $this->error_response( 'registration_gone' ); + } + + $user_id = \get_current_user_id(); + if ( $user_id <= 0 ) { + // Return HTTP 200 with the error_code in the body like every other failure here: + // api-fetch rejects non-2xx, which would mask invalid_user as a generic unexpected_error. + return $this->error_response( 'invalid_user' ); + } + + $return_url = $this->resolve_return_url( $request->get_param( 'return_url' ) ); + + try { + $authorize_url = $this->myyoast_client->get_authorization_url( + $user_id, + [ 'openid' ], + null, + $return_url, + ); + } catch ( Authorization_Flow_Exception $e ) { + return $this->error_response( 'registration_failed', $e ); + } catch ( Invalid_Resource_Exception $e ) { + return $this->handle_exception( $e ); + } + + $body = [ + 'authorize_url' => $authorize_url, + 'status' => $this->status_presenter->present(), + ]; + + return new WP_REST_Response( $body, 200 ); + } + + /** + * DELETE /myyoast/registration — disconnects the site server-side and locally. + * + * @return WP_REST_Response + */ + public function deregister() { + // Disconnect is best-effort on the server but always authoritative + // locally: whatever happens with the remote RFC 7592 DELETE, the site + // ends up disconnected here. An orphaned server-side client is cleaned up + // automatically by MyYoast. deregister() already clears the local + // registration and returns false (rather than throwing) on transport + // failure. + $remote_cleared = false; + try { + $remote_cleared = $this->myyoast_client->deregister(); + } catch ( Throwable $e ) { + $this->logger->warning( + 'Unexpected error during MyYoast deregistration; disconnecting locally anyway: {error}', + [ 'error' => $e->getMessage() ], + ); + } finally { + // Always clear site tokens, even when the remote call threw, so the + // site is never left half-connected. + $this->myyoast_client->clear_all_site_tokens(); + } + + if ( ! $remote_cleared ) { + $this->logger->warning( 'MyYoast server-side deregistration was not confirmed; the site was disconnected locally.' ); + } + + $this->clear_refresh_throttle(); + + return $this->respond_with_connection_status( 200, 'disconnect_success' ); + } + + /** + * Validates a caller-supplied return URL against the site's own host. + * + * The return URL is optional: callers that have nowhere meaningful to send + * the user back to omit it. Anything off-site or otherwise invalid is treated + * as absent rather than rewritten to a default — `wp_validate_redirect()` with + * an empty fallback yields an empty string, which we normalize to null. The + * callback re-validates the stored value before redirecting, so this is the + * first of two gates against an open redirect. + * + * @param string|null $return_url The sanitized `return_url` request parameter (the route's + * args schema coerces it to a string; absent when not sent). + * + * @return string|null The validated same-host URL, or null when none applies. + */ + private function resolve_return_url( ?string $return_url ): ?string { + if ( $return_url === null || $return_url === '' ) { + return null; + } + + $validated = \wp_validate_redirect( $return_url, '' ); + + return ( $validated === '' ) ? null : $validated; + } + + /** + * Returns a "not provisioned" response when SS or IAT is empty. + * + * @return WP_REST_Response|null Response when blocked, null otherwise. + */ + private function require_provisioned(): ?WP_REST_Response { + if ( $this->is_provisioned() ) { + return null; + } + + return $this->error_response( 'not_provisioned' ); + } + + /** + * Whether the plugin is provisioned for OAuth (software statement + IAT). + * + * @return bool + */ + private function is_provisioned(): bool { + return ( $this->issuer_config->get_software_statement() !== '' ) + && ( $this->issuer_config->get_initial_access_token() !== '' ); + } + + /** + * Maps an exception to a REST error response. + * + * The REST endpoint itself executed correctly — what failed is an upstream + * call to MyYoast or a precondition. We therefore return HTTP 200 with an + * `error_code` in the body that the UI translates into actionable copy. + * Genuine request-validation failures return 4xx separately (see callers). + * + * @param Throwable $exception The exception to handle. + * + * @return WP_REST_Response + */ + private function handle_exception( Throwable $exception ): WP_REST_Response { + if ( $exception instanceof Registration_Not_Found_Exception ) { + return $this->error_response( 'registration_gone', $exception ); + } + + if ( $exception instanceof Rate_Limited_Exception ) { + $retry_after = $exception->get_retry_after_seconds(); + $details = ( $retry_after !== null ) ? [ 'retry_after_seconds' => $retry_after ] : []; + return $this->error_response( 'rate_limited', $exception, 200, $details ); + } + + if ( $exception instanceof Server_Capability_Exception ) { + return $this->error_response( 'server_capability', $exception ); + } + + if ( $exception instanceof Discovery_Failed_Exception ) { + return $this->error_response( 'myyoast_unreachable', $exception ); + } + + if ( $exception instanceof Token_Request_Failed_Exception ) { + $code = ( $exception->get_error_code() === 'invalid_grant' ) ? 'token_request_failed_invalid_grant' : 'token_request_failed'; + return $this->error_response( $code, $exception ); + } + + if ( $exception instanceof Token_Storage_Exception ) { + return $this->error_response( 'token_storage_failed', $exception ); + } + + if ( $exception instanceof Invalid_Resource_Exception ) { + return $this->error_response( 'invalid_resource', $exception ); + } + + if ( $exception instanceof Registration_Failed_Exception ) { + return $this->error_response( 'registration_failed', $exception ); + } + + $this->logger->error( + 'Unexpected exception in MyYoast management route: {message}', + [ 'message' => $exception->getMessage() ], + ); + + return $this->error_response( 'unexpected_error', $exception ); + } + + /** + * Returns the issuer-scoped transient key for the refresh throttle marker. + * + * @return string The transient key. + */ + private function get_refresh_throttle_key(): string { + return \sprintf( + '%s_%s', + self::REFRESH_THROTTLE_TRANSIENT_PREFIX, + $this->issuer_config->get_issuer_key(), + ); + } + + /** + * Clears the refresh throttle marker so the next status read hits the server. + * + * Called after any endpoint that changes the registration (connect, re-sync, + * disconnect): the throttle exists only to spare MyYoast's rate limit on + * unchanged status, so a deliberate state change must invalidate it. + * + * @return void + */ + private function clear_refresh_throttle(): void { + \delete_transient( $this->get_refresh_throttle_key() ); + } + + /** + * Builds a successful response carrying the refreshed status payload. + * + * @param int $status The HTTP status. + * @param string|null $message_key The key in the i18n message map for the success notice, or null when none applies. + * + * @return WP_REST_Response + */ + private function respond_with_connection_status( int $status, ?string $message_key ): WP_REST_Response { + $body = [ + 'status' => $this->status_presenter->present(), + ]; + if ( $message_key !== null ) { + $body['message_key'] = $message_key; + } + + return new WP_REST_Response( $body, $status ); + } + + /** + * Builds an error response. + * + * Defaults to HTTP 200 — the REST endpoint succeeded; the failure is in + * an upstream call or precondition, and the UI keys off `error_code`, + * not the HTTP status. Genuine 4xx (e.g. validation failures) pass an + * explicit status. + * + * @param string $error_code The machine-readable error code (looked up client-side in the i18n map). + * @param Throwable|null $exception Optional exception (logged when present). + * @param int $status The HTTP status. Defaults to 200. + * @param array $details Optional extra fields the UI may use to enrich the error message. + * + * @return WP_REST_Response + */ + private function error_response( string $error_code, ?Throwable $exception = null, int $status = 200, array $details = [] ): WP_REST_Response { + if ( $exception !== null ) { + $this->logger->warning( + 'MyYoast management error ({code}): {message}', + [ + 'code' => $error_code, + 'message' => $exception->getMessage(), + ], + ); + } + + $body = [ + 'error_code' => $error_code, + 'status' => $this->status_presenter->present(), + ]; + if ( $details !== [] ) { + $body['details'] = $details; + } + + return new WP_REST_Response( $body, $status ); + } +} diff --git a/src/myyoast-client/user-interface/oauth-callback-integration.php b/src/myyoast-client/user-interface/oauth-callback-integration.php new file mode 100644 index 00000000000..348ccdd3b78 --- /dev/null +++ b/src/myyoast-client/user-interface/oauth-callback-integration.php @@ -0,0 +1,168 @@ +callback_handler = $callback_handler; + $this->auth_code_handler = $auth_code_handler; + $this->redirect_helper = $redirect_helper; + } + + /** + * Returns the conditionals on which this integration should be loaded. + * + * @return array + */ + public static function get_conditionals() { + return [ MyYoast_Connection_Conditional::class ]; + } + + /** + * Registers the callback endpoint and points the site's OAuth redirect URI at it. + * + * @return void + */ + public function register_hooks() { + \add_action( 'admin_post_' . self::CALLBACK_ACTION, [ $this, 'handle' ] ); + } + + /** + * Returns this site's dedicated OAuth callback endpoint URL. + * + * @return string The callback URL. + */ + public static function get_callback_url(): string { + return \get_admin_url( null, 'admin-post.php?action=' . self::CALLBACK_ACTION ); + } + + /** + * Handles the OAuth callback request. + * + * @return void + */ + public function handle(): void { + $user_id = \get_current_user_id(); + $return_url = $this->resolve_return_url( $user_id ); + + // admin_post_* (no _nopriv variant) only fires for logged-in users, so $user_id should + // always be > 0 here. Defensive check in case the hook is dispatched manually. + if ( $user_id <= 0 ) { + $this->redirect_helper->do_safe_redirect( $return_url ); + return; + } + + // The handler records the outcome for the next page load to surface; this + // endpoint only needs to send the browser back where the flow started. + $this->callback_handler->handle( + $user_id, + $this->read_query_arg( 'code' ), + $this->read_query_arg( 'state' ), + $this->read_query_arg( 'error' ), + ); + + $this->redirect_helper->do_safe_redirect( $return_url ); + } + + /** + * Resolves the URL to send the browser back to after the callback runs. + * + * Falls back to the integrations page when no return URL is stored + * (stale bookmark, no pending flow). + * + * @param int $user_id The WordPress user ID. + * + * @return string The return URL. + */ + private function resolve_return_url( int $user_id ): string { + $fallback = \admin_url( 'admin.php?page=' . General_Page_Integration::PAGE ); + + if ( $user_id > 0 ) { + $stored = $this->auth_code_handler->get_return_url( $user_id ); + if ( \is_string( $stored ) && $stored !== '' ) { + // Defense in depth: the stored URL is only ever written as an + // admin_url by the management route, but validate it against the + // site's own host before redirecting so a tampered store entry + // can't become an open redirect. + return \wp_validate_redirect( $stored, $fallback ); + } + } + + return $fallback; + } + + /** + * Reads a query argument, returning an empty string when missing. + * + * @param string $name The query argument name. + * + * @return string The sanitized value. + */ + private function read_query_arg( string $name ): string { + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- CSRF defense is OAuth `state` validated inside exchange_code. + if ( ! isset( $_GET[ $name ] ) || ! \is_string( $_GET[ $name ] ) ) { + return ''; + } + return \sanitize_text_field( \wp_unslash( $_GET[ $name ] ) ); + // phpcs:enable WordPress.Security.NonceVerification.Recommended + } +} diff --git a/src/myyoast-client/user-interface/status-presenter.php b/src/myyoast-client/user-interface/status-presenter.php new file mode 100644 index 00000000000..0399310c196 --- /dev/null +++ b/src/myyoast-client/user-interface/status-presenter.php @@ -0,0 +1,182 @@ +client_registration = $client_registration; + $this->issuer_config = $issuer_config; + $this->redirect_uri_provider = $redirect_uri_provider; + } + + /** + * Returns the current status payload. + * + * @return array{is_provisioned: bool, is_registered: bool, registered_at: int|null, registered_at_iso: string|null, redirect_uris: array, redirect_uris_match: bool} + */ + public function present(): array { + $is_provisioned = ( $this->issuer_config->get_software_statement() !== '' ) + && ( $this->issuer_config->get_initial_access_token() !== '' ); + + $registered_client = $this->client_registration->get_registered_client(); + $is_registered = ( $registered_client !== null ); + + $registered_at = null; + $registered_at_iso = null; + $redirect_uris = []; + $redirect_uris_match = true; + + if ( $registered_client !== null ) { + $registered_at = $this->extract_registered_at( $registered_client ); + $registered_at_iso = ( $registered_at !== null ) ? \gmdate( 'c', $registered_at ) : null; + $redirect_uris = $this->extract_redirect_uris( $registered_client ); + $redirect_uris_match = $this->redirect_uris_match( $registered_client ); + } + + return [ + 'is_provisioned' => $is_provisioned, + 'is_registered' => $is_registered, + 'registered_at' => $registered_at, + 'registered_at_iso' => $registered_at_iso, + 'redirect_uris' => $redirect_uris, + 'redirect_uris_match' => $redirect_uris_match, + ]; + } + + /** + * Extracts the registration timestamp (RFC 7591 `client_id_issued_at`). + * + * @param Registered_Client $client The registered client. + * + * @return int|null Unix timestamp, or null if absent or not coercible. + */ + private function extract_registered_at( Registered_Client $client ): ?int { + $metadata = $client->get_metadata(); + if ( ! isset( $metadata['client_id_issued_at'] ) ) { + return null; + } + + $value = $metadata['client_id_issued_at']; + if ( ! \is_numeric( $value ) ) { + return null; + } + + $timestamp = (int) $value; + return ( $timestamp > 0 ) ? $timestamp : null; + } + + /** + * Whether the registration's redirect URIs still match what this site would register today. + * + * A mismatch means the site's URL has changed since it was connected, and the + * registration needs re-syncing. + * + * @param Registered_Client $client The registered client. + * + * @return bool + */ + private function redirect_uris_match( Registered_Client $client ): bool { + return $client->has_redirect_uris( $this->redirect_uri_provider->get_redirect_uris() ); + } + + /** + * Returns the stored redirect URIs annotated with their origin (scheme + + * host + optional port) and their verification state. + * + * A URI is verified once a user has completed the authorization-code flow for + * it on this site; that state is tracked on the registration. + * + * @param Registered_Client $client The registered client. + * + * @return array + */ + private function extract_redirect_uris( Registered_Client $client ): array { + $result = []; + foreach ( $client->get_redirect_uris() as $uri ) { + if ( ! \is_string( $uri ) || $uri === '' ) { + continue; + } + + $origin = $this->extract_origin( $uri ); + if ( $origin === null ) { + continue; + } + + $result[] = [ + 'uri' => $uri, + 'origin' => $origin, + 'is_verified' => $client->is_uri_validated( $uri ), + ]; + } + + return $result; + } + + /** + * Extracts the origin (scheme + host + optional port) from a URI. + * + * @param string $uri The URI to parse. + * + * @return string|null The origin, or null if the URI couldn't be parsed. + */ + private function extract_origin( string $uri ): ?string { + $parts = \wp_parse_url( $uri ); + if ( ! \is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) { + return null; + } + + $origin = $parts['scheme'] . '://' . $parts['host']; + if ( isset( $parts['port'] ) ) { + $origin .= ':' . $parts['port']; + } + + return $origin; + } +} diff --git a/tests/Unit/AI/Authentication/Application/AI_Request_Sender/AI_Request_Sender_Test.php b/tests/Unit/AI/Authentication/Application/AI_Request_Sender/AI_Request_Sender_Test.php index 13614c1bedd..60cf958a0e7 100644 --- a/tests/Unit/AI/Authentication/Application/AI_Request_Sender/AI_Request_Sender_Test.php +++ b/tests/Unit/AI/Authentication/Application/AI_Request_Sender/AI_Request_Sender_Test.php @@ -129,9 +129,20 @@ public function test_send_falls_back_on_remote_request_exception(): void { * @return void */ public function test_send_rethrows_when_no_fallback(): void { - $this->primary->expects( 'send' )->andThrow( new Unauthorized_Exception( 'no-recovery', 401 ) ); + $this->primary->expects( 'send' )->andThrow( new Unauthorized_Exception( 'no-recovery', 401, 'oauth-expired' ) ); + + $logger = Mockery::mock( LoggerInterface::class ); + $logger->expects( 'warning' )->once()->with( + 'Primary AI auth strategy failed ({error_id}, HTTP {status}: {message}); no fallback configured, giving up.', + [ + 'error_id' => 'oauth-expired', + 'status' => 401, + 'message' => 'no-recovery', + ], + ); $sender = new AI_Request_Sender( $this->primary ); + $sender->setLogger( $logger ); $this->expectException( Unauthorized_Exception::class ); $sender->get_suggestions( $this->suggestions_parameters() ); diff --git a/tests/Unit/Integrations/Admin/Integrations_Page_Integration_Test.php b/tests/Unit/Integrations/Admin/Integrations_Page_Integration_Test.php index c6179dca092..b03d526c328 100644 --- a/tests/Unit/Integrations/Admin/Integrations_Page_Integration_Test.php +++ b/tests/Unit/Integrations/Admin/Integrations_Page_Integration_Test.php @@ -12,6 +12,7 @@ use Yoast\WP\SEO\Dashboard\Infrastructure\Integrations\Site_Kit; use Yoast\WP\SEO\Helpers\Options_Helper; use Yoast\WP\SEO\Integrations\Admin\Integrations_Page; +use Yoast\WP\SEO\MyYoast_Client\User_Interface\Integrations_Page_Script_Data; use Yoast\WP\SEO\Schema\Application\Configuration\Schema_Configuration; use Yoast\WP\SEO\Tests\Unit\TestCase; @@ -74,6 +75,13 @@ final class Integrations_Page_Integration_Test extends TestCase { */ private $schema_configuration; + /** + * The MyYoast connection script-data provider. + * + * @var Mockery\MockInterface|Integrations_Page_Script_Data + */ + private $myyoast_connection_script_data; + /** * The instance under test. * @@ -98,6 +106,7 @@ protected function set_up() { $this->site_kit_configuration = Mockery::mock( Site_Kit::class ); $this->site_kit_consent_management_endpoint = Mockery::mock( Site_Kit_Consent_Management_Endpoint::class ); $this->schema_configuration = Mockery::mock( Schema_Configuration::class ); + $this->myyoast_connection_script_data = Mockery::mock( Integrations_Page_Script_Data::class ); $this->instance = new Integrations_Page( $this->admin_asset_manager, @@ -107,6 +116,7 @@ protected function set_up() { $this->site_kit_configuration, $this->site_kit_consent_management_endpoint, $this->schema_configuration, + $this->myyoast_connection_script_data, ); } @@ -210,6 +220,7 @@ public function test_enqueue_assets() { $this->site_kit_consent_management_endpoint->expects( 'get_url' ) ->andReturn( 'https://www.example.com/manage-consent' ); $this->schema_configuration->expects( 'is_schema_disabled_programmatically' )->andReturnFalse(); + $this->myyoast_connection_script_data->expects( 'present' )->andReturnNull(); $this->admin_asset_manager->expects( 'localize_script' )->with( 'integrations-page', @@ -243,6 +254,7 @@ public function test_enqueue_assets() { 'site_kit_configuration' => $site_kit_config, 'site_kit_consent_management_url' => 'https://www.example.com/manage-consent', 'schema_framework_enabled' => true, + 'myyoast_connection' => null, ], ); diff --git a/tests/Unit/MyYoast_Client/Application/Callback_Outcome_Test.php b/tests/Unit/MyYoast_Client/Application/Callback_Outcome_Test.php new file mode 100644 index 00000000000..7b9572f6bfa --- /dev/null +++ b/tests/Unit/MyYoast_Client/Application/Callback_Outcome_Test.php @@ -0,0 +1,133 @@ +assertTrue( $outcome->is_success() ); + $this->assertFalse( $outcome->is_no_op() ); + $this->assertFalse( $outcome->is_failure() ); + $this->assertSame( Callback_Outcome::PHASE_NONE, $outcome->get_error_phase() ); + $this->assertNull( $outcome->get_error_code() ); + } + + /** + * Tests the no-op outcome. + * + * @covers ::no_op + * @covers ::is_success + * @covers ::is_no_op + * @covers ::is_failure + * + * @return void + */ + public function test_no_op() { + $outcome = Callback_Outcome::no_op(); + + $this->assertFalse( $outcome->is_success() ); + $this->assertTrue( $outcome->is_no_op() ); + $this->assertFalse( $outcome->is_failure() ); + } + + /** + * Tests the provider-error outcome. + * + * @covers ::provider_error + * @covers ::is_failure + * @covers ::get_error_phase + * @covers ::get_error_code + * + * @return void + */ + public function test_provider_error() { + $outcome = Callback_Outcome::provider_error( 'access_denied' ); + + $this->assertTrue( $outcome->is_failure() ); + $this->assertFalse( $outcome->is_success() ); + $this->assertFalse( $outcome->is_no_op() ); + $this->assertSame( Callback_Outcome::PHASE_PROVIDER, $outcome->get_error_phase() ); + $this->assertSame( 'access_denied', $outcome->get_error_code() ); + } + + /** + * Tests the exchange-error outcome, including the code-less case. + * + * @covers ::exchange_error + * @covers ::is_failure + * @covers ::get_error_phase + * @covers ::get_error_code + * + * @return void + */ + public function test_exchange_error() { + $with_code = Callback_Outcome::exchange_error( 'invalid_grant' ); + $this->assertTrue( $with_code->is_failure() ); + $this->assertSame( Callback_Outcome::PHASE_EXCHANGE, $with_code->get_error_phase() ); + $this->assertSame( 'invalid_grant', $with_code->get_error_code() ); + + $without_code = Callback_Outcome::exchange_error( null ); + $this->assertTrue( $without_code->is_failure() ); + $this->assertSame( Callback_Outcome::PHASE_EXCHANGE, $without_code->get_error_phase() ); + $this->assertNull( $without_code->get_error_code() ); + } + + /** + * Tests an outcome survives a round-trip through array serialization. + * + * @covers ::to_array + * @covers ::from_array + * + * @dataProvider provide_round_trip_outcomes + * + * @param Callback_Outcome $outcome The outcome to round-trip. + * + * @return void + */ + public function test_array_round_trip( Callback_Outcome $outcome ) { + $restored = Callback_Outcome::from_array( $outcome->to_array() ); + + $this->assertSame( $outcome->is_success(), $restored->is_success() ); + $this->assertSame( $outcome->is_no_op(), $restored->is_no_op() ); + $this->assertSame( $outcome->is_failure(), $restored->is_failure() ); + $this->assertSame( $outcome->get_error_phase(), $restored->get_error_phase() ); + $this->assertSame( $outcome->get_error_code(), $restored->get_error_code() ); + } + + /** + * Provides outcomes for the array round-trip test. + * + * @return array + */ + public static function provide_round_trip_outcomes(): array { + return [ + 'success' => [ Callback_Outcome::success() ], + 'no_op' => [ Callback_Outcome::no_op() ], + 'provider error' => [ Callback_Outcome::provider_error( 'access_denied' ) ], + 'exchange error' => [ Callback_Outcome::exchange_error( 'invalid_grant' ) ], + 'code-less exchange' => [ Callback_Outcome::exchange_error( null ) ], + ]; + } +} diff --git a/tests/Unit/MyYoast_Client/Application/OAuth_Callback_Handler_Test.php b/tests/Unit/MyYoast_Client/Application/OAuth_Callback_Handler_Test.php new file mode 100644 index 00000000000..c33f06910e1 --- /dev/null +++ b/tests/Unit/MyYoast_Client/Application/OAuth_Callback_Handler_Test.php @@ -0,0 +1,293 @@ +myyoast_client = Mockery::mock( MyYoast_Client::class ); + $this->auth_code_handler = Mockery::mock( Authorization_Code_Handler::class ); + $this->expiring_store = Mockery::mock( Expiring_Store::class ); + + $this->instance = new OAuth_Callback_Handler( + $this->myyoast_client, + $this->auth_code_handler, + $this->expiring_store, + ); + } + + /** + * Tests a provider `access_denied` error discards the flow state and reports the native code. + * + * @covers ::__construct + * @covers ::handle + * + * @return void + */ + public function test_handle_provider_access_denied() { + $this->auth_code_handler->shouldReceive( 'discard_flow_state' )->once()->with( 7 ); + $this->myyoast_client->shouldNotReceive( 'exchange_authorization_code' ); + $this->expect_persist( 7 ); + + $outcome = $this->instance->handle( 7, '', '', 'access_denied' ); + + $this->assertTrue( $outcome->is_failure() ); + $this->assertSame( Callback_Outcome::PHASE_PROVIDER, $outcome->get_error_phase() ); + $this->assertSame( 'access_denied', $outcome->get_error_code() ); + } + + /** + * Tests another provider error still discards the flow state and passes the native code through. + * + * @covers ::handle + * + * @return void + */ + public function test_handle_provider_other_error() { + $this->auth_code_handler->shouldReceive( 'discard_flow_state' )->once()->with( 7 ); + $this->expect_persist( 7 ); + + $outcome = $this->instance->handle( 7, '', '', 'server_error' ); + + $this->assertTrue( $outcome->is_failure() ); + $this->assertSame( Callback_Outcome::PHASE_PROVIDER, $outcome->get_error_phase() ); + $this->assertSame( 'server_error', $outcome->get_error_code() ); + } + + /** + * Tests a missing code is treated as a no-op without exchanging or discarding. + * + * @covers ::handle + * + * @return void + */ + public function test_handle_missing_code_is_no_op() { + $this->auth_code_handler->shouldNotReceive( 'discard_flow_state' ); + $this->myyoast_client->shouldNotReceive( 'exchange_authorization_code' ); + $this->expiring_store->shouldNotReceive( 'persist_for_user' ); + + $outcome = $this->instance->handle( 42, '', 'xyz', '' ); + + $this->assertTrue( $outcome->is_no_op() ); + $this->assertFalse( $outcome->is_failure() ); + } + + /** + * Tests a missing state is treated as a no-op without exchanging or discarding. + * + * @covers ::handle + * + * @return void + */ + public function test_handle_missing_state_is_no_op() { + $this->auth_code_handler->shouldNotReceive( 'discard_flow_state' ); + $this->myyoast_client->shouldNotReceive( 'exchange_authorization_code' ); + $this->expiring_store->shouldNotReceive( 'persist_for_user' ); + + $outcome = $this->instance->handle( 42, 'abc', '', '' ); + + $this->assertTrue( $outcome->is_no_op() ); + } + + /** + * Tests a successful exchange reports success. + * + * @covers ::handle + * + * @return void + */ + public function test_handle_success() { + $this->myyoast_client->shouldReceive( 'exchange_authorization_code' ) + ->once() + ->with( 42, 'abc', 'xyz' ); + $this->expect_persist( 42 ); + + $outcome = $this->instance->handle( 42, 'abc', 'xyz', '' ); + + $this->assertTrue( $outcome->is_success() ); + $this->assertFalse( $outcome->is_failure() ); + $this->assertNull( $outcome->get_error_code() ); + } + + /** + * Tests an `invalid_grant` token failure passes the native code through as an exchange error. + * + * @covers ::handle + * + * @return void + */ + public function test_handle_invalid_grant_exchange_error() { + $this->myyoast_client->shouldReceive( 'exchange_authorization_code' ) + ->once() + ->andThrow( new Token_Request_Failed_Exception( 'invalid_grant', 'expired' ) ); + $this->expect_persist( 11 ); + + $outcome = $this->instance->handle( 11, 'abc', 'xyz', '' ); + + $this->assertTrue( $outcome->is_failure() ); + $this->assertSame( Callback_Outcome::PHASE_EXCHANGE, $outcome->get_error_phase() ); + $this->assertSame( 'invalid_grant', $outcome->get_error_code() ); + } + + /** + * Tests another token failure passes its native code through as an exchange error. + * + * @covers ::handle + * + * @return void + */ + public function test_handle_other_token_exchange_error() { + $this->myyoast_client->shouldReceive( 'exchange_authorization_code' ) + ->once() + ->andThrow( new Token_Request_Failed_Exception( 'invalid_request', 'state mismatch' ) ); + $this->expect_persist( 11 ); + + $outcome = $this->instance->handle( 11, 'abc', 'xyz', '' ); + + $this->assertTrue( $outcome->is_failure() ); + $this->assertSame( Callback_Outcome::PHASE_EXCHANGE, $outcome->get_error_phase() ); + $this->assertSame( 'invalid_request', $outcome->get_error_code() ); + } + + /** + * Tests an unexpected exception is logged and reported as a code-less exchange error. + * + * @covers ::handle + * + * @return void + */ + public function test_handle_unexpected_exception_is_logged() { + $this->myyoast_client->shouldReceive( 'exchange_authorization_code' ) + ->once() + ->andThrow( new Exception( 'boom' ) ); + + $logger = Mockery::mock( LoggerInterface::class ); + $logger->shouldReceive( 'error' )->once(); + $this->instance->setLogger( $logger ); + $this->expect_persist( 11 ); + + $outcome = $this->instance->handle( 11, 'abc', 'xyz', '' ); + + $this->assertTrue( $outcome->is_failure() ); + $this->assertSame( Callback_Outcome::PHASE_EXCHANGE, $outcome->get_error_phase() ); + $this->assertNull( $outcome->get_error_code() ); + } + + /** + * Tests consuming the outcome reads, deletes, and reconstructs it. + * + * @covers ::consume_outcome + * + * @return void + */ + public function test_consume_outcome_reads_and_deletes() { + $stored = Callback_Outcome::provider_error( 'access_denied' )->to_array(); + + $this->expiring_store->shouldReceive( 'get_for_user' )->once()->with( self::OUTCOME_KEY, 7 )->andReturn( $stored ); + $this->expiring_store->shouldReceive( 'delete_for_user' )->once()->with( self::OUTCOME_KEY, 7 ); + + $outcome = $this->instance->consume_outcome( 7 ); + + $this->assertInstanceOf( Callback_Outcome::class, $outcome ); + $this->assertTrue( $outcome->is_failure() ); + $this->assertSame( Callback_Outcome::PHASE_PROVIDER, $outcome->get_error_phase() ); + $this->assertSame( 'access_denied', $outcome->get_error_code() ); + } + + /** + * Tests consuming returns null and does not delete when nothing is stored. + * + * @covers ::consume_outcome + * + * @return void + */ + public function test_consume_outcome_returns_null_when_absent() { + $this->expiring_store->shouldReceive( 'get_for_user' ) + ->once() + ->with( self::OUTCOME_KEY, 7 ) + ->andThrow( new Key_Not_Found_Exception() ); + $this->expiring_store->shouldNotReceive( 'delete_for_user' ); + + $this->assertNull( $this->instance->consume_outcome( 7 ) ); + } + + /** + * Tests consuming is skipped for an invalid user id. + * + * @covers ::consume_outcome + * + * @return void + */ + public function test_consume_outcome_skips_invalid_user() { + $this->expiring_store->shouldNotReceive( 'get_for_user' ); + + $this->assertNull( $this->instance->consume_outcome( 0 ) ); + } + + /** + * Configures the expectation that the outcome is persisted once for a user. + * + * @param int $user_id The user id the outcome should be stored for. + * + * @return void + */ + private function expect_persist( int $user_id ): void { + $this->expiring_store->shouldReceive( 'persist_for_user' ) + ->once() + ->with( self::OUTCOME_KEY, Mockery::type( 'array' ), \MINUTE_IN_SECONDS, $user_id ); + } +} diff --git a/tests/Unit/MyYoast_Client/Application/Rate_Limited_Exception_Test.php b/tests/Unit/MyYoast_Client/Application/Rate_Limited_Exception_Test.php new file mode 100644 index 00000000000..5baefec7669 --- /dev/null +++ b/tests/Unit/MyYoast_Client/Application/Rate_Limited_Exception_Test.php @@ -0,0 +1,137 @@ +assertInstanceOf( Registration_Failed_Exception::class, $exception ); + $this->assertSame( 'Slow down.', $exception->getMessage() ); + } + + /** + * Tests that the retry-after value defaults to null when not provided. + * + * @covers ::get_retry_after_seconds + * + * @return void + */ + public function test_retry_after_seconds_defaults_to_null() { + $exception = new Rate_Limited_Exception( 'Slow down.' ); + + $this->assertNull( $exception->get_retry_after_seconds() ); + } + + /** + * Tests that the retry-after value is exposed when provided. + * + * @covers ::__construct + * @covers ::get_retry_after_seconds + * + * @return void + */ + public function test_retry_after_seconds_is_exposed() { + $exception = new Rate_Limited_Exception( 'Slow down.', 120 ); + + $this->assertSame( 120, $exception->get_retry_after_seconds() ); + } + + /** + * Tests parse_retry_after on the delta-seconds form. + * + * @covers ::parse_retry_after + * + * @return void + */ + public function test_parse_retry_after_seconds_form() { + $this->assertSame( 120, Rate_Limited_Exception::parse_retry_after( '120' ) ); + $this->assertSame( 30, Rate_Limited_Exception::parse_retry_after( 30 ) ); + $this->assertSame( 0, Rate_Limited_Exception::parse_retry_after( '0' ) ); + } + + /** + * Tests that a negative seconds value is clamped to zero. + * + * @covers ::parse_retry_after + * + * @return void + */ + public function test_parse_retry_after_clamps_negative_to_zero() { + $this->assertSame( 0, Rate_Limited_Exception::parse_retry_after( '-5' ) ); + } + + /** + * Tests parse_retry_after on the HTTP-date form. + * + * @covers ::parse_retry_after + * + * @return void + */ + public function test_parse_retry_after_http_date_form() { + $future_seconds = 300; + $date = \gmdate( 'D, d M Y H:i:s', ( \time() + $future_seconds ) ) . ' GMT'; + + $parsed = Rate_Limited_Exception::parse_retry_after( $date ); + + // Allow a small jitter since `time()` is called twice (once here, once in the parser). + $this->assertNotNull( $parsed ); + $this->assertGreaterThanOrEqual( ( $future_seconds - 5 ), $parsed ); + $this->assertLessThanOrEqual( ( $future_seconds + 5 ), $parsed ); + } + + /** + * Tests that a past HTTP-date is clamped to zero rather than going negative. + * + * @covers ::parse_retry_after + * + * @return void + */ + public function test_parse_retry_after_past_date_clamps_to_zero() { + $past_date = \gmdate( 'D, d M Y H:i:s', ( \time() - 3600 ) ) . ' GMT'; + + $this->assertSame( 0, Rate_Limited_Exception::parse_retry_after( $past_date ) ); + } + + /** + * Tests that null / empty / garbage Retry-After values return null. + * + * @covers ::parse_retry_after + * + * @return void + */ + public function test_parse_retry_after_returns_null_when_unparseable() { + $this->assertNull( Rate_Limited_Exception::parse_retry_after( null ) ); + $this->assertNull( Rate_Limited_Exception::parse_retry_after( '' ) ); + $this->assertNull( Rate_Limited_Exception::parse_retry_after( 'not a date' ) ); + } + + /** + * Tests that a list-valued header (e.g. from wp_remote_*) is unwrapped. + * + * @covers ::parse_retry_after + * + * @return void + */ + public function test_parse_retry_after_unwraps_list_header() { + $this->assertSame( 60, Rate_Limited_Exception::parse_retry_after( [ '60', '120' ] ) ); + } +} diff --git a/tests/Unit/MyYoast_Client/Application/Registration_Not_Found_Exception_Test.php b/tests/Unit/MyYoast_Client/Application/Registration_Not_Found_Exception_Test.php new file mode 100644 index 00000000000..2743cdf0f83 --- /dev/null +++ b/tests/Unit/MyYoast_Client/Application/Registration_Not_Found_Exception_Test.php @@ -0,0 +1,30 @@ +assertInstanceOf( Registration_Failed_Exception::class, $exception ); + $this->assertSame( 'Gone.', $exception->getMessage() ); + } +} diff --git a/tests/Unit/MyYoast_Client/Infrastructure/Registration/Client_Registration_Test.php b/tests/Unit/MyYoast_Client/Infrastructure/Registration/Client_Registration_Test.php index 294b00aa8b5..8031711712e 100644 --- a/tests/Unit/MyYoast_Client/Infrastructure/Registration/Client_Registration_Test.php +++ b/tests/Unit/MyYoast_Client/Infrastructure/Registration/Client_Registration_Test.php @@ -7,7 +7,9 @@ use Mockery; use Yoast\WP\SEO\Exceptions\Locking\Lock_Timeout_Exception; use Yoast\WP\SEO\Helpers\Lock_Helper; +use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Rate_Limited_Exception; use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Registration_Failed_Exception; +use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Registration_Not_Found_Exception; use Yoast\WP\SEO\MyYoast_Client\Domain\HTTP_Response; use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Encryption; use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Key_Pair; @@ -759,6 +761,82 @@ public function test_mark_uri_validated_is_noop_when_not_registered() { $this->instance->mark_uri_validated( 'https://example.com/callback' ); } + /** + * Tests that a 401 from the RFC 7592 read clears local state and throws Registration_Not_Found_Exception. + * + * @covers ::read_registration + * + * @return void + */ + public function test_read_registration_throws_not_found_on_401() { + $this->mock_get_client(); + + $this->http_client + ->expects( 'authenticated_request' ) + ->andReturn( new HTTP_Response( 401, [], [ 'error' => 'invalid_token' ] ) ); + + Functions\expect( 'delete_option' )->once()->with( self::OPTION_KEY )->andReturn( true ); + + $this->expectException( Registration_Not_Found_Exception::class ); + $this->instance->read_registration(); + } + + /** + * Tests that a 404 from the RFC 7592 read clears local state and throws Registration_Not_Found_Exception. + * + * @covers ::read_registration + * + * @return void + */ + public function test_read_registration_throws_not_found_on_404() { + $this->mock_get_client(); + + $this->http_client + ->expects( 'authenticated_request' ) + ->andReturn( new HTTP_Response( 404, [], [] ) ); + + Functions\expect( 'delete_option' )->once()->with( self::OPTION_KEY )->andReturn( true ); + + $this->expectException( Registration_Not_Found_Exception::class ); + $this->instance->read_registration(); + } + + /** + * Tests that a 429 from the RFC 7592 read throws Rate_Limited_Exception carrying the Retry-After. + * + * @covers ::read_registration + * @covers ::get_retry_after_seconds + * + * @return void + */ + public function test_read_registration_throws_rate_limited_on_429() { + $this->mock_get_client(); + + $this->http_client + ->expects( 'authenticated_request' ) + ->andReturn( new HTTP_Response( 429, [ 'retry-after' => '120' ], [] ) ); + + try { + $this->instance->read_registration(); + $this->fail( 'Expected Rate_Limited_Exception was not thrown.' ); + } catch ( Rate_Limited_Exception $e ) { + $this->assertSame( 120, $e->get_retry_after_seconds() ); + } + } + + /** + * Tests that the typed registration exceptions are still caught as Registration_Failed_Exception. + * + * @covers \Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Rate_Limited_Exception + * @covers \Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Registration_Not_Found_Exception + * + * @return void + */ + public function test_typed_exceptions_extend_registration_failed_exception() { + $this->assertInstanceOf( Registration_Failed_Exception::class, new Rate_Limited_Exception( 'rate limited' ) ); + $this->assertInstanceOf( Registration_Failed_Exception::class, new Registration_Not_Found_Exception( 'gone' ) ); + } + /** * Sets up mocks for get_client() to return a valid Registered_Client. * diff --git a/tests/Unit/MyYoast_Client/Infrastructure/WordPress/Redirect_URI_Provider_Test.php b/tests/Unit/MyYoast_Client/Infrastructure/WordPress/Redirect_URI_Provider_Test.php index fa8c4b7d9b1..41f3573a47b 100644 --- a/tests/Unit/MyYoast_Client/Infrastructure/WordPress/Redirect_URI_Provider_Test.php +++ b/tests/Unit/MyYoast_Client/Infrastructure/WordPress/Redirect_URI_Provider_Test.php @@ -21,7 +21,7 @@ final class Redirect_URI_Provider_Test extends TestCase { * * @var string */ - private const CANONICAL = 'https://example.com/wp-admin/admin.php?page=wpseo_dashboard&yoast_myyoast_oauth_callback=1'; + private const CANONICAL = 'https://example.com/wp-admin/admin-post.php?action=yoast_myyoast_oauth_callback'; /** * The test instance. diff --git a/tests/Unit/MyYoast_Client/User_Interface/Integrations_Page_Script_Data_Test.php b/tests/Unit/MyYoast_Client/User_Interface/Integrations_Page_Script_Data_Test.php new file mode 100644 index 00000000000..e637c42f65a --- /dev/null +++ b/tests/Unit/MyYoast_Client/User_Interface/Integrations_Page_Script_Data_Test.php @@ -0,0 +1,244 @@ +status_presenter = Mockery::mock( Status_Presenter::class ); + $this->myyoast_connection_conditional = Mockery::mock( MyYoast_Connection_Conditional::class ); + $this->callback_handler = Mockery::mock( OAuth_Callback_Handler::class ); + $this->short_link_helper = Mockery::mock( Short_Link_Helper::class ); + $this->instance = new Integrations_Page_Script_Data( + $this->status_presenter, + $this->myyoast_connection_conditional, + $this->callback_handler, + $this->short_link_helper, + ); + } + + /** + * Tests the payload is returned when the feature flag is enabled and no + * callback outcome is pending. + * + * @covers ::__construct + * @covers ::present + * @covers ::consume_callback_outcome + * + * @return void + */ + public function test_present_when_enabled() { + $status = [ + 'is_provisioned' => true, + 'is_registered' => false, + 'registered_at' => null, + 'registered_at_iso' => null, + 'redirect_uris' => [], + ]; + + $this->myyoast_connection_conditional->shouldReceive( 'is_met' )->andReturn( true ); + $this->status_presenter->shouldReceive( 'present' )->andReturn( $status ); + + Monkey\Functions\expect( 'get_current_user_id' )->andReturn( 42 ); + $this->callback_handler->shouldReceive( 'consume_outcome' )->once()->with( 42 )->andReturn( null ); + $this->short_link_helper->shouldReceive( 'get_query_params' )->once()->andReturn( [ 'php_version' => '8.2' ] ); + + $result = $this->instance->present(); + + $this->assertIsArray( $result ); + $this->assertSame( $status, $result['initialStatus'] ); + $this->assertNull( $result['callbackOutcome'] ); + $this->assertSame( [ 'php_version' => '8.2' ], $result['linkParams'] ); + } + + /** + * Tests a successful outcome is shaped into the verify_success notification. + * + * @covers ::present + * @covers ::consume_callback_outcome + * + * @return void + */ + public function test_present_consumes_success_outcome() { + $status = [ + 'is_provisioned' => true, + 'is_registered' => true, + 'registered_at' => 1_731_369_600, + 'registered_at_iso' => '2024-11-12T00:00:00+00:00', + 'redirect_uris' => [], + ]; + + $this->myyoast_connection_conditional->shouldReceive( 'is_met' )->andReturn( true ); + $this->status_presenter->shouldReceive( 'present' )->andReturn( $status ); + + Monkey\Functions\expect( 'get_current_user_id' )->andReturn( 7 ); + $this->callback_handler->shouldReceive( 'consume_outcome' )->once()->with( 7 )->andReturn( Callback_Outcome::success() ); + $this->short_link_helper->shouldReceive( 'get_query_params' )->andReturn( [] ); + + $result = $this->instance->present(); + + $this->assertIsArray( $result ); + $this->assertSame( + [ + 'kind' => 'success', + 'key' => 'verify_success', + ], + $result['callbackOutcome'], + ); + } + + /** + * Tests an error outcome is translated to its front-end message key. + * + * @covers ::present + * @covers ::consume_callback_outcome + * @covers ::error_message_key + * + * @dataProvider provide_error_outcomes + * + * @param Callback_Outcome $outcome The stored outcome. + * @param string $expected_key The expected front-end message key. + * + * @return void + */ + public function test_present_translates_error_outcome( Callback_Outcome $outcome, string $expected_key ) { + $status = [ + 'is_provisioned' => true, + 'is_registered' => false, + 'registered_at' => null, + 'registered_at_iso' => null, + 'redirect_uris' => [], + ]; + + $this->myyoast_connection_conditional->shouldReceive( 'is_met' )->andReturn( true ); + $this->status_presenter->shouldReceive( 'present' )->andReturn( $status ); + + Monkey\Functions\expect( 'get_current_user_id' )->andReturn( 7 ); + $this->callback_handler->shouldReceive( 'consume_outcome' )->once()->with( 7 )->andReturn( $outcome ); + $this->short_link_helper->shouldReceive( 'get_query_params' )->andReturn( [] ); + + $result = $this->instance->present(); + + $this->assertSame( + [ + 'kind' => 'error', + 'key' => $expected_key, + ], + $result['callbackOutcome'], + ); + } + + /** + * Provides error outcomes and the message keys they map to. + * + * @return array + */ + public static function provide_error_outcomes(): array { + return [ + 'provider access_denied -> cancelled' => [ Callback_Outcome::provider_error( 'access_denied' ), 'connection_cancelled' ], + 'other provider error -> unexpected' => [ Callback_Outcome::provider_error( 'server_error' ), 'unexpected_error' ], + 'invalid_grant -> dedicated key' => [ Callback_Outcome::exchange_error( 'invalid_grant' ), 'token_request_failed_invalid_grant' ], + 'other exchange error -> generic key' => [ Callback_Outcome::exchange_error( 'invalid_request' ), 'token_request_failed' ], + 'code-less exchange error -> unexpected' => [ Callback_Outcome::exchange_error( null ), 'unexpected_error' ], + ]; + } + + /** + * Tests the store still drives consumption with the resolved (zero) user id when no user is logged in. + * + * @covers ::consume_callback_outcome + * + * @return void + */ + public function test_present_without_user() { + $status = [ + 'is_provisioned' => true, + 'is_registered' => false, + 'registered_at' => null, + 'registered_at_iso' => null, + 'redirect_uris' => [], + ]; + + $this->myyoast_connection_conditional->shouldReceive( 'is_met' )->andReturn( true ); + $this->status_presenter->shouldReceive( 'present' )->andReturn( $status ); + + Monkey\Functions\expect( 'get_current_user_id' )->andReturn( 0 ); + $this->callback_handler->shouldReceive( 'consume_outcome' )->once()->with( 0 )->andReturn( null ); + $this->short_link_helper->shouldReceive( 'get_query_params' )->andReturn( [] ); + + $result = $this->instance->present(); + + $this->assertNull( $result['callbackOutcome'] ); + } + + /** + * Tests `null` is returned when the feature flag is disabled. + * + * @covers ::present + * + * @return void + */ + public function test_present_when_disabled() { + $this->myyoast_connection_conditional->shouldReceive( 'is_met' )->andReturn( false ); + $this->status_presenter->shouldNotReceive( 'present' ); + + $this->assertNull( $this->instance->present() ); + } +} diff --git a/tests/Unit/MyYoast_Client/User_Interface/Management_Route_Test.php b/tests/Unit/MyYoast_Client/User_Interface/Management_Route_Test.php new file mode 100644 index 00000000000..e8a7c3fc4ac --- /dev/null +++ b/tests/Unit/MyYoast_Client/User_Interface/Management_Route_Test.php @@ -0,0 +1,812 @@ +myyoast_client = Mockery::mock( MyYoast_Client::class ); + $this->status_presenter = Mockery::mock( Status_Presenter::class ); + $this->issuer_config = Mockery::mock( Issuer_Config::class ); + $this->client_registration = Mockery::mock( Client_Registration_Interface::class ); + + $this->instance = new Management_Route( + $this->myyoast_client, + $this->status_presenter, + $this->issuer_config, + $this->client_registration, + ); + + // Default throttle stubs: the issuer key is always available and the marker + // is absent (cache miss). Writes/deletes are asserted per-test with + // Functions\expect(); a Functions\when() default here would shadow those + // expectations, so it is intentionally omitted. + $this->issuer_config->shouldReceive( 'get_issuer_key' )->andReturn( 'abcd1234' )->byDefault(); + Monkey\Functions\when( 'get_transient' )->justReturn( false ); + } + + /** + * Marks the config as provisioned (default in most tests). + * + * @return void + */ + private function provision(): void { + $this->issuer_config->shouldReceive( 'get_software_statement' )->andReturn( 'jwt' ); + $this->issuer_config->shouldReceive( 'get_initial_access_token' )->andReturn( 'iat' ); + } + + /** + * Marks the config as unprovisioned. + * + * @return void + */ + private function unprovision(): void { + $this->issuer_config->shouldReceive( 'get_software_statement' )->andReturn( '' ); + $this->issuer_config->shouldReceive( 'get_initial_access_token' )->andReturn( '' ); + } + + /** + * Builds a REST request mock whose `return_url` param returns the given value. + * + * @param string|null $return_url The value `get_param( 'return_url' )` should return. + * + * @return WP_REST_Request|Mockery\MockInterface + */ + private function request_with_return_url( ?string $return_url ) { + $request = Mockery::mock( WP_REST_Request::class ); + $request->shouldReceive( 'get_param' )->with( 'return_url' )->andReturn( $return_url ); + + return $request; + } + + /** + * Returns the default status payload used by mocked presenter calls. + * + * @return array + */ + private function status_payload(): array { + return [ + 'is_provisioned' => true, + 'is_registered' => true, + 'registered_at' => 1_731_369_600, + 'registered_at_iso' => '2025-11-12T00:00:00+00:00', + 'redirect_uris' => [ + [ + 'uri' => 'https://example.com/wp-admin/admin.php?page=wpseo_dashboard&yoast_myyoast_oauth_callback=1', + 'origin' => 'https://example.com', + 'is_verified' => false, + ], + ], + 'redirect_uris_match' => true, + ]; + } + + /** + * Tests the conditionals. + * + * @covers ::get_conditionals + * + * @return void + */ + public function test_get_conditionals() { + $this->assertSame( + [ MyYoast_Connection_Conditional::class ], + Management_Route::get_conditionals(), + ); + } + + /** + * Tests that all routes are registered. Five register_rest_route calls + * are made: /status (GET), /refresh-status (POST), /register (POST), + * /registration (PUT + DELETE on the same path), and /authorize (POST). + * + * @covers ::register_routes + * + * @return void + */ + public function test_register_routes() { + Monkey\Functions\expect( 'register_rest_route' )->times( 5 ); + + $this->instance->register_routes(); + } + + /** + * Tests that the permission callback checks `wpseo_manage_options`. + * + * @covers ::can_manage + * + * @return void + */ + public function test_can_manage() { + Monkey\Functions\expect( 'current_user_can' ) + ->with( 'wpseo_manage_options' ) + ->once() + ->andReturn( true ); + + $this->assertTrue( $this->instance->can_manage() ); + } + + /** + * Tests GET /myyoast/status. + * + * @covers ::get_status + * @covers ::respond_with_connection_status + * + * @return void + */ + public function test_get_status_returns_payload() { + $this->status_presenter->shouldReceive( 'present' )->andReturn( $this->status_payload() ); + + Mockery::mock( 'overload:' . WP_REST_Response::class ); + + $response = $this->instance->get_status(); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + } + + /** + * Tests that refresh_status dispatches to the facade, sets the throttle + * marker, and returns a success payload. + * + * @covers ::refresh_status + * @covers ::get_refresh_throttle_key + * + * @return void + */ + public function test_refresh_status_success() { + $this->provision(); + $this->myyoast_client->shouldReceive( 'refresh_registration_status' )->once()->andReturn( [] ); + $this->status_presenter->shouldReceive( 'present' )->andReturn( $this->status_payload() ); + + Monkey\Functions\expect( 'set_transient' ) + ->once() + ->with( 'wpseo_myyoast_refresh_throttle_abcd1234', 1, \HOUR_IN_SECONDS ); + + Mockery::mock( 'overload:' . WP_REST_Response::class ); + + $response = $this->instance->refresh_status(); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + } + + /** + * Tests that refresh_status skips the upstream call when the throttle marker + * is present, returning the local status without touching the facade. + * + * @covers ::refresh_status + * @covers ::get_refresh_throttle_key + * + * @return void + */ + public function test_refresh_status_skips_when_throttled() { + $this->provision(); + Monkey\Functions\when( 'get_transient' )->justReturn( 1 ); + $this->status_presenter->shouldReceive( 'present' )->andReturn( $this->status_payload() ); + + $this->myyoast_client->shouldNotReceive( 'refresh_registration_status' ); + Monkey\Functions\expect( 'set_transient' )->never(); + + Mockery::mock( 'overload:' . WP_REST_Response::class ); + + $response = $this->instance->refresh_status(); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + } + + /** + * Tests that a failed upstream refresh does not set the throttle marker, so + * the next load retries. + * + * @covers ::refresh_status + * + * @return void + */ + public function test_refresh_status_does_not_throttle_on_failure() { + $this->provision(); + $this->myyoast_client->shouldReceive( 'refresh_registration_status' ) + ->once() + ->andThrow( new Rate_Limited_Exception( 'slow down' ) ); + $this->status_presenter->shouldReceive( 'present' )->andReturn( $this->status_payload() ); + + Monkey\Functions\expect( 'set_transient' )->never(); + + Mockery::mock( 'overload:' . WP_REST_Response::class ); + + $response = $this->instance->refresh_status(); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + } + + /** + * Tests that refresh_status hitting Registration_Not_Found_Exception surfaces registration_gone. + * + * @covers ::refresh_status + * @covers ::handle_exception + * + * @return void + */ + public function test_refresh_status_with_registration_gone() { + $this->provision(); + $this->myyoast_client->shouldReceive( 'refresh_registration_status' ) + ->once() + ->andThrow( new Registration_Not_Found_Exception( 'gone' ) ); + $this->status_presenter->shouldReceive( 'present' )->andReturn( $this->status_payload() ); + + Mockery::mock( 'overload:' . WP_REST_Response::class ); + + $response = $this->instance->refresh_status(); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + } + + /** + * Tests that a Rate_Limited_Exception carrying a Retry-After value + * surfaces it on the response body under `details.retry_after_seconds`. + * + * @covers ::handle_exception + * @covers ::error_response + * + * @return void + */ + public function test_rate_limited_response_includes_retry_after_details() { + $this->provision(); + $this->myyoast_client->shouldReceive( 'refresh_registration_status' ) + ->once() + ->andThrow( new Rate_Limited_Exception( 'slow down', 240 ) ); + $this->status_presenter->shouldReceive( 'present' )->andReturn( $this->status_payload() ); + + $captured = null; + $mock = Mockery::mock( 'overload:' . WP_REST_Response::class ); + $mock->shouldReceive( '__construct' )->andReturnUsing( + static function ( $body ) use ( &$captured ): void { + $captured = $body; + }, + ); + + $this->instance->refresh_status(); + + $this->assertIsArray( $captured ); + $this->assertSame( 'rate_limited', $captured['error_code'] ); + $this->assertArrayHasKey( 'details', $captured ); + $this->assertSame( [ 'retry_after_seconds' => 240 ], $captured['details'] ); + } + + /** + * Tests that a Rate_Limited_Exception without a Retry-After value omits + * the `details` field entirely. + * + * @covers ::handle_exception + * @covers ::error_response + * + * @return void + */ + public function test_rate_limited_response_omits_details_when_retry_after_missing() { + $this->provision(); + $this->myyoast_client->shouldReceive( 'refresh_registration_status' ) + ->once() + ->andThrow( new Rate_Limited_Exception( 'slow down' ) ); + $this->status_presenter->shouldReceive( 'present' )->andReturn( $this->status_payload() ); + + $captured = null; + $mock = Mockery::mock( 'overload:' . WP_REST_Response::class ); + $mock->shouldReceive( '__construct' )->andReturnUsing( + static function ( $body ) use ( &$captured ): void { + $captured = $body; + }, + ); + + $this->instance->refresh_status(); + + $this->assertIsArray( $captured ); + $this->assertSame( 'rate_limited', $captured['error_code'] ); + $this->assertArrayNotHasKey( 'details', $captured ); + } + + /** + * Tests that handle_exception maps each domain exception to a response (smoke + * test — exception-mapping coverage is per-branch but groups into one test). + * + * Driven through update_registration, which catches the full Throwable family; + * refresh_status only catches Registration_Failed_Exception (its declared + * throw type), so it cannot reach the sibling branches exercised here. + * + * @covers ::handle_exception + * + * @return void + */ + public function test_handle_exception_branches() { + $this->provision(); + $this->status_presenter->shouldReceive( 'present' )->andReturn( $this->status_payload() ); + Monkey\Functions\expect( 'delete_transient' )->never(); + + Mockery::mock( 'overload:' . WP_REST_Response::class ); + + $exceptions = [ + new Rate_Limited_Exception( 'rate' ), + new Server_Capability_Exception( 'cap' ), + new Discovery_Failed_Exception( 'discovery' ), + new Token_Request_Failed_Exception( 'invalid_grant', 'bad', 400 ), + new Token_Request_Failed_Exception( 'invalid_client', 'bad', 400 ), + new Token_Storage_Exception( 'storage' ), + new Registration_Failed_Exception( 'other' ), + new Exception( 'boom' ), + ]; + + foreach ( $exceptions as $exception ) { + $this->myyoast_client->shouldReceive( 'ensure_registered' ) + ->once() + ->andThrow( $exception ); + + $response = $this->instance->update_registration(); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + } + } + + /** + * Tests POST /myyoast/register delegates to ensure_registered, which resolves the + * redirect URIs itself. + * + * @covers ::register + * + * @return void + */ + public function test_register_delegates_to_ensure_registered() { + $this->provision(); + + $this->myyoast_client->shouldNotReceive( 'deregister' ); + $this->myyoast_client->shouldReceive( 'ensure_registered' )->once()->withNoArgs(); + $this->status_presenter->shouldReceive( 'present' )->andReturn( $this->status_payload() ); + + Monkey\Functions\expect( 'delete_transient' ) + ->once() + ->with( 'wpseo_myyoast_refresh_throttle_abcd1234' ); + + Mockery::mock( 'overload:' . WP_REST_Response::class ); + + $response = $this->instance->register(); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + } + + /** + * Tests PUT /myyoast/registration re-syncs the registration via ensure_registered. + * + * @covers ::update_registration + * + * @return void + */ + public function test_update_registration() { + $this->provision(); + + $this->myyoast_client->shouldReceive( 'ensure_registered' )->once()->withNoArgs(); + $this->status_presenter->shouldReceive( 'present' )->andReturn( $this->status_payload() ); + + Monkey\Functions\expect( 'delete_transient' ) + ->once() + ->with( 'wpseo_myyoast_refresh_throttle_abcd1234' ); + + Mockery::mock( 'overload:' . WP_REST_Response::class ); + + $response = $this->instance->update_registration(); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + } + + /** + * Tests update_registration surfaces Registration_Not_Found as a normal error response. + * + * @covers ::update_registration + * @covers ::handle_exception + * + * @return void + */ + public function test_update_registration_handles_registration_gone() { + $this->provision(); + + $this->myyoast_client->shouldReceive( 'ensure_registered' ) + ->once() + ->andThrow( new Registration_Not_Found_Exception( 'gone' ) ); + $this->status_presenter->shouldReceive( 'present' )->andReturn( $this->status_payload() ); + + Mockery::mock( 'overload:' . WP_REST_Response::class ); + + $response = $this->instance->update_registration(); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + } + + /** + * Tests deregister dispatches to the facade and also clears all site tokens. + * + * @covers ::deregister + * + * @return void + */ + public function test_deregister_success() { + $this->provision(); + + $this->myyoast_client->shouldReceive( 'deregister' )->once()->andReturn( true ); + $this->myyoast_client->shouldReceive( 'clear_all_site_tokens' )->once(); + $this->status_presenter->shouldReceive( 'present' )->andReturn( $this->status_payload() ); + + Monkey\Functions\expect( 'delete_transient' ) + ->once() + ->with( 'wpseo_myyoast_refresh_throttle_abcd1234' ); + + Mockery::mock( 'overload:' . WP_REST_Response::class ); + + $response = $this->instance->deregister(); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + } + + /** + * Tests deregister still clears site tokens and reports success when the + * server-side teardown could not be confirmed (transport failure). + * + * @covers ::deregister + * + * @return void + */ + public function test_deregister_succeeds_locally_when_remote_unconfirmed() { + $this->provision(); + + $this->myyoast_client->shouldReceive( 'deregister' )->once()->andReturn( false ); + $this->myyoast_client->shouldReceive( 'clear_all_site_tokens' )->once(); + $this->status_presenter->shouldReceive( 'present' )->andReturn( $this->status_payload() ); + Monkey\Functions\expect( 'delete_transient' )->zeroOrMoreTimes(); + + Mockery::mock( 'overload:' . WP_REST_Response::class ); + + $response = $this->instance->deregister(); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + } + + /** + * Tests deregister still clears site tokens when the remote call throws + * unexpectedly, so the site is never left half-connected. + * + * @covers ::deregister + * + * @return void + */ + public function test_deregister_clears_tokens_when_remote_throws() { + $this->provision(); + + $this->myyoast_client->shouldReceive( 'deregister' )->once()->andThrow( new Exception( 'boom' ) ); + $this->myyoast_client->shouldReceive( 'clear_all_site_tokens' )->once(); + $this->status_presenter->shouldReceive( 'present' )->andReturn( $this->status_payload() ); + Monkey\Functions\expect( 'delete_transient' )->zeroOrMoreTimes(); + + Mockery::mock( 'overload:' . WP_REST_Response::class ); + + $response = $this->instance->deregister(); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + } + + /** + * Tests the registration-mutating endpoints short-circuit when the plugin is + * not provisioned. + * + * The register/update endpoints gate on provisioning; authorize gates on + * having a registered client (a stricter precondition that also implies + * provisioning), so with no client stubbed it short-circuits before touching + * the facade too. The refresh-status and deregister endpoints work without + * provisioning and are covered by their own tests. + * + * @covers ::require_provisioned + * + * @return void + */ + public function test_registration_actions_blocked_when_not_provisioned() { + $this->unprovision(); + $this->client_registration->shouldReceive( 'get_registered_client' )->andReturn( null ); + $this->status_presenter->shouldReceive( 'present' )->andReturn( $this->status_payload() ); + + $this->myyoast_client->shouldNotReceive( 'ensure_registered' ); + $this->myyoast_client->shouldNotReceive( 'get_authorization_url' ); + + Mockery::mock( 'overload:' . WP_REST_Response::class ); + + $this->assertInstanceOf( WP_REST_Response::class, $this->instance->register() ); + $this->assertInstanceOf( WP_REST_Response::class, $this->instance->update_registration() ); + $this->assertInstanceOf( WP_REST_Response::class, $this->instance->authorize( $this->request_with_return_url( null ) ) ); + } + + /** + * Tests refresh_status works regardless of provisioning — it talks to MyYoast with + * the stored registration access token, not the software statement. + * + * @covers ::refresh_status + * + * @return void + */ + public function test_refresh_status_works_when_not_provisioned() { + $this->unprovision(); + $this->status_presenter->shouldReceive( 'present' )->andReturn( $this->status_payload() ); + $this->myyoast_client->shouldReceive( 'refresh_registration_status' )->once(); + Monkey\Functions\expect( 'set_transient' )->zeroOrMoreTimes(); + + Mockery::mock( 'overload:' . WP_REST_Response::class ); + + $this->assertInstanceOf( WP_REST_Response::class, $this->instance->refresh_status() ); + } + + /** + * Tests deregister works regardless of provisioning — disconnecting only + * needs the stored registration, not the software statement. + * + * @covers ::deregister + * + * @return void + */ + public function test_deregister_works_when_not_provisioned() { + $this->unprovision(); + $this->status_presenter->shouldReceive( 'present' )->andReturn( $this->status_payload() ); + $this->myyoast_client->shouldReceive( 'deregister' )->once()->andReturn( true ); + $this->myyoast_client->shouldReceive( 'clear_all_site_tokens' )->once(); + Monkey\Functions\expect( 'delete_transient' )->zeroOrMoreTimes(); + + Mockery::mock( 'overload:' . WP_REST_Response::class ); + + $this->assertInstanceOf( WP_REST_Response::class, $this->instance->deregister() ); + } + + /** + * Tests authorize passes a valid same-host return URL through to the facade. + * + * @covers ::authorize + * @covers ::resolve_return_url + * + * @return void + */ + public function test_authorize_passes_valid_return_url() { + $this->provision(); + + $return_url = 'https://example.com/wp-admin/admin.php?page=wpseo_integrations'; + $authorize_url = 'https://my.yoast.com/auth?code_challenge=abc'; + + $this->client_registration->shouldReceive( 'get_registered_client' ) + ->andReturn( + new Registered_Client( + 'client-123', + 'rat', + 'https://my.yoast.com/clients/client-123', + [ 'redirect_uris' => [ 'https://example.com/cb' ] ], + ), + ); + + Monkey\Functions\expect( 'get_current_user_id' )->andReturn( 42 ); + + Monkey\Functions\expect( 'wp_validate_redirect' ) + ->once() + ->with( $return_url, '' ) + ->andReturn( $return_url ); + + $this->myyoast_client->shouldReceive( 'get_authorization_url' ) + ->once() + ->with( 42, [ 'openid' ], null, $return_url ) + ->andReturn( $authorize_url ); + + $this->status_presenter->shouldReceive( 'present' )->andReturn( $this->status_payload() ); + + Mockery::mock( 'overload:' . WP_REST_Response::class ); + + $response = $this->instance->authorize( $this->request_with_return_url( $return_url ) ); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + } + + /** + * Tests authorize passes null to the facade when no return URL is supplied, + * so the callback later surfaces a standalone outcome instead of redirecting. + * + * @covers ::authorize + * @covers ::resolve_return_url + * + * @return void + */ + public function test_authorize_passes_null_when_return_url_absent() { + $this->provision(); + + $this->client_registration->shouldReceive( 'get_registered_client' ) + ->andReturn( + new Registered_Client( + 'client-123', + 'rat', + 'https://my.yoast.com/clients/client-123', + [ 'redirect_uris' => [ 'https://example.com/cb' ] ], + ), + ); + + Monkey\Functions\expect( 'get_current_user_id' )->andReturn( 42 ); + Monkey\Functions\expect( 'wp_validate_redirect' )->never(); + + $this->myyoast_client->shouldReceive( 'get_authorization_url' ) + ->once() + ->with( 42, [ 'openid' ], null, null ) + ->andReturn( 'https://my.yoast.com/auth?code_challenge=abc' ); + + $this->status_presenter->shouldReceive( 'present' )->andReturn( $this->status_payload() ); + + Mockery::mock( 'overload:' . WP_REST_Response::class ); + + $response = $this->instance->authorize( $this->request_with_return_url( null ) ); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + } + + /** + * Tests authorize drops an off-site return URL to null rather than forwarding + * it, closing the open-redirect surface at the route. + * + * @covers ::authorize + * @covers ::resolve_return_url + * + * @return void + */ + public function test_authorize_drops_offsite_return_url() { + $this->provision(); + + $evil_url = 'https://evil.example.org/phish'; + + $this->client_registration->shouldReceive( 'get_registered_client' ) + ->andReturn( + new Registered_Client( + 'client-123', + 'rat', + 'https://my.yoast.com/clients/client-123', + [ 'redirect_uris' => [ 'https://example.com/cb' ] ], + ), + ); + + Monkey\Functions\expect( 'get_current_user_id' )->andReturn( 42 ); + + // wp_validate_redirect returns the (empty) fallback for an off-site URL. + Monkey\Functions\expect( 'wp_validate_redirect' ) + ->once() + ->with( $evil_url, '' ) + ->andReturn( '' ); + + $this->myyoast_client->shouldReceive( 'get_authorization_url' ) + ->once() + ->with( 42, [ 'openid' ], null, null ) + ->andReturn( 'https://my.yoast.com/auth?code_challenge=abc' ); + + $this->status_presenter->shouldReceive( 'present' )->andReturn( $this->status_payload() ); + + Mockery::mock( 'overload:' . WP_REST_Response::class ); + + $response = $this->instance->authorize( $this->request_with_return_url( $evil_url ) ); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + } + + /** + * Tests authorize surfaces an error when the site is not registered. + * + * @covers ::authorize + * + * @return void + */ + public function test_authorize_when_not_registered() { + $this->provision(); + + $this->client_registration->shouldReceive( 'get_registered_client' )->andReturn( null ); + $this->myyoast_client->shouldNotReceive( 'get_authorization_url' ); + $this->status_presenter->shouldReceive( 'present' )->andReturn( $this->status_payload() ); + + Mockery::mock( 'overload:' . WP_REST_Response::class ); + + $this->assertInstanceOf( WP_REST_Response::class, $this->instance->authorize( $this->request_with_return_url( null ) ) ); + } + + /** + * Tests authorize maps Authorization_Flow_Exception to a registration_failed error. + * + * @covers ::authorize + * + * @return void + */ + public function test_authorize_handles_flow_exception() { + $this->provision(); + + $this->client_registration->shouldReceive( 'get_registered_client' ) + ->andReturn( + new Registered_Client( + 'client-123', + 'rat', + 'https://my.yoast.com/clients/client-123', + [ 'redirect_uris' => [ 'https://example.com/cb' ] ], + ), + ); + + Monkey\Functions\expect( 'get_current_user_id' )->andReturn( 42 ); + + $this->myyoast_client->shouldReceive( 'get_authorization_url' ) + ->andThrow( new Authorization_Flow_Exception( 'registration_failed', 'boom' ) ); + + $this->status_presenter->shouldReceive( 'present' )->andReturn( $this->status_payload() ); + + Mockery::mock( 'overload:' . WP_REST_Response::class ); + + $this->assertInstanceOf( WP_REST_Response::class, $this->instance->authorize( $this->request_with_return_url( null ) ) ); + } +} diff --git a/tests/Unit/MyYoast_Client/User_Interface/OAuth_Callback_Integration_Test.php b/tests/Unit/MyYoast_Client/User_Interface/OAuth_Callback_Integration_Test.php new file mode 100644 index 00000000000..eabe5ae1a7a --- /dev/null +++ b/tests/Unit/MyYoast_Client/User_Interface/OAuth_Callback_Integration_Test.php @@ -0,0 +1,309 @@ +callback_handler = Mockery::mock( OAuth_Callback_Handler::class ); + $this->auth_code_handler = Mockery::mock( Authorization_Code_Handler::class ); + $this->redirect_helper = Mockery::mock( Redirect_Helper::class ); + + $this->instance = new OAuth_Callback_Integration( + $this->callback_handler, + $this->auth_code_handler, + $this->redirect_helper, + ); + + // resolve_return_url always builds the fallback and validates any stored + // URL against it; stub both so every test path has them available. + Monkey\Functions\stubs( + [ + 'admin_url' => static function ( $path ) { + return 'https://example.com/wp-admin/' . $path; + }, + // Valid URLs pass through; a fallback would only be returned for an + // off-host URL, which is covered explicitly where it matters. + 'wp_validate_redirect' => static function ( $location ) { + return $location; + }, + ], + ); + + $_GET = []; + } + + /** + * Tears down test fixtures. + * + * @return void + */ + protected function tear_down() { + $_GET = []; + parent::tear_down(); + } + + /** + * Tests the conditional list. + * + * @covers ::get_conditionals + * + * @return void + */ + public function test_get_conditionals() { + $this->assertSame( + [ MyYoast_Connection_Conditional::class ], + OAuth_Callback_Integration::get_conditionals(), + ); + } + + /** + * Tests the admin-post callback hook is registered. + * + * The redirect URI no longer needs filtering here: the redirect-URI provider + * defaults to this endpoint's URL directly, so the integration only wires the + * `admin_post_*` handler. + * + * @covers ::register_hooks + * + * @return void + */ + public function test_register_hooks() { + Monkey\Actions\expectAdded( 'admin_post_yoast_myyoast_oauth_callback' ) + ->once() + ->with( [ $this->instance, 'handle' ] ); + + $this->instance->register_hooks(); + } + + /** + * Tests the callback endpoint URL points at the dedicated admin-post action. + * + * @covers ::get_callback_url + * + * @return void + */ + public function test_get_callback_url_points_at_admin_post_action() { + $callback_url = 'https://example.com/wp-admin/admin-post.php?action=yoast_myyoast_oauth_callback'; + + Monkey\Functions\expect( 'get_admin_url' ) + ->with( null, 'admin-post.php?action=yoast_myyoast_oauth_callback' ) + ->andReturn( $callback_url ); + + $this->assertSame( $callback_url, OAuth_Callback_Integration::get_callback_url() ); + } + + /** + * Tests the extracted callback parameters are passed to the handler and the user is redirected back. + * + * @covers ::__construct + * @covers ::handle + * @covers ::resolve_return_url + * @covers ::read_query_arg + * + * @return void + */ + public function test_handle_drives_callback_handler_with_extracted_params() { + $_GET = [ + 'code' => 'abc', + 'state' => 'xyz', + 'error' => '', + ]; + + $this->expect_user( 42 ); + $this->expect_return_url_lookup( 42, self::RETURN_URL ); + $this->expect_callback( 42, 'abc', 'xyz', '', Callback_Outcome::success() ); + + $this->expect_redirect( self::RETURN_URL ); + + $this->instance->handle(); + } + + /** + * Tests a provider error is forwarded verbatim to the handler before redirecting. + * + * @covers ::handle + * @covers ::read_query_arg + * + * @return void + */ + public function test_handle_forwards_provider_error() { + $_GET = [ 'error' => 'access_denied' ]; + + $this->expect_user( 7 ); + $this->expect_return_url_lookup( 7, self::RETURN_URL ); + $this->expect_callback( 7, '', '', 'access_denied', Callback_Outcome::provider_error( 'access_denied' ) ); + + $this->expect_redirect( self::RETURN_URL ); + + $this->instance->handle(); + } + + /** + * Tests the handler still drives the use-case (and redirects) for a no-op callback. + * + * @covers ::handle + * + * @return void + */ + public function test_handle_no_op_redirects() { + $_GET = []; + + $this->expect_user( 42 ); + $this->expect_return_url_lookup( 42, self::RETURN_URL ); + $this->expect_callback( 42, '', '', '', Callback_Outcome::no_op() ); + + $this->expect_redirect( self::RETURN_URL ); + + $this->instance->handle(); + } + + /** + * Tests the handler falls back to the integrations page when no return URL is stored. + * + * @covers ::resolve_return_url + * + * @return void + */ + public function test_handle_falls_back_to_integrations_page_when_no_return_url_stored() { + $_GET = []; + + $this->expect_user( 42 ); + + $this->auth_code_handler->shouldReceive( 'get_return_url' )->once()->with( 42 )->andReturn( null ); + $this->expect_callback( 42, '', '', '', Callback_Outcome::no_op() ); + + $this->expect_redirect( self::FALLBACK_URL ); + + $this->instance->handle(); + } + + /** + * Tests anonymous requests are redirected to the fallback without driving the handler. + * + * @covers ::handle + * + * @return void + */ + public function test_handle_anonymous_request_redirects_to_fallback() { + $_GET = [ + 'code' => 'abc', + 'state' => 'xyz', + ]; + + $this->expect_user( 0 ); + + $this->callback_handler->shouldNotReceive( 'handle' ); + + $this->expect_redirect( self::FALLBACK_URL ); + + $this->instance->handle(); + } + + /** + * Configures the current user id stub. + * + * @param int $user_id The user id to return. + * + * @return void + */ + private function expect_user( int $user_id ): void { + Monkey\Functions\expect( 'get_current_user_id' )->andReturn( $user_id ); + } + + /** + * Configures the stored return URL lookup. + * + * @param int $user_id The user id. + * @param string $url The URL to return. + * + * @return void + */ + private function expect_return_url_lookup( int $user_id, string $url ): void { + $this->auth_code_handler->shouldReceive( 'get_return_url' )->once()->with( $user_id )->andReturn( $url ); + } + + /** + * Configures the expected delegation to the callback handler. + * + * @param int $user_id The user id. + * @param string $code The expected authorization code argument. + * @param string $state The expected state argument. + * @param string $error The expected provider error argument. + * @param Callback_Outcome $outcome The outcome to return. + * + * @return void + */ + private function expect_callback( int $user_id, string $code, string $state, string $error, Callback_Outcome $outcome ): void { + $this->callback_handler->shouldReceive( 'handle' ) + ->once() + ->with( $user_id, $code, $state, $error ) + ->andReturn( $outcome ); + } + + /** + * Configures the expected redirect. + * + * @param string $url The expected redirect target. + * + * @return void + */ + private function expect_redirect( string $url ): void { + $this->redirect_helper->shouldReceive( 'do_safe_redirect' )->once()->with( $url ); + } +} diff --git a/tests/Unit/MyYoast_Client/User_Interface/Status_Presenter_Test.php b/tests/Unit/MyYoast_Client/User_Interface/Status_Presenter_Test.php new file mode 100644 index 00000000000..c58e7fb6025 --- /dev/null +++ b/tests/Unit/MyYoast_Client/User_Interface/Status_Presenter_Test.php @@ -0,0 +1,247 @@ +client_registration = Mockery::mock( Client_Registration_Interface::class ); + $this->issuer_config = Mockery::mock( Issuer_Config::class ); + $this->redirect_uri_provider = Mockery::mock( Redirect_URI_Provider_Interface::class ); + + $this->instance = new Status_Presenter( + $this->client_registration, + $this->issuer_config, + $this->redirect_uri_provider, + ); + + Monkey\Functions\stubs( + [ + 'wp_parse_url' => static function ( $url ) { + return \parse_url( $url ); + }, + ], + ); + } + + /** + * Tests the not-provisioned, not-registered branch. + * + * @covers ::__construct + * @covers ::present + * + * @return void + */ + public function test_present_when_not_provisioned() { + $this->issuer_config->shouldReceive( 'get_software_statement' )->andReturn( '' ); + $this->issuer_config->shouldReceive( 'get_initial_access_token' )->andReturn( '' ); + $this->client_registration->shouldReceive( 'get_registered_client' )->andReturn( null ); + + $result = $this->instance->present(); + + $this->assertFalse( $result['is_provisioned'] ); + $this->assertFalse( $result['is_registered'] ); + $this->assertNull( $result['registered_at'] ); + $this->assertNull( $result['registered_at_iso'] ); + $this->assertSame( [], $result['redirect_uris'] ); + $this->assertTrue( $result['redirect_uris_match'] ); + } + + /** + * Tests the provisioned but not-yet-registered branch. + * + * @covers ::present + * + * @return void + */ + public function test_present_when_provisioned_but_not_registered() { + $this->issuer_config->shouldReceive( 'get_software_statement' )->andReturn( 'jwt' ); + $this->issuer_config->shouldReceive( 'get_initial_access_token' )->andReturn( 'iat' ); + $this->client_registration->shouldReceive( 'get_registered_client' )->andReturn( null ); + + $result = $this->instance->present(); + + $this->assertTrue( $result['is_provisioned'] ); + $this->assertFalse( $result['is_registered'] ); + $this->assertNull( $result['registered_at'] ); + $this->assertSame( [], $result['redirect_uris'] ); + $this->assertTrue( $result['redirect_uris_match'] ); + } + + /** + * Tests the registered branch where the stored redirect URIs include the + * currently-computed one — redirect_uris_match should be true. + * + * @covers ::present + * @covers ::extract_registered_at + * @covers ::extract_redirect_uris + * @covers ::extract_origin + * @covers ::redirect_uris_match + * + * @return void + */ + public function test_present_when_registered_with_matching_uri() { + $this->issuer_config->shouldReceive( 'get_software_statement' )->andReturn( 'jwt' ); + $this->issuer_config->shouldReceive( 'get_initial_access_token' )->andReturn( 'iat' ); + + $staging_uri = 'https://staging.example.com/wp-admin/admin.php?page=wpseo_dashboard'; + $this->redirect_uri_provider + ->shouldReceive( 'get_redirect_uris' ) + ->andReturn( [ self::CURRENT_REDIRECT_URI, $staging_uri ] ); + + // The current URI has completed an authorization-code flow; the staging one has not. + $registered_client = new Registered_Client( + 'client-123', + 'rat', + 'https://my.yoast.com/clients/client-123', + [ + 'client_id_issued_at' => 1_731_369_600, + 'redirect_uris' => [ + self::CURRENT_REDIRECT_URI, + $staging_uri, + ], + ], + [ self::CURRENT_REDIRECT_URI ], + ); + + $this->client_registration->shouldReceive( 'get_registered_client' )->andReturn( $registered_client ); + + $result = $this->instance->present(); + + $this->assertTrue( $result['is_provisioned'] ); + $this->assertTrue( $result['is_registered'] ); + $this->assertSame( 1_731_369_600, $result['registered_at'] ); + $this->assertIsString( $result['registered_at_iso'] ); + $this->assertSame( + [ + [ + 'uri' => self::CURRENT_REDIRECT_URI, + 'origin' => 'https://example.com', + 'is_verified' => true, + ], + [ + 'uri' => $staging_uri, + 'origin' => 'https://staging.example.com', + 'is_verified' => false, + ], + ], + $result['redirect_uris'], + ); + $this->assertTrue( $result['redirect_uris_match'] ); + } + + /** + * Tests the registered branch where the site URL has drifted (the + * currently-computed redirect URI is no longer in the stored list). + * + * @covers ::present + * @covers ::redirect_uris_match + * + * @return void + */ + public function test_present_when_registered_with_drifted_uri() { + $this->issuer_config->shouldReceive( 'get_software_statement' )->andReturn( 'jwt' ); + $this->issuer_config->shouldReceive( 'get_initial_access_token' )->andReturn( 'iat' ); + $this->redirect_uri_provider + ->shouldReceive( 'get_redirect_uris' ) + ->andReturn( [ 'https://new-domain.example.com/wp-admin/admin.php?page=wpseo_dashboard&yoast_myyoast_oauth_callback=1' ] ); + + $registered_client = new Registered_Client( + 'client-123', + 'rat', + 'https://my.yoast.com/clients/client-123', + [ + 'redirect_uris' => [ self::CURRENT_REDIRECT_URI ], + ], + ); + + $this->client_registration->shouldReceive( 'get_registered_client' )->andReturn( $registered_client ); + + $result = $this->instance->present(); + + $this->assertTrue( $result['is_registered'] ); + $this->assertFalse( $result['redirect_uris_match'] ); + } + + /** + * Tests that absent `client_id_issued_at` falls back to null. + * + * @covers ::extract_registered_at + * + * @return void + */ + public function test_present_when_registered_without_issued_at() { + $this->issuer_config->shouldReceive( 'get_software_statement' )->andReturn( 'jwt' ); + $this->issuer_config->shouldReceive( 'get_initial_access_token' )->andReturn( 'iat' ); + $this->redirect_uri_provider + ->shouldReceive( 'get_redirect_uris' ) + ->andReturn( [ self::CURRENT_REDIRECT_URI ] ); + + $registered_client = new Registered_Client( + 'client-123', + 'rat', + 'https://my.yoast.com/clients/client-123', + [ + 'redirect_uris' => [ self::CURRENT_REDIRECT_URI ], + ], + ); + + $this->client_registration->shouldReceive( 'get_registered_client' )->andReturn( $registered_client ); + + $result = $this->instance->present(); + + $this->assertNull( $result['registered_at'] ); + $this->assertNull( $result['registered_at_iso'] ); + } +}