From ef559f3d8cbed452c540e88343854dd7bf721d8c Mon Sep 17 00:00:00 2001 From: Hampus Date: Sun, 6 Sep 2026 15:32:47 +0200 Subject: [PATCH] fix(api): handle bad manifests, unfurl errors and the apns key (#2508) --- fluxer_api/src/api/app/APILifecycle.ts | 2 + .../src/api/download/DownloadService.ts | 4 +- .../tests/DownloadService.manifest.test.ts | 71 +++++++++++++++++ .../favorite_gif/FavoriteGifResolver.test.ts | 79 ++++++++++++++++++- .../api/favorite_gif/FavoriteGifResolver.ts | 7 +- .../NatsUnfurlerService.test.ts | 54 ++++++++++++- .../api/infrastructure/NatsUnfurlerService.ts | 31 +++++++- fluxer_api/src/api/push/ApnsPushService.ts | 25 +++++- .../api/push/tests/ApnsPushService.test.ts | 27 ++++++- 9 files changed, 287 insertions(+), 13 deletions(-) create mode 100644 fluxer_api/src/api/download/tests/DownloadService.manifest.test.ts diff --git a/fluxer_api/src/api/app/APILifecycle.ts b/fluxer_api/src/api/app/APILifecycle.ts index 3707caa81..06ac13532 100644 --- a/fluxer_api/src/api/app/APILifecycle.ts +++ b/fluxer_api/src/api/app/APILifecycle.ts @@ -28,6 +28,7 @@ import { getUserRepository, } from '../middleware/ServiceSingletons'; import {torExitListCache} from '../middleware/TorExitListCache'; +import {ensureApnsSigningKey} from '../push/ApnsPushService'; import {initializeSearch, shutdownSearch} from '../SearchFactory'; import {warmupAdminSearchIndexes} from '../search/SearchWarmup'; import {VisionarySlotInitializer} from '../stripe/VisionarySlotInitializer'; @@ -46,6 +47,7 @@ export function createInitializer(config: APIConfig, logger: ILogger): () => Pro return async (): Promise => { try { logger.info('Initializing API service...'); + await ensureApnsSigningKey(); const geoipStartupResult = await ensureGeoipDatabaseOnStartup({ geoip: config.geoip, s3Config: { diff --git a/fluxer_api/src/api/download/DownloadService.ts b/fluxer_api/src/api/download/DownloadService.ts index 89e64f6cc..4c9f25121 100644 --- a/fluxer_api/src/api/download/DownloadService.ts +++ b/fluxer_api/src/api/download/DownloadService.ts @@ -17,7 +17,7 @@ import { StorageObjectRangeNotSatisfiableError, } from '../infrastructure/IStorageService'; import {Logger} from '../Logger'; -import {isJsonRecord, parseJsonUnknown} from '../utils/JsonBoundaryUtils'; +import {isJsonRecord, parseJsonRecord, parseJsonUnknown} from '../utils/JsonBoundaryUtils'; import { parseDesktopArtifactScope, parseDesktopReleaseDescriptor, @@ -731,7 +731,7 @@ export class DownloadService { private async readJsonObjectFromStorage(key: string): Promise { const text = await this.readTextFromStorage(key); - return text == null ? null : parseJsonUnknown(text); + return text == null ? null : parseJsonRecord(text); } private async readTextFromStorage(key: string): Promise { diff --git a/fluxer_api/src/api/download/tests/DownloadService.manifest.test.ts b/fluxer_api/src/api/download/tests/DownloadService.manifest.test.ts new file mode 100644 index 000000000..0956c0fc2 --- /dev/null +++ b/fluxer_api/src/api/download/tests/DownloadService.manifest.test.ts @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Readable} from 'node:stream'; +import {describe, expect, it} from 'vitest'; +import type {IStorageService} from '../../infrastructure/IStorageService'; +import {DownloadService} from '../DownloadService'; + +const PREFIX = 'desktop/stable/darwin/x64'; +const MANIFEST_KEY = `${PREFIX}/manifest.json`; +const LISTED_FILENAME = 'Fluxer-1.2.3-mac-universal.dmg'; +const MANIFEST_FILENAME = 'Fluxer-1.3.0-mac-universal.dmg'; + +const LATEST_PARAMS = { + channel: 'stable', + plat: 'darwin', + arch: 'x64', + format: 'dmg', +} as const; + +function createService(overrides: {manifestBody?: string | null; objectKeys?: Array} = {}) { + const objectKeys = overrides.objectKeys ?? [`${PREFIX}/${LISTED_FILENAME}`]; + const storageService = { + streamObject: async (params: {key: string}) => { + if (params.key !== MANIFEST_KEY) { + return null; + } + const body = overrides.manifestBody; + if (body == null) { + return null; + } + const buffer = Buffer.from(body, 'utf8'); + return {body: Readable.from([buffer]), contentLength: buffer.byteLength}; + }, + listObjects: async () => objectKeys.map((key) => ({key})), + getObjectMetadata: async (_bucket: string, key: string) => + objectKeys.includes(key) ? {contentLength: 1, contentType: 'application/x-apple-diskimage'} : null, + } as unknown as IStorageService; + return new DownloadService(storageService); +} + +describe('desktop manifest parsing', () => { + it('falls back to the object listing when the manifest is not valid JSON', async () => { + const service = createService({manifestBody: '{not json'}); + await expect(service.resolveLatestDesktopKey({...LATEST_PARAMS})).resolves.toBe(`${PREFIX}/${LISTED_FILENAME}`); + }); + + it('returns null rather than throwing when the manifest is malformed and no artifact is listed', async () => { + const service = createService({manifestBody: '{not json', objectKeys: []}); + await expect(service.resolveLatestDesktopKey({...LATEST_PARAMS})).resolves.toBeNull(); + }); + + it('falls back to the object listing when the manifest parses to an array', async () => { + const service = createService({manifestBody: '[]'}); + await expect(service.resolveLatestDesktopKey({...LATEST_PARAMS})).resolves.toBe(`${PREFIX}/${LISTED_FILENAME}`); + }); + + it('still resolves through a well-formed manifest', async () => { + const service = createService({ + manifestBody: JSON.stringify({ + channel: 'stable', + platform: 'darwin', + arch: 'x64', + version: '1.3.0', + pub_date: '2026-08-17T00:00:00Z', + files: {dmg: MANIFEST_FILENAME}, + }), + objectKeys: [`${PREFIX}/${LISTED_FILENAME}`, `${PREFIX}/${MANIFEST_FILENAME}`], + }); + await expect(service.resolveLatestDesktopKey({...LATEST_PARAMS})).resolves.toBe(`${PREFIX}/${MANIFEST_FILENAME}`); + }); +}); diff --git a/fluxer_api/src/api/favorite_gif/FavoriteGifResolver.test.ts b/fluxer_api/src/api/favorite_gif/FavoriteGifResolver.test.ts index 22627bcd8..1b2a92f6e 100644 --- a/fluxer_api/src/api/favorite_gif/FavoriteGifResolver.test.ts +++ b/fluxer_api/src/api/favorite_gif/FavoriteGifResolver.test.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {BadGatewayError} from '@fluxer/errors/src/domains/core/BadGatewayError'; import type {GifResponse} from '@fluxer/schema/src/domains/gif/GifSchemas'; import {describe, expect, it, vi} from 'vitest'; import {GifService} from '../gif/GifService'; @@ -8,7 +9,7 @@ import type {IMediaService} from '../infrastructure/IMediaService'; import type {IUnfurlerService} from '../infrastructure/IUnfurlerService'; import {resolveFavoriteGifEntry} from './FavoriteGifResolver'; -function createProvider(gif: GifResponse): IGifProvider { +function createProvider(gif: GifResponse, slug: string | null = gif.slug): IGifProvider { return { meta: { name: 'klipy', @@ -23,7 +24,7 @@ function createProvider(gif: GifResponse): IGifProvider { suggest: async () => [], resolveByUrl: async () => gif, buildShareUrl: (slug) => `https://klipy.com/gifs/${slug}`, - extractSlugFromUrl: () => gif.slug, + extractSlugFromUrl: () => slug, }; } @@ -84,4 +85,78 @@ describe('resolveFavoriteGifEntry', () => { expect(mediaService.getMetadata).not.toHaveBeenCalled(); expect(unfurlerService.unfurl).not.toHaveBeenCalled(); }); + + it('degrades only the failing URL when its unfurl stage rejects', async () => { + const gif: GifResponse = { + id: 'unused', + slug: 'unused', + provider: 'klipy', + title: 'Unused', + url: 'https://klipy.com/gifs/unused', + src: 'https://static.klipy.example/unused.gif', + proxy_src: 'https://media.example/unused.gif', + width: 1, + height: 1, + media: {}, + placeholder: null, + }; + const brokenUrl = 'https://example.com/broken'; + const workingUrl = 'https://example.com/working'; + const mediaService = { + getMetadata: vi.fn(async () => null), + getExternalMediaProxyURL: vi.fn((url: string) => `https://media.example/proxy?url=${encodeURIComponent(url)}`), + } as unknown as IMediaService; + const unfurlerService = { + unfurl: vi.fn(async (url: string) => { + if (url === brokenUrl) { + throw new BadGatewayError({message: '[nats-unfurl] service error: upstream exploded'}); + } + return [ + { + image: { + url: 'https://cdn.example/working.gif', + proxy_url: 'https://media.example/working.gif', + content_type: 'image/gif', + width: 200, + height: 100, + placeholder: null, + }, + }, + ]; + }), + } as unknown as IUnfurlerService; + const gifService = new GifService(createProvider(gif, null)); + + const entries = await Promise.all( + [brokenUrl, workingUrl].map((url) => + resolveFavoriteGifEntry({url, locale: 'en-US', country: 'US', gifService, mediaService, unfurlerService}), + ), + ); + + expect(entries[0]).toEqual({ + url: brokenUrl, + proxy_url: `https://media.example/proxy?url=${encodeURIComponent(brokenUrl)}`, + width: 0, + height: 0, + media: {}, + content_type: '', + placeholder: null, + }); + expect(entries[1]).toEqual({ + url: workingUrl, + proxy_url: 'https://media.example/working.gif', + width: 200, + height: 100, + media: { + gif: { + src: 'https://cdn.example/working.gif', + proxy_src: 'https://media.example/working.gif', + width: 200, + height: 100, + }, + }, + content_type: 'image/gif', + placeholder: null, + }); + }); }); diff --git a/fluxer_api/src/api/favorite_gif/FavoriteGifResolver.ts b/fluxer_api/src/api/favorite_gif/FavoriteGifResolver.ts index 6f2db77a7..6f4027731 100644 --- a/fluxer_api/src/api/favorite_gif/FavoriteGifResolver.ts +++ b/fluxer_api/src/api/favorite_gif/FavoriteGifResolver.ts @@ -4,7 +4,7 @@ import {Logger} from '@fluxer/logger/src/Logger'; import type {ResolvedGifEntrySchema} from '@fluxer/schema/src/domains/gif/FavoriteGifSchemas'; import {inferFormatContentType, PREVIEW_FORMAT_PRIORITY} from '@fluxer/schema/src/domains/gif/GifMediaFormatKeys'; import type {GifMediaFormat, GifResponse} from '@fluxer/schema/src/domains/gif/GifSchemas'; -import type {EmbedMediaResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas'; +import type {EmbedMediaResponse, MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas'; import {tryExtractGifProviderSlug} from '../gif/GifProviderUtils'; import type {GifService} from '../gif/GifService'; import type {IGifProvider} from '../gif/IGifProvider'; @@ -130,7 +130,10 @@ async function resolveUnfurledFavoriteGifEntry({ unfurlerService: IUnfurlerService; mediaService: IMediaService; }): Promise { - const embeds = await unfurlerService.unfurl(url, 'allow'); + const embeds = await unfurlerService.unfurl(url, 'allow').catch((error: unknown) => { + logger.warn({error, url}, 'Failed to unfurl favorite GIF URL'); + return [] as Array; + }); for (const embed of embeds) { const media = [embed.video, embed.image, embed.thumbnail].find((candidate) => isRenderableMediaType(candidate?.content_type), diff --git a/fluxer_api/src/api/infrastructure/NatsUnfurlerService.test.ts b/fluxer_api/src/api/infrastructure/NatsUnfurlerService.test.ts index bf713e3b9..af8cbd807 100644 --- a/fluxer_api/src/api/infrastructure/NatsUnfurlerService.test.ts +++ b/fluxer_api/src/api/infrastructure/NatsUnfurlerService.test.ts @@ -1,5 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {BadGatewayError} from '@fluxer/errors/src/domains/core/BadGatewayError'; +import {GatewayTimeoutError} from '@fluxer/errors/src/domains/core/GatewayTimeoutError'; +import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError'; import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager'; import {type NatsConnection, StringCodec} from 'nats'; import {describe, expect, it} from 'vitest'; @@ -11,12 +14,23 @@ interface FakeRequest { timeout: number | undefined; } +const RESOLVED_REPLY = JSON.stringify({Resolved: {embeds: [], cache_ttl_seconds: null}}); + +function natsErrorWithCode(code: string): Error { + return Object.assign(new Error('nats request failed'), {code}); +} + class FakeNatsConnectionManager implements INatsConnectionManager { private readonly codec = StringCodec(); private closed = true; readonly requests: Array = []; connectCalls = 0; + constructor( + private readonly replyText: string = RESOLVED_REPLY, + private readonly requestError: Error | null = null, + ) {} + async connect(): Promise { this.connectCalls += 1; this.closed = false; @@ -33,8 +47,11 @@ class FakeNatsConnectionManager implements INatsConnectionManager { body: JSON.parse(this.codec.decode(data)) as Record, timeout: options?.timeout, }); + if (this.requestError) { + throw this.requestError; + } return { - data: this.codec.encode(JSON.stringify({Resolved: {embeds: [], cache_ttl_seconds: null}})), + data: this.codec.encode(this.replyText), }; }, } as unknown as NatsConnection; @@ -79,4 +96,39 @@ describe('NatsUnfurlerService', () => { expect(manager.requests[0]?.body).not.toHaveProperty('media_proxy_endpoint'); expect(manager.requests[0]?.body).not.toHaveProperty('media_proxy_secret_key'); }); + + it('rejects with a bad gateway error when the unfurl service reports a failure', async () => { + const manager = new FakeNatsConnectionManager(JSON.stringify({Failed: {message: 'upstream exploded'}})); + const service = new NatsUnfurlerService(manager); + + await expect(service.unfurlWithCachePolicy('https://example.com')).rejects.toBeInstanceOf(BadGatewayError); + }); + + it('rejects with a bad gateway error when the reply payload is unreadable', async () => { + const manager = new FakeNatsConnectionManager('not json'); + const service = new NatsUnfurlerService(manager); + + await expect(service.unfurlWithCachePolicy('https://example.com')).rejects.toBeInstanceOf(BadGatewayError); + }); + + it('rejects with a gateway timeout error when the request times out', async () => { + const manager = new FakeNatsConnectionManager(RESOLVED_REPLY, natsErrorWithCode('TIMEOUT')); + const service = new NatsUnfurlerService(manager); + + await expect(service.unfurlWithCachePolicy('https://example.com')).rejects.toBeInstanceOf(GatewayTimeoutError); + }); + + it('rejects with a service unavailable error when no responders answer', async () => { + const manager = new FakeNatsConnectionManager(RESOLVED_REPLY, natsErrorWithCode('503')); + const service = new NatsUnfurlerService(manager); + + await expect(service.unfurlWithCachePolicy('https://example.com')).rejects.toBeInstanceOf(ServiceUnavailableError); + }); + + it('rejects with a service unavailable error when the shard rejects the request', async () => { + const manager = new FakeNatsConnectionManager(JSON.stringify({error: 'overloaded'})); + const service = new NatsUnfurlerService(manager); + + await expect(service.unfurlWithCachePolicy('https://example.com')).rejects.toBeInstanceOf(ServiceUnavailableError); + }); }); diff --git a/fluxer_api/src/api/infrastructure/NatsUnfurlerService.ts b/fluxer_api/src/api/infrastructure/NatsUnfurlerService.ts index 176f0b834..b0038156d 100644 --- a/fluxer_api/src/api/infrastructure/NatsUnfurlerService.ts +++ b/fluxer_api/src/api/infrastructure/NatsUnfurlerService.ts @@ -1,5 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {BadGatewayError} from '@fluxer/errors/src/domains/core/BadGatewayError'; +import {GatewayTimeoutError} from '@fluxer/errors/src/domains/core/GatewayTimeoutError'; +import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError'; +import {FluxerError} from '@fluxer/errors/src/FluxerError'; import type {MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas'; import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager'; import {StringCodec} from 'nats'; @@ -12,6 +16,8 @@ import {throwForSvcErrorReply} from './SvcErrorReply'; const NATS_UNFURL_SUBJECT = 'svc.unfurl'; const NATS_UNFURL_TIMEOUT_MS = 12000; const NATS_UNFURL_CACHE_ONLY_TIMEOUT_MS = 1000; +const NATS_NO_RESPONDERS_CODE = '503'; +const NATS_TIMEOUT_CODE = 'TIMEOUT'; interface NatsUnfurlRequest { op: 'Unfurl'; @@ -54,6 +60,23 @@ function isNatsUnfurlResponse(value: unknown): value is NatsUnfurlResponse { return false; } +function mapUnfurlTransportError(error: unknown): Error { + if (error instanceof FluxerError) { + return error; + } + if (!(error instanceof Error)) { + return new BadGatewayError({message: '[nats-unfurl] request failed'}); + } + const code = 'code' in error && typeof error.code === 'string' ? error.code : null; + if (code === NATS_NO_RESPONDERS_CODE || error.message === 'NO_RESPONDERS' || error.name === 'NoRespondersError') { + return new ServiceUnavailableError({message: '[nats-unfurl] no unfurl service is answering'}); + } + if (code === NATS_TIMEOUT_CODE || error.message === 'TIMEOUT' || error.name === 'TimeoutError') { + return new GatewayTimeoutError({message: '[nats-unfurl] unfurl service did not answer in time'}); + } + return new BadGatewayError({message: '[nats-unfurl] request failed'}); +} + export class NatsUnfurlerService extends IUnfurlerService { private readonly connectionManager: INatsConnectionManager; private readonly codec = StringCodec(); @@ -94,7 +117,7 @@ export class NatsUnfurlerService extends IUnfurlerService { const response = parseJsonWithGuard(responseText, isNatsUnfurlResponse); if (!response) { throwForSvcErrorReply('nats-unfurl', parseJsonRecord(responseText)); - throw new Error(`[nats-unfurl] invalid response payload: ${responseText}`); + throw new BadGatewayError({message: '[nats-unfurl] invalid response payload'}); } if ('Resolved' in response) { return { @@ -103,16 +126,16 @@ export class NatsUnfurlerService extends IUnfurlerService { }; } if ('Failed' in response) { - throw new Error(`[nats-unfurl] service error: ${response.Failed.message}`); + throw new BadGatewayError({message: `[nats-unfurl] service error: ${response.Failed.message}`}); } - throw new Error(`[nats-unfurl] unexpected response variant: ${responseText}`); + throw new BadGatewayError({message: '[nats-unfurl] unexpected response variant'}); } catch (error) { if (options.signal?.aborted) { Logger.warn({url}, '[nats-unfurl] request aborted'); } else { Logger.error({error, url}, '[nats-unfurl] failed to unfurl URL'); } - throw error; + throw mapUnfurlTransportError(error); } } } diff --git a/fluxer_api/src/api/push/ApnsPushService.ts b/fluxer_api/src/api/push/ApnsPushService.ts index 23c8ee3a3..74329d2ab 100644 --- a/fluxer_api/src/api/push/ApnsPushService.ts +++ b/fluxer_api/src/api/push/ApnsPushService.ts @@ -45,6 +45,7 @@ interface Http2Response { } const providerTokenCache = new Map(); +const apnsSigningKeys = new Map>(); const apnsSessions = new Map(); export async function sendApnsPush(params: SendApnsPushParams): Promise { @@ -105,7 +106,7 @@ async function getProviderToken(): Promise { if (cached && cached.expiresAtSeconds > nowSeconds) { return cached.token; } - const key = await importPKCS8(privateKey, 'ES256'); + const key = await apnsSigningKey(privateKey); const token = await new SignJWT({}) .setProtectedHeader({alg: 'ES256', kid: cfg.keyId}) .setIssuer(cfg.teamId) @@ -125,6 +126,27 @@ async function resolveApnsPrivateKey(): Promise { return normalizePem(pem); } +export async function ensureApnsSigningKey(): Promise { + if (!Config.push.apns.enabled) return; + const privateKey = await resolveApnsPrivateKey(); + if (!privateKey) return; + await apnsSigningKey(privateKey); + Logger.info('APNs signing key loaded'); +} + +async function apnsSigningKey(privateKey: string): Promise>> { + const cached = apnsSigningKeys.get(privateKey); + if (cached) return await cached; + const pending = importPKCS8(privateKey, 'ES256'); + apnsSigningKeys.set(privateKey, pending); + try { + return await pending; + } catch (error) { + apnsSigningKeys.delete(privateKey); + throw error; + } +} + function normalizePem(value: string): string { return value.replaceAll('\\n', '\n'); } @@ -316,4 +338,5 @@ export const ApnsPushServiceTestHooks = { buildApnsPayload, buildApnsHeaders, isPermanentApnsFailure, + apnsSigningKey, }; diff --git a/fluxer_api/src/api/push/tests/ApnsPushService.test.ts b/fluxer_api/src/api/push/tests/ApnsPushService.test.ts index d3878a30d..2b9e88641 100644 --- a/fluxer_api/src/api/push/tests/ApnsPushService.test.ts +++ b/fluxer_api/src/api/push/tests/ApnsPushService.test.ts @@ -1,7 +1,17 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {generateKeyPairSync} from 'node:crypto'; import {describe, expect, it} from 'vitest'; -import {ApnsPushServiceTestHooks} from '../ApnsPushService'; +import {ApnsPushServiceTestHooks, ensureApnsSigningKey} from '../ApnsPushService'; + +function pkcs8Pem(der: Buffer): string { + const base64 = der.toString('base64'); + const lines: Array = []; + for (let index = 0; index < base64.length; index += 64) { + lines.push(base64.slice(index, index + 64)); + } + return `-----BEGIN PRIVATE KEY-----\n${lines.join('\n')}\n-----END PRIVATE KEY-----\n`; +} describe('ApnsPushService', () => { it('builds modern APNs alert payloads for chat messages', () => { @@ -132,6 +142,21 @@ describe('ApnsPushService', () => { expect(payload.aps).not.toHaveProperty('mutable-content'); expect(payload.author_avatar_url).toBe('https://cdn.example/avatar.png'); }); + it('imports the APNs signing key once per PEM and rejects a truncated one every time', async () => { + const {privateKey} = generateKeyPairSync('ec', {namedCurve: 'prime256v1'}); + const der = privateKey.export({type: 'pkcs8', format: 'der'}); + const pem = privateKey.export({type: 'pkcs8', format: 'pem'}).toString(); + const truncated = pkcs8Pem(der.subarray(0, der.length - 8)); + const first = await ApnsPushServiceTestHooks.apnsSigningKey(pem); + expect(await ApnsPushServiceTestHooks.apnsSigningKey(pem)).toBe(first); + await expect(ApnsPushServiceTestHooks.apnsSigningKey(truncated)).rejects.toThrow(); + await expect(ApnsPushServiceTestHooks.apnsSigningKey(truncated)).rejects.toThrow(); + }); + + it('loads no APNs signing key at startup while APNs is disabled', async () => { + await expect(ensureApnsSigningKey()).resolves.toBeUndefined(); + }); + it('marks only permanent APNs token failures as subscription deletion signals', () => { expect(ApnsPushServiceTestHooks.isPermanentApnsFailure(410, 'Unregistered')).toBe(true); expect(ApnsPushServiceTestHooks.isPermanentApnsFailure(400, 'BadDeviceToken')).toBe(true);