diff --git a/src/messaging/error.ts b/src/messaging/error.ts index 6df577f591..27de36af3b 100644 --- a/src/messaging/error.ts +++ b/src/messaging/error.ts @@ -132,9 +132,13 @@ export const messagingClientErrorCode: { readonly [K in keyof typeof MessagingEr }, THIRD_PARTY_AUTH_ERROR: { code: MessagingErrorCode.THIRD_PARTY_AUTH_ERROR, - message: 'A message targeted to an iOS device could not be sent because the ' + - 'required APNs SSL certificate was not uploaded or has expired. Check the validity of your ' + - 'development and production certificates.', + // The backend documents this code as "APNs certificate or web push auth key was invalid or + // missing", so the message names both, and the project settings where they are configured. + // The previous wording covered only an APNs SSL certificate, which does not fit APNs auth + // keys or Web Push. + message: 'The message could not be sent because the APNs certificate or auth key, or the ' + + 'web push auth key, configured for your Firebase project was invalid or missing. Check ' + + 'the APNs and web push credentials in your Firebase project settings.', }, TOO_MANY_TOPICS: { code: MessagingErrorCode.TOO_MANY_TOPICS, @@ -213,6 +217,22 @@ const MESSAGING_SERVER_TO_CLIENT_CODE: Record} Server error codes that name the provider credential explicitly. + * + * These all map to THIRD_PARTY_AUTH_ERROR and leave no doubt about which credential is at fault, + * so the canonical message can lead for them. + * + * `UNAUTHENTICATED` maps to the same client code but is deliberately absent. Without an FcmError + * detail it is the plain gateway rejection, which really can mean this SDK's own credential is + * bad, and prefixing APNs guidance there would point developers away from the actual fault. + */ +const PROVIDER_AUTH_SERVER_CODES: ReadonlySet = new Set([ + 'THIRD_PARTY_AUTH_ERROR', + 'APNS_AUTH_ERROR', + 'InvalidApnsCredential', +]); + /** * @const {Record} Topic management (IID) * server to client enum error codes. @@ -257,7 +277,17 @@ export class FirebaseMessagingError extends FirebaseError { clientCodeKey = MESSAGING_SERVER_TO_CLIENT_CODE[serverErrorCode]; } const error: ErrorInfo = deepCopy((messagingClientErrorCode as any)[clientCodeKey]); - error.message = message || error.message; + // The server message is normally the more specific of the two, so it wins. The exception is + // the codes above: for those the backend commonly sends the generic gateway text ("Request is + // missing required authentication credential. Expected OAuth 2 access token, ..."), which + // describes the caller's own credential while the fault is an APNs or web push credential on + // the project, so on its own it sends developers to audit their service account. Lead with the + // canonical message and keep the server text after it. + if (message && PROVIDER_AUTH_SERVER_CODES.has(serverErrorCode ?? '')) { + error.message = `${error.message} Server message: "${message}"`; + } else { + error.message = message || error.message; + } const rawData = serverError?.response?.data; if (clientCodeKey === 'UNKNOWN_ERROR' && typeof rawData !== 'undefined') { diff --git a/test/unit/index.spec.ts b/test/unit/index.spec.ts index 360bac68e2..4911cc4bdb 100644 --- a/test/unit/index.spec.ts +++ b/test/unit/index.spec.ts @@ -48,6 +48,7 @@ import './database/index.spec'; // Messaging import './messaging/index.spec'; import './messaging/messaging.spec'; +import './messaging/messaging-errors-internal.spec'; // Machine Learning import './machine-learning/index.spec'; diff --git a/test/unit/messaging/messaging-errors-internal.spec.ts b/test/unit/messaging/messaging-errors-internal.spec.ts index 360fe0716b..fbf5821021 100644 --- a/test/unit/messaging/messaging-errors-internal.spec.ts +++ b/test/unit/messaging/messaging-errors-internal.spec.ts @@ -58,6 +58,92 @@ describe('messaging-errors-internal', () => { }); }); + // The gateway text the backend returns for these. It describes the caller's own credential, + // which is not what is at fault. + const GATEWAY_MESSAGE = 'Request is missing required authentication credential. Expected ' + + 'OAuth 2 access token, login cookie or other valid authentication credential.'; + const APNS_GUIDANCE = 'The message could not be sent because the APNs certificate or auth ' + + 'key, or the web push auth key, configured for your Firebase project was invalid or ' + + 'missing. Check the APNs and web push credentials in your Firebase project settings.'; + + /** Builds a JSON error response. `error` is spread in so cases can omit `message` entirely. */ + function jsonError(status: number, error: object): RequestResponseError { + const mockResponse: Partial = { + status, + headers: {}, + isJson: () => true, + data: { error } + }; + return new RequestResponseError(mockResponse as RequestResponse); + } + + /** An FcmError detail carrying the given code, as the v1 backend sends it. */ + function fcmDetail(errorCode: string): object[] { + return [{ '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', errorCode }]; + } + + // Every server code that names the provider credential outright. They must behave alike: the + // backend picks between these aliases, and the guidance should not depend on which it sent. + // The HTTP status varies so the behavior cannot be keyed on it. + const providerAuthCases = [ + { name: 'THIRD_PARTY_AUTH_ERROR detail', status: 401, + error: { status: 'UNAUTHENTICATED', details: fcmDetail('THIRD_PARTY_AUTH_ERROR') } }, + { name: 'APNS_AUTH_ERROR detail', status: 404, + error: { status: 'INVALID_ARGUMENT', details: fcmDetail('APNS_AUTH_ERROR') } }, + { name: 'legacy InvalidApnsCredential', status: 400, + error: { status: 'InvalidApnsCredential' } }, + ]; + + providerAuthCases.forEach(({ name, status, error }) => { + it(`should lead with the APNs guidance for ${name}`, () => { + const err = createFirebaseError(jsonError(status, { ...error, message: GATEWAY_MESSAGE })); + + expect(err.code).to.equal('messaging/third-party-auth-error'); + // The whole message, so that weakening any part of the guidance fails here. + expect(err.message).to.equal(`${APNS_GUIDANCE} Server message: "${GATEWAY_MESSAGE}"`); + }); + + it(`should not append an empty server message for ${name}`, () => { + // No `message` key at all. Appending here would print `Server message: "undefined"`. + expect(createFirebaseError(jsonError(status, error)).message).to.equal(APNS_GUIDANCE); + // Present but empty, which the parser also treats as absent. + expect(createFirebaseError(jsonError(status, { ...error, message: '' })).message) + .to.equal(APNS_GUIDANCE); + }); + }); + + it('should not claim an APNs fault for UNAUTHENTICATED without an FcmError detail', () => { + // Same client code, different cause: with no FcmError detail this is the plain gateway + // rejection, which really can mean the SDK's own credential is bad. The server message has + // to stand on its own here rather than being prefixed with APNs guidance. + const err = createFirebaseError( + jsonError(401, { status: 'UNAUTHENTICATED', message: GATEWAY_MESSAGE })); + + expect(err.code).to.equal('messaging/third-party-auth-error'); + expect(err.message).to.equal(GATEWAY_MESSAGE); + }); + + it('should still use the server message verbatim for other error codes', () => { + // Guards the change above from widening: only THIRD_PARTY_AUTH_ERROR is special-cased. + const mockResponse: Partial = { + status: 404, + headers: {}, + isJson: () => true, + data: { + error: { + status: 'NOT_FOUND', + message: 'Requested entity was not found.' + } + } + }; + const mockError = new RequestResponseError(mockResponse as RequestResponse); + + const error = createFirebaseError(mockError); + + expect(error.code).to.equal('messaging/registration-token-not-registered'); + expect(error.message).to.equal('Requested entity was not found.'); + }); + it('should create FirebaseMessagingError for non-JSON response (400)', () => { const mockResponse: Partial = { status: 400,