diff --git a/common/api-review/installations-exp.api.md b/common/api-review/installations-exp.api.md new file mode 100644 index 00000000000..9e3db393ef6 --- /dev/null +++ b/common/api-review/installations-exp.api.md @@ -0,0 +1,34 @@ +## API Report File for "@firebase/installations-exp" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { FirebaseApp } from '@firebase/app-types-exp'; +import { FirebaseInstallations } from '@firebase/installations-types-exp'; + +// @public (undocumented) +export function deleteInstallations(installations: FirebaseInstallations): Promise; + +// @public (undocumented) +export function getId(installations: FirebaseInstallations): Promise; + +// @public (undocumented) +export function getInstallations(app: FirebaseApp): FirebaseInstallations; + +// @public (undocumented) +export function getToken(installations: FirebaseInstallations, forceRefresh?: boolean): Promise; + +// @public (undocumented) +export type IdChangeCallbackFn = (installationId: string) => void; + +// @public (undocumented) +export type IdChangeUnsubscribeFn = () => void; + +// @public +export function onIdChange(installations: FirebaseInstallations, callback: IdChangeCallbackFn): IdChangeUnsubscribeFn; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/common/api-review/installations-types-exp.api.md b/common/api-review/installations-types-exp.api.md new file mode 100644 index 00000000000..f7925dd7a9f --- /dev/null +++ b/common/api-review/installations-types-exp.api.md @@ -0,0 +1,20 @@ +## API Report File for "@firebase/installations-types-exp" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +// @public (undocumented) +export interface FirebaseInstallations {} + +// @internal +export interface _FirebaseInstallationsInternal { + getId(): Promise; + + getToken(forceRefresh?: boolean): Promise; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages-exp/installations-exp/.eslintrc.js b/packages-exp/installations-exp/.eslintrc.js new file mode 100644 index 00000000000..ca80aa0f69a --- /dev/null +++ b/packages-exp/installations-exp/.eslintrc.js @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module.exports = { + extends: '../../config/.eslintrc.js', + parserOptions: { + project: 'tsconfig.json', + // to make vscode-eslint work with monorepo + // https://github.com/typescript-eslint/typescript-eslint/issues/251#issuecomment-463943250 + tsconfigRootDir: __dirname + } +}; diff --git a/packages-exp/installations-exp/api-extractor.json b/packages-exp/installations-exp/api-extractor.json new file mode 100644 index 00000000000..f291311f711 --- /dev/null +++ b/packages-exp/installations-exp/api-extractor.json @@ -0,0 +1,8 @@ +{ + "extends": "../../config/api-extractor.json", + // Point it to your entry point d.ts file. + "mainEntryPointFilePath": "/dist/src/index.d.ts", + "dtsRollup": { + "enabled": true + } +} \ No newline at end of file diff --git a/packages-exp/installations-exp/karma.conf.js b/packages-exp/installations-exp/karma.conf.js new file mode 100644 index 00000000000..1699a0681ec --- /dev/null +++ b/packages-exp/installations-exp/karma.conf.js @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const karmaBase = require('../../config/karma.base'); + +const files = [`src/**/*.test.ts`]; + +module.exports = function (config) { + const karmaConfig = { + ...karmaBase, + // files to load into karma + files, + // frameworks to use + // available frameworks: https://npmjs.org/browse/keyword/karma-adapter + frameworks: ['mocha'] + }; + + config.set(karmaConfig); +}; + +module.exports.files = files; diff --git a/packages-exp/installations-exp/package.json b/packages-exp/installations-exp/package.json new file mode 100644 index 00000000000..f715dd022b3 --- /dev/null +++ b/packages-exp/installations-exp/package.json @@ -0,0 +1,64 @@ +{ + "name": "@firebase/installations-exp", + "version": "0.0.800", + "private": true, + "author": "Firebase (https://firebase.google.com/)", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "browser": "dist/index.esm.js", + "esm2017": "dist/index.esm2017.js", + "typings": "dist/installations-exp.d.ts", + "license": "Apache-2.0", + "files": [ + "dist" + ], + "scripts": { + "lint": "eslint -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", + "lint:fix": "eslint --fix -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", + "build": "rollup -c && yarn api-report", + "build:deps": "lerna run --scope @firebase/installations-exp --include-dependencies build", + "dev": "rollup -c -w", + "test": "yarn type-check && yarn test:karma && yarn lint", + "test:ci": "node ../../scripts/run_tests_in_ci.js", + "test:karma": "karma start --single-run", + "test:debug": "karma start --browsers=Chrome --auto-watch", + "type-check": "tsc -p . --noEmit", + "serve": "yarn serve:build && yarn serve:host", + "serve:build": "rollup -c test-app/rollup.config.js", + "serve:host": "http-server -c-1 test-app", + "prepare": "yarn build", + "api-report": "api-extractor run --local --verbose", + "predoc": "node ../../scripts/exp/remove-exp.js temp", + "doc": "api-documenter markdown --input temp --output docs", + "build:doc": "yarn build && yarn doc" + }, + "repository": { + "directory": "packages-exp/installations-exp", + "type": "git", + "url": "https://github.com/firebase/firebase-js-sdk.git" + }, + "bugs": { + "url": "https://github.com/firebase/firebase-js-sdk/issues" + }, + "devDependencies": { + "@firebase/app-exp": "0.0.800", + "rollup": "2.26.7", + "rollup-plugin-commonjs": "10.1.0", + "rollup-plugin-json": "4.0.0", + "rollup-plugin-node-resolve": "5.2.0", + "rollup-plugin-typescript2": "0.27.2", + "rollup-plugin-uglify": "6.0.4", + "typescript": "4.0.2" + }, + "peerDependencies": { + "@firebase/app-exp": "0.x", + "@firebase/app-types-exp": "0.x" + }, + "dependencies": { + "@firebase/installations-types-exp": "0.0.800", + "@firebase/util": "0.3.1", + "@firebase/component": "0.1.18", + "idb": "3.0.2", + "tslib": "^1.11.1" + } +} \ No newline at end of file diff --git a/packages-exp/installations-exp/rollup.config.js b/packages-exp/installations-exp/rollup.config.js new file mode 100644 index 00000000000..a10d038fd95 --- /dev/null +++ b/packages-exp/installations-exp/rollup.config.js @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import json from 'rollup-plugin-json'; +import typescriptPlugin from 'rollup-plugin-typescript2'; +import pkg from './package.json'; +import typescript from 'typescript'; + +const deps = Object.keys({ ...pkg.peerDependencies, ...pkg.dependencies }); + +/** + * ES5 Builds + */ +const es5BuildPlugins = [typescriptPlugin({ typescript }), json()]; + +const es5Builds = [ + { + input: 'src/index.ts', + output: [ + { file: pkg.main, format: 'cjs', sourcemap: true }, + { file: pkg.module, format: 'es', sourcemap: true } + ], + plugins: es5BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + } +]; + +/** + * ES2017 Builds + */ +const es2017BuildPlugins = [ + typescriptPlugin({ + typescript, + tsconfigOverride: { + compilerOptions: { + target: 'es2017' + } + } + }), + json({ preferConst: true }) +]; + +const es2017Builds = [ + { + input: 'src/index.ts', + output: { + file: pkg.esm2017, + format: 'es', + sourcemap: true + }, + plugins: es2017BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + } +]; + +export default [...es5Builds, ...es2017Builds]; diff --git a/packages-exp/installations-exp/src/api/delete-installations.test.ts b/packages-exp/installations-exp/src/api/delete-installations.test.ts new file mode 100644 index 00000000000..ccae7f9c2cd --- /dev/null +++ b/packages-exp/installations-exp/src/api/delete-installations.test.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { SinonStub, stub } from 'sinon'; +import * as deleteInstallationRequestModule from '../functions/delete-installation-request'; +import { get, set } from '../helpers/idb-manager'; +import { + InProgressInstallationEntry, + RegisteredInstallationEntry, + RequestStatus, + UnregisteredInstallationEntry +} from '../interfaces/installation-entry'; +import { getFakeInstallations } from '../testing/fake-generators'; +import '../testing/setup'; +import { ErrorCode } from '../util/errors'; +import { sleep } from '../util/sleep'; +import { deleteInstallations } from './delete-installations'; +import { + FirebaseInstallationsImpl, + AppConfig +} from '../interfaces/installation-impl'; + +const FID = 'children-of-the-damned'; + +describe('deleteInstallation', () => { + let installations: FirebaseInstallationsImpl; + let deleteInstallationRequestSpy: SinonStub< + [AppConfig, RegisteredInstallationEntry], + Promise + >; + + beforeEach(() => { + installations = getFakeInstallations(); + + deleteInstallationRequestSpy = stub( + deleteInstallationRequestModule, + 'deleteInstallationRequest' + ).callsFake( + () => sleep(100) // Request would take some time + ); + }); + + it('resolves without calling server API if there is no installation', async () => { + await expect(deleteInstallations(installations)).to.be.fulfilled; + expect(deleteInstallationRequestSpy).not.to.have.been.called; + }); + + it('deletes and resolves without calling server API if the installation is unregistered', async () => { + const entry: UnregisteredInstallationEntry = { + registrationStatus: RequestStatus.NOT_STARTED, + fid: FID + }; + await set(installations.appConfig, entry); + + await expect(deleteInstallations(installations)).to.be.fulfilled; + expect(deleteInstallationRequestSpy).not.to.have.been.called; + await expect(get(installations.appConfig)).to.eventually.be.undefined; + }); + + it('rejects without calling server API if the installation is pending', async () => { + const entry: InProgressInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.IN_PROGRESS, + registrationTime: Date.now() - 3 * 1000 + }; + await set(installations.appConfig, entry); + + await expect(deleteInstallations(installations)).to.be.rejectedWith( + ErrorCode.DELETE_PENDING_REGISTRATION + ); + expect(deleteInstallationRequestSpy).not.to.have.been.called; + }); + + it('rejects without calling server API if the installation is registered and app is offline', async () => { + const entry: RegisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + token: 'authToken', + expiresIn: 123456, + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now() + } + }; + await set(installations.appConfig, entry); + stub(navigator, 'onLine').value(false); + + await expect(deleteInstallations(installations)).to.be.rejectedWith( + ErrorCode.APP_OFFLINE + ); + expect(deleteInstallationRequestSpy).not.to.have.been.called; + }); + + it('deletes and resolves after calling server API if the installation is registered', async () => { + const entry: RegisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + token: 'authToken', + expiresIn: 123456, + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now() + } + }; + await set(installations.appConfig, entry); + + await expect(deleteInstallations(installations)).to.be.fulfilled; + expect(deleteInstallationRequestSpy).to.have.been.calledOnceWith( + installations.appConfig, + entry + ); + await expect(get(installations.appConfig)).to.eventually.be.undefined; + }); +}); diff --git a/packages-exp/installations-exp/src/api/delete-installations.ts b/packages-exp/installations-exp/src/api/delete-installations.ts new file mode 100644 index 00000000000..adaf51c3dde --- /dev/null +++ b/packages-exp/installations-exp/src/api/delete-installations.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { deleteInstallationRequest } from '../functions/delete-installation-request'; +import { remove, update } from '../helpers/idb-manager'; +import { RequestStatus } from '../interfaces/installation-entry'; +import { ERROR_FACTORY, ErrorCode } from '../util/errors'; +import { FirebaseInstallationsImpl } from '../interfaces/installation-impl'; +import { FirebaseInstallations } from '@firebase/installations-types-exp'; + +/** + * @public + */ +export async function deleteInstallations( + installations: FirebaseInstallations +): Promise { + const { appConfig } = installations as FirebaseInstallationsImpl; + + const entry = await update(appConfig, oldEntry => { + if (oldEntry && oldEntry.registrationStatus === RequestStatus.NOT_STARTED) { + // Delete the unregistered entry without sending a deleteInstallation request. + return undefined; + } + return oldEntry; + }); + + if (entry) { + if (entry.registrationStatus === RequestStatus.IN_PROGRESS) { + // Can't delete while trying to register. + throw ERROR_FACTORY.create(ErrorCode.DELETE_PENDING_REGISTRATION); + } else if (entry.registrationStatus === RequestStatus.COMPLETED) { + if (!navigator.onLine) { + throw ERROR_FACTORY.create(ErrorCode.APP_OFFLINE); + } else { + await deleteInstallationRequest(appConfig, entry); + await remove(appConfig); + } + } + } +} diff --git a/packages-exp/installations-exp/src/api/get-id.test.ts b/packages-exp/installations-exp/src/api/get-id.test.ts new file mode 100644 index 00000000000..77f14a03cb8 --- /dev/null +++ b/packages-exp/installations-exp/src/api/get-id.test.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { SinonStub, stub } from 'sinon'; +import * as getInstallationEntryModule from '../helpers/get-installation-entry'; +import * as refreshAuthTokenModule from '../helpers/refresh-auth-token'; +import { + RegisteredInstallationEntry, + RequestStatus +} from '../interfaces/installation-entry'; +import { getFakeInstallations } from '../testing/fake-generators'; +import '../testing/setup'; +import { getId } from './get-id'; +import { + FirebaseInstallationsImpl, + AppConfig +} from '../interfaces/installation-impl'; + +const FID = 'disciples-of-the-watch'; + +describe('getId', () => { + let installations: FirebaseInstallationsImpl; + let getInstallationEntrySpy: SinonStub< + [AppConfig], + Promise + >; + + beforeEach(() => { + installations = getFakeInstallations(); + + getInstallationEntrySpy = stub( + getInstallationEntryModule, + 'getInstallationEntry' + ); + }); + + it('returns the FID in InstallationEntry returned by getInstallationEntry', async () => { + getInstallationEntrySpy.resolves({ + installationEntry: { + fid: FID, + registrationStatus: RequestStatus.NOT_STARTED + }, + registrationPromise: Promise.resolve({} as RegisteredInstallationEntry) + }); + + const fid = await getId(installations); + expect(fid).to.equal(FID); + expect(getInstallationEntrySpy).to.be.calledOnce; + }); + + it('calls refreshAuthToken if the installation is registered', async () => { + getInstallationEntrySpy.resolves({ + installationEntry: { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + requestStatus: RequestStatus.NOT_STARTED + } + } + }); + + const refreshAuthTokenSpy = stub( + refreshAuthTokenModule, + 'refreshAuthToken' + ).resolves({ + token: 'authToken', + expiresIn: 123456, + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now() + }); + + await getId(installations); + expect(refreshAuthTokenSpy).to.be.calledOnce; + }); +}); diff --git a/packages-exp/installations-exp/src/api/get-id.ts b/packages-exp/installations-exp/src/api/get-id.ts new file mode 100644 index 00000000000..b8ae95090e0 --- /dev/null +++ b/packages-exp/installations-exp/src/api/get-id.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getInstallationEntry } from '../helpers/get-installation-entry'; +import { refreshAuthToken } from '../helpers/refresh-auth-token'; +import { FirebaseInstallationsImpl } from '../interfaces/installation-impl'; +import { FirebaseInstallations } from '@firebase/installations-types-exp'; + +/** + * @public + */ +export async function getId( + installations: FirebaseInstallations +): Promise { + const installationsImpl = installations as FirebaseInstallationsImpl; + const { installationEntry, registrationPromise } = await getInstallationEntry( + installationsImpl.appConfig + ); + + if (registrationPromise) { + registrationPromise.catch(console.error); + } else { + // If the installation is already registered, update the authentication + // token if needed. + refreshAuthToken(installationsImpl).catch(console.error); + } + + return installationEntry.fid; +} diff --git a/packages-exp/installations-exp/src/api/get-installations.ts b/packages-exp/installations-exp/src/api/get-installations.ts new file mode 100644 index 00000000000..b6d0839c36c --- /dev/null +++ b/packages-exp/installations-exp/src/api/get-installations.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FirebaseApp } from '@firebase/app-types-exp'; +import { FirebaseInstallations } from '@firebase/installations-types-exp'; +import { _getProvider } from '@firebase/app-exp'; + +/** + * @public + */ +export function getInstallations(app: FirebaseApp): FirebaseInstallations { + const installationsImpl = _getProvider( + app, + 'installations-exp' + ).getImmediate(); + return installationsImpl; +} diff --git a/packages-exp/installations-exp/src/api/get-token.test.ts b/packages-exp/installations-exp/src/api/get-token.test.ts new file mode 100644 index 00000000000..430d341b3c2 --- /dev/null +++ b/packages-exp/installations-exp/src/api/get-token.test.ts @@ -0,0 +1,467 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { SinonFakeTimers, SinonStub, stub, useFakeTimers } from 'sinon'; +import * as createInstallationRequestModule from '../functions/create-installation-request'; +import * as generateAuthTokenRequestModule from '../functions/generate-auth-token-request'; +import { get, set } from '../helpers/idb-manager'; +import { + CompletedAuthToken, + InProgressInstallationEntry, + RegisteredInstallationEntry, + RequestStatus, + UnregisteredInstallationEntry +} from '../interfaces/installation-entry'; +import { getFakeInstallations } from '../testing/fake-generators'; +import '../testing/setup'; +import { TOKEN_EXPIRATION_BUFFER } from '../util/constants'; +import { ERROR_FACTORY, ErrorCode } from '../util/errors'; +import { sleep } from '../util/sleep'; +import { getToken } from './get-token'; +import { + AppConfig, + FirebaseInstallationsImpl +} from '../interfaces/installation-impl'; + +const FID = 'dont-talk-to-strangers'; +const AUTH_TOKEN = 'authToken'; +const NEW_AUTH_TOKEN = 'newAuthToken'; +const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000; + +/** + * A map of different states of the database and a function that creates the + * said state. + */ +const setupInstallationEntryMap: Map< + string, + (appConfig: AppConfig) => Promise +> = new Map([ + [ + 'existing and valid auth token', + async (appConfig: AppConfig) => { + const entry: RegisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + token: AUTH_TOKEN, + expiresIn: ONE_WEEK_MS, + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now() + } + }; + await set(appConfig, entry); + } + ], + [ + 'expired auth token', + async (appConfig: AppConfig) => { + const entry: RegisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + token: AUTH_TOKEN, + expiresIn: ONE_WEEK_MS, + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now() - 2 * ONE_WEEK_MS + } + }; + await set(appConfig, entry); + } + ], + [ + 'pending auth token', + async (appConfig: AppConfig) => { + const entry: RegisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + requestStatus: RequestStatus.IN_PROGRESS, + requestTime: Date.now() - 3 * 1000 + } + }; + + await set(appConfig, entry); + + // Finish pending request after 500 ms + // eslint-disable-next-line @typescript-eslint/no-floating-promises + sleep(500).then(async () => { + const updatedEntry: RegisteredInstallationEntry = { + ...entry, + authToken: { + token: NEW_AUTH_TOKEN, + expiresIn: ONE_WEEK_MS, + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now() + } + }; + await set(appConfig, updatedEntry); + }); + } + ], + [ + 'no auth token', + async (appConfig: AppConfig) => { + const entry: RegisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + requestStatus: RequestStatus.NOT_STARTED + } + }; + await set(appConfig, entry); + } + ], + [ + 'pending fid registration', + async (appConfig: AppConfig) => { + const entry: InProgressInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.IN_PROGRESS, + registrationTime: Date.now() - 3 * 1000 + }; + + await set(appConfig, entry); + + // Finish pending request after 500 ms + // eslint-disable-next-line @typescript-eslint/no-floating-promises + sleep(500).then(async () => { + const updatedEntry: RegisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + token: AUTH_TOKEN, + expiresIn: ONE_WEEK_MS, + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now() + } + }; + await set(appConfig, updatedEntry); + }); + } + ], + [ + 'unregistered fid', + async (appConfig: AppConfig) => { + const entry: UnregisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.NOT_STARTED + }; + + await set(appConfig, entry); + } + ] +]); + +describe('getToken', () => { + let installations: FirebaseInstallationsImpl; + let createInstallationRequestSpy: SinonStub< + [AppConfig, InProgressInstallationEntry], + Promise + >; + let generateAuthTokenRequestSpy: SinonStub< + [FirebaseInstallationsImpl, RegisteredInstallationEntry], + Promise + >; + + beforeEach(() => { + installations = getFakeInstallations(); + + createInstallationRequestSpy = stub( + createInstallationRequestModule, + 'createInstallationRequest' + ).callsFake(async (_, installationEntry) => { + await sleep(100); // Request would take some time + const result: RegisteredInstallationEntry = { + fid: installationEntry.fid, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + token: NEW_AUTH_TOKEN, + expiresIn: ONE_WEEK_MS, + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now() + } + }; + return result; + }); + generateAuthTokenRequestSpy = stub( + generateAuthTokenRequestModule, + 'generateAuthTokenRequest' + ).callsFake(async () => { + await sleep(100); // Request would take some time + const result: CompletedAuthToken = { + token: NEW_AUTH_TOKEN, + expiresIn: ONE_WEEK_MS, + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now() + }; + return result; + }); + }); + + describe('basic functionality', () => { + for (const [title, setup] of setupInstallationEntryMap.entries()) { + describe(`when ${title} in the DB`, () => { + beforeEach(() => setup(installations.appConfig)); + + it('resolves with an auth token', async () => { + const token = await getToken(installations); + expect(token).to.be.oneOf([AUTH_TOKEN, NEW_AUTH_TOKEN]); + }); + + it('saves the token in the DB', async () => { + const token = await getToken(installations); + const installationEntry = (await get( + installations.appConfig + )) as RegisteredInstallationEntry; + expect(installationEntry).not.to.be.undefined; + expect(installationEntry.registrationStatus).to.equal( + RequestStatus.COMPLETED + ); + expect(installationEntry.authToken.requestStatus).to.equal( + RequestStatus.COMPLETED + ); + expect( + (installationEntry.authToken as CompletedAuthToken).token + ).to.equal(token); + }); + + it('returns the same token on subsequent calls', async () => { + const token1 = await getToken(installations); + const token2 = await getToken(installations); + expect(token1).to.equal(token2); + }); + }); + } + }); + + describe('when there is no FID in the DB', () => { + it('gets the token by registering a new FID', async () => { + await getToken(installations); + expect(createInstallationRequestSpy).to.be.called; + expect(generateAuthTokenRequestSpy).not.to.be.called; + }); + + it('does not register a new FID on subsequent calls', async () => { + await getToken(installations); + await getToken(installations); + expect(createInstallationRequestSpy).to.be.calledOnce; + }); + + it('throws if the app is offline', async () => { + stub(navigator, 'onLine').value(false); + + await expect(getToken(installations)).to.be.rejected; + }); + }); + + describe('when there is a FID in the DB, but no auth token', () => { + let installationEntry: RegisteredInstallationEntry; + + beforeEach(async () => { + installationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + requestStatus: RequestStatus.NOT_STARTED + } + }; + await set(installations.appConfig, installationEntry); + }); + + it('gets the token by calling generateAuthToken', async () => { + await getToken(installations); + expect(generateAuthTokenRequestSpy).to.be.called; + expect(createInstallationRequestSpy).not.to.be.called; + }); + + it('does not call generateAuthToken twice on subsequent calls', async () => { + await getToken(installations); + await getToken(installations); + expect(generateAuthTokenRequestSpy).to.be.calledOnce; + }); + + it('does not call generateAuthToken twice on simultaneous calls', async () => { + await Promise.all([getToken(installations), getToken(installations)]); + expect(generateAuthTokenRequestSpy).to.be.calledOnce; + }); + + it('throws if the app is offline', async () => { + stub(navigator, 'onLine').value(false); + + await expect(getToken(installations)).to.be.rejected; + }); + + describe('and the server returns an error', () => { + it('removes the FID from the DB if the server returns a 401 response', async () => { + generateAuthTokenRequestSpy.callsFake(async () => { + throw ERROR_FACTORY.create(ErrorCode.REQUEST_FAILED, { + requestName: 'Generate Auth Token', + serverCode: 401, + serverStatus: 'UNAUTHENTICATED', + serverMessage: 'Invalid Authentication.' + }); + }); + + await expect(getToken(installations)).to.be.rejected; + await expect(get(installations.appConfig)).to.eventually.be.undefined; + }); + + it('removes the FID from the DB if the server returns a 404 response', async () => { + generateAuthTokenRequestSpy.callsFake(async () => { + throw ERROR_FACTORY.create(ErrorCode.REQUEST_FAILED, { + requestName: 'Generate Auth Token', + serverCode: 404, + serverStatus: 'NOT_FOUND', + serverMessage: 'FID not found.' + }); + }); + + await expect(getToken(installations)).to.be.rejected; + await expect(get(installations.appConfig)).to.eventually.be.undefined; + }); + + it('does not remove the FID from the DB if the server returns any other response', async () => { + generateAuthTokenRequestSpy.callsFake(async () => { + throw ERROR_FACTORY.create(ErrorCode.REQUEST_FAILED, { + requestName: 'Generate Auth Token', + serverCode: 500, + serverStatus: 'INTERNAL', + serverMessage: 'Internal server error.' + }); + }); + + await expect(getToken(installations)).to.be.rejected; + await expect(get(installations.appConfig)).to.eventually.deep.equal( + installationEntry + ); + }); + }); + }); + + describe('when there is a registered auth token in the DB', () => { + beforeEach(async () => { + const installationEntry: RegisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + token: AUTH_TOKEN, + expiresIn: ONE_WEEK_MS, + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now() + } + }; + await set(installations.appConfig, installationEntry); + }); + + it('does not call any server APIs', async () => { + await getToken(installations); + expect(createInstallationRequestSpy).not.to.be.called; + expect(generateAuthTokenRequestSpy).not.to.be.called; + }); + + it('refreshes the token if forceRefresh is true', async () => { + const token = await getToken(installations, true); + expect(token).to.equal(NEW_AUTH_TOKEN); + expect(generateAuthTokenRequestSpy).to.be.called; + }); + + it('works even if the app is offline', async () => { + stub(navigator, 'onLine').value(false); + + const token = await getToken(installations); + expect(token).to.equal(AUTH_TOKEN); + }); + + it('throws if the app is offline and forceRefresh is true', async () => { + stub(navigator, 'onLine').value(false); + + await expect(getToken(installations, true)).to.be.rejected; + }); + }); + + describe('when there is an auth token that is about to expire in the DB', () => { + let clock: SinonFakeTimers; + + beforeEach(async () => { + clock = useFakeTimers({ shouldAdvanceTime: true }); + const installationEntry: RegisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + token: AUTH_TOKEN, + expiresIn: ONE_WEEK_MS, + requestStatus: RequestStatus.COMPLETED, + creationTime: + // Expires in ten minutes + Date.now() - ONE_WEEK_MS + TOKEN_EXPIRATION_BUFFER + 10 * 60 * 1000 + } + }; + await set(installations.appConfig, installationEntry); + }); + + it('returns a different token after expiration', async () => { + const token1 = await getToken(installations); + expect(token1).to.equal(AUTH_TOKEN); + + // Wait 30 minutes. + clock.tick('30:00'); + + const token2 = await getToken(installations); + await expect(token2).to.equal(NEW_AUTH_TOKEN); + await expect(token2).not.to.equal(token1); + }); + }); + + describe('when there is an expired auth token in the DB', () => { + beforeEach(async () => { + const installationEntry: RegisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + token: AUTH_TOKEN, + expiresIn: ONE_WEEK_MS, + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now() - 2 * ONE_WEEK_MS + } + }; + await set(installations.appConfig, installationEntry); + }); + + it('returns a different token', async () => { + const token = await getToken(installations); + expect(token).to.equal(NEW_AUTH_TOKEN); + expect(generateAuthTokenRequestSpy).to.be.called; + }); + + it('throws if the app is offline', async () => { + stub(navigator, 'onLine').value(false); + + await expect(getToken(installations)).to.be.rejected; + }); + }); +}); diff --git a/packages-exp/installations-exp/src/api/get-token.ts b/packages-exp/installations-exp/src/api/get-token.ts new file mode 100644 index 00000000000..622193759ea --- /dev/null +++ b/packages-exp/installations-exp/src/api/get-token.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getInstallationEntry } from '../helpers/get-installation-entry'; +import { refreshAuthToken } from '../helpers/refresh-auth-token'; +import { + FirebaseInstallationsImpl, + AppConfig +} from '../interfaces/installation-impl'; +import { FirebaseInstallations } from '@firebase/installations-types-exp'; + +/** + * @public + */ +export async function getToken( + installations: FirebaseInstallations, + forceRefresh = false +): Promise { + const installationsImpl = installations as FirebaseInstallationsImpl; + await completeInstallationRegistration(installationsImpl.appConfig); + + // At this point we either have a Registered Installation in the DB, or we've + // already thrown an error. + const authToken = await refreshAuthToken(installationsImpl, forceRefresh); + return authToken.token; +} + +async function completeInstallationRegistration( + appConfig: AppConfig +): Promise { + const { registrationPromise } = await getInstallationEntry(appConfig); + + if (registrationPromise) { + // A createInstallation request is in progress. Wait until it finishes. + await registrationPromise; + } +} diff --git a/packages-exp/installations-exp/src/api/index.ts b/packages-exp/installations-exp/src/api/index.ts new file mode 100644 index 00000000000..7a21b629036 --- /dev/null +++ b/packages-exp/installations-exp/src/api/index.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './get-id'; +export * from './get-token'; +export * from './delete-installations'; +export * from './on-id-change'; +export * from './get-installations'; diff --git a/packages-exp/installations-exp/src/api/on-id-change.test.ts b/packages-exp/installations-exp/src/api/on-id-change.test.ts new file mode 100644 index 00000000000..219532a0233 --- /dev/null +++ b/packages-exp/installations-exp/src/api/on-id-change.test.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { stub } from 'sinon'; +import '../testing/setup'; +import { onIdChange } from './on-id-change'; +import * as FidChangedModule from '../helpers/fid-changed'; +import { getFakeInstallations } from '../testing/fake-generators'; +import { FirebaseInstallationsImpl } from '../interfaces/installation-impl'; + +describe('onIdChange', () => { + let installations: FirebaseInstallationsImpl; + + beforeEach(() => { + installations = getFakeInstallations(); + stub(FidChangedModule); + }); + + it('calls addCallback with the given callback and app key when called', () => { + const callback = stub(); + onIdChange(installations, callback); + expect(FidChangedModule.addCallback).to.have.been.calledOnceWith( + installations.appConfig, + callback + ); + }); + + it('calls removeCallback with the given callback and app key when unsubscribe is called', () => { + const callback = stub(); + const unsubscribe = onIdChange(installations, callback); + unsubscribe(); + expect(FidChangedModule.removeCallback).to.have.been.calledOnceWith( + installations.appConfig, + callback + ); + }); +}); diff --git a/packages-exp/installations-exp/src/api/on-id-change.ts b/packages-exp/installations-exp/src/api/on-id-change.ts new file mode 100644 index 00000000000..b3133e21eca --- /dev/null +++ b/packages-exp/installations-exp/src/api/on-id-change.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { addCallback, removeCallback } from '../helpers/fid-changed'; +import { FirebaseInstallationsImpl } from '../interfaces/installation-impl'; +import { FirebaseInstallations } from '@firebase/installations-types-exp'; + +/** + * @public + */ +export type IdChangeCallbackFn = (installationId: string) => void; +/** + * @public + */ +export type IdChangeUnsubscribeFn = () => void; + +/** + * Sets a new callback that will get called when Installation ID changes. + * Returns an unsubscribe function that will remove the callback when called. + * + * @public + */ +export function onIdChange( + installations: FirebaseInstallations, + callback: IdChangeCallbackFn +): IdChangeUnsubscribeFn { + const { appConfig } = installations as FirebaseInstallationsImpl; + + addCallback(appConfig, callback); + return () => { + removeCallback(appConfig, callback); + }; +} diff --git a/packages-exp/installations-exp/src/functions/common.test.ts b/packages-exp/installations-exp/src/functions/common.test.ts new file mode 100644 index 00000000000..95829751ba7 --- /dev/null +++ b/packages-exp/installations-exp/src/functions/common.test.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { SinonStub, stub } from 'sinon'; +import '../testing/setup'; +import { retryIfServerError } from './common'; + +describe('common', () => { + describe('retryIfServerError', () => { + let fetchStub: SinonStub<[], Promise>; + + beforeEach(() => { + fetchStub = stub(); + }); + + it('retries once if the server returns a 5xx error', async () => { + const expectedResponse = new Response(); + fetchStub.onCall(0).resolves(new Response(null, { status: 500 })); + fetchStub.onCall(1).resolves(expectedResponse); + + await expect(retryIfServerError(fetchStub)).to.eventually.equal( + expectedResponse + ); + expect(fetchStub).to.be.calledTwice; + }); + + it('does not retry again if the server returns a 5xx error twice', async () => { + const expectedResponse = new Response(null, { status: 500 }); + fetchStub.onCall(0).resolves(new Response(null, { status: 500 })); + fetchStub.onCall(1).resolves(expectedResponse); + fetchStub.onCall(2).resolves(new Response()); + + await expect(retryIfServerError(fetchStub)).to.eventually.equal( + expectedResponse + ); + expect(fetchStub).to.be.calledTwice; + }); + + it('does not retry if the error is not 5xx', async () => { + const expectedResponse = new Response(null, { status: 404 }); + fetchStub.resolves(expectedResponse); + + await expect(retryIfServerError(fetchStub)).to.eventually.equal( + expectedResponse + ); + expect(fetchStub).to.be.calledOnce; + }); + + it('does not retry if response is ok', async () => { + const expectedResponse = new Response(); + fetchStub.resolves(expectedResponse); + + await expect(retryIfServerError(fetchStub)).to.eventually.equal( + expectedResponse + ); + expect(fetchStub).to.be.calledOnce; + }); + }); +}); diff --git a/packages-exp/installations-exp/src/functions/common.ts b/packages-exp/installations-exp/src/functions/common.ts new file mode 100644 index 00000000000..507296da392 --- /dev/null +++ b/packages-exp/installations-exp/src/functions/common.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FirebaseError } from '@firebase/util'; +import { GenerateAuthTokenResponse } from '../interfaces/api-response'; +import { + CompletedAuthToken, + RegisteredInstallationEntry, + RequestStatus +} from '../interfaces/installation-entry'; +import { + INSTALLATIONS_API_URL, + INTERNAL_AUTH_VERSION +} from '../util/constants'; +import { ERROR_FACTORY, ErrorCode } from '../util/errors'; +import { AppConfig } from '../interfaces/installation-impl'; + +export function getInstallationsEndpoint({ projectId }: AppConfig): string { + return `${INSTALLATIONS_API_URL}/projects/${projectId}/installations`; +} + +export function extractAuthTokenInfoFromResponse( + response: GenerateAuthTokenResponse +): CompletedAuthToken { + return { + token: response.token, + requestStatus: RequestStatus.COMPLETED, + expiresIn: getExpiresInFromResponseExpiresIn(response.expiresIn), + creationTime: Date.now() + }; +} + +export async function getErrorFromResponse( + requestName: string, + response: Response +): Promise { + const responseJson: ErrorResponse = await response.json(); + const errorData = responseJson.error; + return ERROR_FACTORY.create(ErrorCode.REQUEST_FAILED, { + requestName, + serverCode: errorData.code, + serverMessage: errorData.message, + serverStatus: errorData.status + }); +} + +export function getHeaders({ apiKey }: AppConfig): Headers { + return new Headers({ + 'Content-Type': 'application/json', + Accept: 'application/json', + 'x-goog-api-key': apiKey + }); +} + +export function getHeadersWithAuth( + appConfig: AppConfig, + { refreshToken }: RegisteredInstallationEntry +): Headers { + const headers = getHeaders(appConfig); + headers.append('Authorization', getAuthorizationHeader(refreshToken)); + return headers; +} + +export interface ErrorResponse { + error: { + code: number; + message: string; + status: string; + }; +} + +/** + * Calls the passed in fetch wrapper and returns the response. + * If the returned response has a status of 5xx, re-runs the function once and + * returns the response. + */ +export async function retryIfServerError( + fn: () => Promise +): Promise { + const result = await fn(); + + if (result.status >= 500 && result.status < 600) { + // Internal Server Error. Retry request. + return fn(); + } + + return result; +} + +function getExpiresInFromResponseExpiresIn(responseExpiresIn: string): number { + // This works because the server will never respond with fractions of a second. + return Number(responseExpiresIn.replace('s', '000')); +} + +function getAuthorizationHeader(refreshToken: string): string { + return `${INTERNAL_AUTH_VERSION} ${refreshToken}`; +} diff --git a/packages-exp/installations-exp/src/functions/config.ts b/packages-exp/installations-exp/src/functions/config.ts new file mode 100644 index 00000000000..9a0544b4ba6 --- /dev/null +++ b/packages-exp/installations-exp/src/functions/config.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { _registerComponent, _getProvider } from '@firebase/app-exp'; +import { _FirebaseService } from '@firebase/app-types-exp'; +import { + Component, + ComponentType, + InstanceFactory, + ComponentContainer +} from '@firebase/component'; +import { getId, getToken } from '../api/index'; +import { _FirebaseInstallationsInternal } from '@firebase/installations-types-exp'; +import { FirebaseInstallationsImpl } from '../interfaces/installation-impl'; +import { extractAppConfig } from '../helpers/extract-app-config'; + +const INSTALLATIONS_NAME = 'installations-exp'; +const INSTALLATIONS_NAME_INTERNAL = 'installations-exp-internal'; + +const publicFactory: InstanceFactory<'installations-exp'> = ( + container: ComponentContainer +) => { + const app = container.getProvider('app-exp').getImmediate(); + // Throws if app isn't configured properly. + const appConfig = extractAppConfig(app); + const platformLoggerProvider = _getProvider(app, 'platform-logger'); + + const installationsImpl: FirebaseInstallationsImpl = { + app, + appConfig, + platformLoggerProvider, + _delete: () => Promise.resolve() + }; + return installationsImpl; +}; + +const internalFactory: InstanceFactory<'installations-exp-internal'> = ( + container: ComponentContainer +) => { + const app = container.getProvider('app-exp').getImmediate(); + // Internal FIS instance relies on public FIS instance. + const installations = _getProvider(app, INSTALLATIONS_NAME).getImmediate(); + + const installationsInternal: _FirebaseInstallationsInternal = { + getId: () => getId(installations), + getToken: (forceRefresh?: boolean) => getToken(installations, forceRefresh) + }; + return installationsInternal; +}; + +export function registerInstallations(): void { + _registerComponent( + new Component(INSTALLATIONS_NAME, publicFactory, ComponentType.PUBLIC) + ); + _registerComponent( + new Component( + INSTALLATIONS_NAME_INTERNAL, + internalFactory, + ComponentType.PRIVATE + ) + ); +} diff --git a/packages-exp/installations-exp/src/functions/create-installation-request.test.ts b/packages-exp/installations-exp/src/functions/create-installation-request.test.ts new file mode 100644 index 00000000000..67f00585595 --- /dev/null +++ b/packages-exp/installations-exp/src/functions/create-installation-request.test.ts @@ -0,0 +1,165 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FirebaseError } from '@firebase/util'; +import { expect } from 'chai'; +import { SinonStub, stub } from 'sinon'; +import { CreateInstallationResponse } from '../interfaces/api-response'; +import { AppConfig } from '../interfaces/installation-impl'; +import { + InProgressInstallationEntry, + RequestStatus +} from '../interfaces/installation-entry'; +import { compareHeaders } from '../testing/compare-headers'; +import { getFakeAppConfig } from '../testing/fake-generators'; +import '../testing/setup'; +import { + INSTALLATIONS_API_URL, + INTERNAL_AUTH_VERSION, + PACKAGE_VERSION +} from '../util/constants'; +import { ErrorResponse } from './common'; +import { createInstallationRequest } from './create-installation-request'; + +const FID = 'defenders-of-the-faith'; + +describe('createInstallationRequest', () => { + let appConfig: AppConfig; + let fetchSpy: SinonStub<[RequestInfo, RequestInit?], Promise>; + let inProgressInstallationEntry: InProgressInstallationEntry; + let response: CreateInstallationResponse; + + beforeEach(() => { + appConfig = getFakeAppConfig(); + + inProgressInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.IN_PROGRESS, + registrationTime: Date.now() + }; + + response = { + refreshToken: 'refreshToken', + authToken: { + token: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', + expiresIn: '604800s' + }, + fid: FID + }; + fetchSpy = stub(self, 'fetch'); + }); + + describe('successful request', () => { + beforeEach(() => { + fetchSpy.resolves(new Response(JSON.stringify(response))); + }); + + it('registers a pending InstallationEntry', async () => { + const registeredInstallationEntry = await createInstallationRequest( + appConfig, + inProgressInstallationEntry + ); + expect(registeredInstallationEntry.registrationStatus).to.equal( + RequestStatus.COMPLETED + ); + }); + + it('calls the createInstallation server API with correct parameters', async () => { + const expectedHeaders = new Headers({ + 'Content-Type': 'application/json', + Accept: 'application/json', + 'x-goog-api-key': 'apiKey' + }); + const expectedBody = { + fid: FID, + authVersion: INTERNAL_AUTH_VERSION, + appId: appConfig.appId, + sdkVersion: PACKAGE_VERSION + }; + const expectedRequest: RequestInit = { + method: 'POST', + headers: expectedHeaders, + body: JSON.stringify(expectedBody) + }; + const expectedEndpoint = `${INSTALLATIONS_API_URL}/projects/projectId/installations`; + + await createInstallationRequest(appConfig, inProgressInstallationEntry); + expect(fetchSpy).to.be.calledOnceWith(expectedEndpoint, expectedRequest); + const actualHeaders = fetchSpy.lastCall.lastArg.headers; + compareHeaders(expectedHeaders, actualHeaders); + }); + }); + + it('returns the FID from the request if the response does not contain one', async () => { + response = { + refreshToken: 'refreshToken', + authToken: { + token: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', + expiresIn: '604800s' + } + }; + fetchSpy.resolves(new Response(JSON.stringify(response))); + + const registeredInstallationEntry = await createInstallationRequest( + appConfig, + inProgressInstallationEntry + ); + expect(registeredInstallationEntry.fid).to.equal(FID); + }); + + describe('failed request', () => { + it('throws a FirebaseError with the error information from the server', async () => { + const errorResponse: ErrorResponse = { + error: { + code: 409, + message: 'Requested entity already exists', + status: 'ALREADY_EXISTS' + } + }; + + fetchSpy.resolves( + new Response(JSON.stringify(errorResponse), { status: 409 }) + ); + + await expect( + createInstallationRequest(appConfig, inProgressInstallationEntry) + ).to.be.rejectedWith(FirebaseError); + }); + + it('retries once if the server returns a 5xx error', async () => { + const errorResponse: ErrorResponse = { + error: { + code: 500, + message: 'Internal server error', + status: 'SERVER_ERROR' + } + }; + + fetchSpy + .onCall(0) + .resolves(new Response(JSON.stringify(errorResponse), { status: 500 })); + fetchSpy.onCall(1).resolves(new Response(JSON.stringify(response))); + + await expect( + createInstallationRequest(appConfig, inProgressInstallationEntry) + ).to.be.fulfilled; + expect(fetchSpy).to.be.calledTwice; + }); + }); +}); diff --git a/packages-exp/installations-exp/src/functions/create-installation-request.ts b/packages-exp/installations-exp/src/functions/create-installation-request.ts new file mode 100644 index 00000000000..fe8242613f6 --- /dev/null +++ b/packages-exp/installations-exp/src/functions/create-installation-request.ts @@ -0,0 +1,67 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CreateInstallationResponse } from '../interfaces/api-response'; +import { + InProgressInstallationEntry, + RegisteredInstallationEntry, + RequestStatus +} from '../interfaces/installation-entry'; +import { INTERNAL_AUTH_VERSION, PACKAGE_VERSION } from '../util/constants'; +import { + extractAuthTokenInfoFromResponse, + getErrorFromResponse, + getHeaders, + getInstallationsEndpoint, + retryIfServerError +} from './common'; +import { AppConfig } from '../interfaces/installation-impl'; + +export async function createInstallationRequest( + appConfig: AppConfig, + { fid }: InProgressInstallationEntry +): Promise { + const endpoint = getInstallationsEndpoint(appConfig); + + const headers = getHeaders(appConfig); + const body = { + fid, + authVersion: INTERNAL_AUTH_VERSION, + appId: appConfig.appId, + sdkVersion: PACKAGE_VERSION + }; + + const request: RequestInit = { + method: 'POST', + headers, + body: JSON.stringify(body) + }; + + const response = await retryIfServerError(() => fetch(endpoint, request)); + if (response.ok) { + const responseValue: CreateInstallationResponse = await response.json(); + const registeredInstallationEntry: RegisteredInstallationEntry = { + fid: responseValue.fid || fid, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: responseValue.refreshToken, + authToken: extractAuthTokenInfoFromResponse(responseValue.authToken) + }; + return registeredInstallationEntry; + } else { + throw await getErrorFromResponse('Create Installation', response); + } +} diff --git a/packages-exp/installations-exp/src/functions/delete-installation-request.test.ts b/packages-exp/installations-exp/src/functions/delete-installation-request.test.ts new file mode 100644 index 00000000000..68c069e6bf4 --- /dev/null +++ b/packages-exp/installations-exp/src/functions/delete-installation-request.test.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FirebaseError } from '@firebase/util'; +import { expect } from 'chai'; +import { SinonStub, stub } from 'sinon'; +import { AppConfig } from '../interfaces/installation-impl'; +import { + RegisteredInstallationEntry, + RequestStatus +} from '../interfaces/installation-entry'; +import { compareHeaders } from '../testing/compare-headers'; +import { getFakeAppConfig } from '../testing/fake-generators'; +import '../testing/setup'; +import { + INSTALLATIONS_API_URL, + INTERNAL_AUTH_VERSION +} from '../util/constants'; +import { ErrorResponse } from './common'; +import { deleteInstallationRequest } from './delete-installation-request'; + +const FID = 'foreclosure-of-a-dream'; + +describe('deleteInstallationRequest', () => { + let appConfig: AppConfig; + let fetchSpy: SinonStub<[RequestInfo, RequestInit?], Promise>; + let registeredInstallationEntry: RegisteredInstallationEntry; + + beforeEach(() => { + appConfig = getFakeAppConfig(); + + registeredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + requestStatus: RequestStatus.NOT_STARTED + } + }; + + fetchSpy = stub(self, 'fetch'); + }); + + describe('successful request', () => { + beforeEach(() => { + fetchSpy.resolves(new Response()); + }); + + it('calls the deleteInstallation server API with correct parameters', async () => { + const expectedHeaders = new Headers({ + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `${INTERNAL_AUTH_VERSION} refreshToken`, + 'x-goog-api-key': 'apiKey' + }); + const expectedRequest: RequestInit = { + method: 'DELETE', + headers: expectedHeaders + }; + const expectedEndpoint = `${INSTALLATIONS_API_URL}/projects/projectId/installations/${FID}`; + + await deleteInstallationRequest(appConfig, registeredInstallationEntry); + + expect(fetchSpy).to.be.calledOnceWith(expectedEndpoint, expectedRequest); + const actualHeaders = fetchSpy.lastCall.lastArg.headers; + compareHeaders(expectedHeaders, actualHeaders); + }); + }); + + describe('failed request', () => { + it('throws a FirebaseError with the error information from the server', async () => { + const errorResponse: ErrorResponse = { + error: { + code: 409, + message: 'Requested entity already exists', + status: 'ALREADY_EXISTS' + } + }; + + fetchSpy.resolves( + new Response(JSON.stringify(errorResponse), { status: 409 }) + ); + + await expect( + deleteInstallationRequest(appConfig, registeredInstallationEntry) + ).to.be.rejectedWith(FirebaseError); + }); + + it('retries once if the server returns a 5xx error', async () => { + const errorResponse: ErrorResponse = { + error: { + code: 500, + message: 'Internal server error', + status: 'SERVER_ERROR' + } + }; + + fetchSpy + .onCall(0) + .resolves(new Response(JSON.stringify(errorResponse), { status: 500 })); + fetchSpy.onCall(1).resolves(new Response()); + + await expect( + deleteInstallationRequest(appConfig, registeredInstallationEntry) + ).to.be.fulfilled; + expect(fetchSpy).to.be.calledTwice; + }); + }); +}); diff --git a/packages-exp/installations-exp/src/functions/delete-installation-request.ts b/packages-exp/installations-exp/src/functions/delete-installation-request.ts new file mode 100644 index 00000000000..0cab8595ee0 --- /dev/null +++ b/packages-exp/installations-exp/src/functions/delete-installation-request.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AppConfig } from '../interfaces/installation-impl'; +import { RegisteredInstallationEntry } from '../interfaces/installation-entry'; +import { + getErrorFromResponse, + getHeadersWithAuth, + getInstallationsEndpoint, + retryIfServerError +} from './common'; + +export async function deleteInstallationRequest( + appConfig: AppConfig, + installationEntry: RegisteredInstallationEntry +): Promise { + const endpoint = getDeleteEndpoint(appConfig, installationEntry); + + const headers = getHeadersWithAuth(appConfig, installationEntry); + const request: RequestInit = { + method: 'DELETE', + headers + }; + + const response = await retryIfServerError(() => fetch(endpoint, request)); + if (!response.ok) { + throw await getErrorFromResponse('Delete Installation', response); + } +} + +function getDeleteEndpoint( + appConfig: AppConfig, + { fid }: RegisteredInstallationEntry +): string { + return `${getInstallationsEndpoint(appConfig)}/${fid}`; +} diff --git a/packages-exp/installations-exp/src/functions/generate-auth-token-request.test.ts b/packages-exp/installations-exp/src/functions/generate-auth-token-request.test.ts new file mode 100644 index 00000000000..cf1d4231ef4 --- /dev/null +++ b/packages-exp/installations-exp/src/functions/generate-auth-token-request.test.ts @@ -0,0 +1,153 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FirebaseError } from '@firebase/util'; +import { expect } from 'chai'; +import { SinonStub, stub } from 'sinon'; +import { GenerateAuthTokenResponse } from '../interfaces/api-response'; +import { + CompletedAuthToken, + RegisteredInstallationEntry, + RequestStatus +} from '../interfaces/installation-entry'; +import { compareHeaders } from '../testing/compare-headers'; +import { getFakeInstallations } from '../testing/fake-generators'; +import '../testing/setup'; +import { + INSTALLATIONS_API_URL, + INTERNAL_AUTH_VERSION, + PACKAGE_VERSION +} from '../util/constants'; +import { ErrorResponse } from './common'; +import { generateAuthTokenRequest } from './generate-auth-token-request'; +import { FirebaseInstallationsImpl } from '../interfaces/installation-impl'; + +const FID = 'evil-has-no-boundaries'; + +describe('generateAuthTokenRequest', () => { + let installations: FirebaseInstallationsImpl; + let fetchSpy: SinonStub<[RequestInfo, RequestInit?], Promise>; + let registeredInstallationEntry: RegisteredInstallationEntry; + let response: GenerateAuthTokenResponse; + + beforeEach(() => { + installations = getFakeInstallations(); + + registeredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + requestStatus: RequestStatus.NOT_STARTED + } + }; + + response = { + token: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', + expiresIn: '604800s' + }; + + fetchSpy = stub(self, 'fetch'); + }); + + describe('successful request', () => { + beforeEach(() => { + fetchSpy.resolves(new Response(JSON.stringify(response))); + }); + + it('fetches a new Authentication Token', async () => { + const completedAuthToken: CompletedAuthToken = await generateAuthTokenRequest( + installations, + registeredInstallationEntry + ); + expect(completedAuthToken.requestStatus).to.equal( + RequestStatus.COMPLETED + ); + }); + + it('calls the generateAuthToken server API with correct parameters', async () => { + const expectedHeaders = new Headers({ + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `${INTERNAL_AUTH_VERSION} refreshToken`, + 'x-goog-api-key': 'apiKey', + 'x-firebase-client': 'a/1.2.3 b/2.3.4' + }); + const expectedBody = { + installation: { + sdkVersion: PACKAGE_VERSION + } + }; + const expectedRequest: RequestInit = { + method: 'POST', + headers: expectedHeaders, + body: JSON.stringify(expectedBody) + }; + const expectedEndpoint = `${INSTALLATIONS_API_URL}/projects/projectId/installations/${FID}/authTokens:generate`; + + await generateAuthTokenRequest( + installations, + registeredInstallationEntry + ); + + expect(fetchSpy).to.be.calledOnceWith(expectedEndpoint, expectedRequest); + const actualHeaders = fetchSpy.lastCall.lastArg.headers; + compareHeaders(expectedHeaders, actualHeaders); + }); + }); + + describe('failed request', () => { + it('throws a FirebaseError with the error information from the server', async () => { + const errorResponse: ErrorResponse = { + error: { + code: 409, + message: 'Requested entity already exists', + status: 'ALREADY_EXISTS' + } + }; + + fetchSpy.resolves( + new Response(JSON.stringify(errorResponse), { status: 409 }) + ); + + await expect( + generateAuthTokenRequest(installations, registeredInstallationEntry) + ).to.be.rejectedWith(FirebaseError); + }); + + it('retries once if the server returns a 5xx error', async () => { + const errorResponse: ErrorResponse = { + error: { + code: 500, + message: 'Internal server error', + status: 'SERVER_ERROR' + } + }; + + fetchSpy + .onCall(0) + .resolves(new Response(JSON.stringify(errorResponse), { status: 500 })); + fetchSpy.onCall(1).resolves(new Response(JSON.stringify(response))); + + await expect( + generateAuthTokenRequest(installations, registeredInstallationEntry) + ).to.be.fulfilled; + expect(fetchSpy).to.be.calledTwice; + }); + }); +}); diff --git a/packages-exp/installations-exp/src/functions/generate-auth-token-request.ts b/packages-exp/installations-exp/src/functions/generate-auth-token-request.ts new file mode 100644 index 00000000000..d662745d300 --- /dev/null +++ b/packages-exp/installations-exp/src/functions/generate-auth-token-request.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { GenerateAuthTokenResponse } from '../interfaces/api-response'; +import { + CompletedAuthToken, + RegisteredInstallationEntry +} from '../interfaces/installation-entry'; +import { PACKAGE_VERSION } from '../util/constants'; +import { + extractAuthTokenInfoFromResponse, + getErrorFromResponse, + getHeadersWithAuth, + getInstallationsEndpoint, + retryIfServerError +} from './common'; +import { + FirebaseInstallationsImpl, + AppConfig +} from '../interfaces/installation-impl'; + +export async function generateAuthTokenRequest( + { appConfig, platformLoggerProvider }: FirebaseInstallationsImpl, + installationEntry: RegisteredInstallationEntry +): Promise { + const endpoint = getGenerateAuthTokenEndpoint(appConfig, installationEntry); + + const headers = getHeadersWithAuth(appConfig, installationEntry); + + // If platform logger exists, add the platform info string to the header. + const platformLogger = platformLoggerProvider.getImmediate({ + optional: true + }); + if (platformLogger) { + headers.append('x-firebase-client', platformLogger.getPlatformInfoString()); + } + + const body = { + installation: { + sdkVersion: PACKAGE_VERSION + } + }; + + const request: RequestInit = { + method: 'POST', + headers, + body: JSON.stringify(body) + }; + + const response = await retryIfServerError(() => fetch(endpoint, request)); + if (response.ok) { + const responseValue: GenerateAuthTokenResponse = await response.json(); + const completedAuthToken: CompletedAuthToken = extractAuthTokenInfoFromResponse( + responseValue + ); + return completedAuthToken; + } else { + throw await getErrorFromResponse('Generate Auth Token', response); + } +} + +function getGenerateAuthTokenEndpoint( + appConfig: AppConfig, + { fid }: RegisteredInstallationEntry +): string { + return `${getInstallationsEndpoint(appConfig)}/${fid}/authTokens:generate`; +} diff --git a/packages-exp/installations-exp/src/helpers/buffer-to-base64-url-safe.test.ts b/packages-exp/installations-exp/src/helpers/buffer-to-base64-url-safe.test.ts new file mode 100644 index 00000000000..18e56f436f2 --- /dev/null +++ b/packages-exp/installations-exp/src/helpers/buffer-to-base64-url-safe.test.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import '../testing/setup'; +import { bufferToBase64UrlSafe } from './buffer-to-base64-url-safe'; + +const str = 'hello world'; +const TYPED_ARRAY_REPRESENTATION = new Uint8Array(str.length); +for (let i = 0; i < str.length; i++) { + TYPED_ARRAY_REPRESENTATION[i] = str.charCodeAt(i); +} + +const BASE_64_REPRESENTATION = btoa(str); + +describe('bufferToBase64', () => { + it('returns a base64 representation of a Uint8Array', () => { + expect(bufferToBase64UrlSafe(TYPED_ARRAY_REPRESENTATION)).to.equal( + BASE_64_REPRESENTATION + ); + }); +}); diff --git a/packages-exp/installations-exp/src/helpers/buffer-to-base64-url-safe.ts b/packages-exp/installations-exp/src/helpers/buffer-to-base64-url-safe.ts new file mode 100644 index 00000000000..c336ce63528 --- /dev/null +++ b/packages-exp/installations-exp/src/helpers/buffer-to-base64-url-safe.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function bufferToBase64UrlSafe(array: Uint8Array): string { + const b64 = btoa(String.fromCharCode(...array)); + return b64.replace(/\+/g, '-').replace(/\//g, '_'); +} diff --git a/packages-exp/installations-exp/src/helpers/extract-app-config.test.ts b/packages-exp/installations-exp/src/helpers/extract-app-config.test.ts new file mode 100644 index 00000000000..06a7ec2dcb6 --- /dev/null +++ b/packages-exp/installations-exp/src/helpers/extract-app-config.test.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FirebaseError } from '@firebase/util'; +import { expect } from 'chai'; +import { AppConfig } from '../interfaces/installation-impl'; +import { getFakeApp } from '../testing/fake-generators'; +import '../testing/setup'; +import { extractAppConfig } from './extract-app-config'; + +describe('extractAppConfig', () => { + it('returns AppConfig if the argument is a FirebaseApp object that includes an appId', () => { + const firebaseApp = getFakeApp(); + const expected: AppConfig = { + appName: 'appName', + apiKey: 'apiKey', + projectId: 'projectId', + appId: '1:777777777777:web:d93b5ca1475efe57' + }; + expect(extractAppConfig(firebaseApp)).to.deep.equal(expected); + }); + + it('throws if a necessary value is missing', () => { + expect(() => extractAppConfig(undefined as any)).to.throw(FirebaseError); + + let firebaseApp = getFakeApp(); + delete (firebaseApp as any).name; + expect(() => extractAppConfig(firebaseApp)).to.throw(FirebaseError); + + firebaseApp = getFakeApp(); + delete (firebaseApp as any).options; + expect(() => extractAppConfig(firebaseApp)).to.throw(FirebaseError); + + firebaseApp = getFakeApp(); + delete firebaseApp.options.projectId; + expect(() => extractAppConfig(firebaseApp)).to.throw(FirebaseError); + + firebaseApp = getFakeApp(); + delete firebaseApp.options.apiKey; + expect(() => extractAppConfig(firebaseApp)).to.throw(FirebaseError); + + firebaseApp = getFakeApp(); + delete firebaseApp.options.appId; + expect(() => extractAppConfig(firebaseApp)).to.throw(FirebaseError); + }); +}); diff --git a/packages-exp/installations-exp/src/helpers/extract-app-config.ts b/packages-exp/installations-exp/src/helpers/extract-app-config.ts new file mode 100644 index 00000000000..e1390d4ab83 --- /dev/null +++ b/packages-exp/installations-exp/src/helpers/extract-app-config.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FirebaseApp, FirebaseOptions } from '@firebase/app-types-exp'; +import { FirebaseError } from '@firebase/util'; +import { AppConfig } from '../interfaces/installation-impl'; +import { ERROR_FACTORY, ErrorCode } from '../util/errors'; + +export function extractAppConfig(app: FirebaseApp): AppConfig { + if (!app || !app.options) { + throw getMissingValueError('App Configuration'); + } + + if (!app.name) { + throw getMissingValueError('App Name'); + } + + // Required app config keys + const configKeys: Array = [ + 'projectId', + 'apiKey', + 'appId' + ]; + + for (const keyName of configKeys) { + if (!app.options[keyName]) { + throw getMissingValueError(keyName); + } + } + + return { + appName: app.name, + projectId: app.options.projectId!, + apiKey: app.options.apiKey!, + appId: app.options.appId! + }; +} + +function getMissingValueError(valueName: string): FirebaseError { + return ERROR_FACTORY.create(ErrorCode.MISSING_APP_CONFIG_VALUES, { + valueName + }); +} diff --git a/packages-exp/installations-exp/src/helpers/fid-changed.test.ts b/packages-exp/installations-exp/src/helpers/fid-changed.test.ts new file mode 100644 index 00000000000..458bc1d097d --- /dev/null +++ b/packages-exp/installations-exp/src/helpers/fid-changed.test.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { stub } from 'sinon'; +import '../testing/setup'; +import { AppConfig } from '../interfaces/installation-impl'; +import { + fidChanged, + addCallback, + removeCallback +} from '../helpers/fid-changed'; +import { getFakeAppConfig } from '../testing/fake-generators'; + +const FID = 'evil-lies-in-every-man'; + +describe('onIdChange', () => { + describe('with single app', () => { + let appConfig: AppConfig; + + beforeEach(() => { + appConfig = getFakeAppConfig(); + }); + + it('calls the provided callback when FID changes', () => { + const stubFn = stub(); + addCallback(appConfig, stubFn); + + fidChanged(appConfig, FID); + + expect(stubFn).to.have.been.calledOnceWith(FID); + }); + + it('calls multiple callbacks', () => { + const stubA = stub(); + addCallback(appConfig, stubA); + const stubB = stub(); + addCallback(appConfig, stubB); + + fidChanged(appConfig, FID); + + expect(stubA).to.have.been.calledOnceWith(FID); + expect(stubB).to.have.been.calledOnceWith(FID); + }); + + it('does not call removed callbacks', () => { + const stubFn = stub(); + addCallback(appConfig, stubFn); + + removeCallback(appConfig, stubFn); + fidChanged(appConfig, FID); + + expect(stubFn).not.to.have.been.called; + }); + + it('does not throw when removeCallback is called multiple times', () => { + const stubFn = stub(); + addCallback(appConfig, stubFn); + + removeCallback(appConfig, stubFn); + removeCallback(appConfig, stubFn); + fidChanged(appConfig, FID); + + expect(stubFn).not.to.have.been.called; + }); + }); + + describe('with multiple apps', () => { + let appConfigA: AppConfig; + let appConfigB: AppConfig; + + beforeEach(() => { + appConfigA = getFakeAppConfig(); + appConfigB = getFakeAppConfig({ appName: 'differentAppName' }); + }); + + it('calls the correct callback when FID changes', () => { + const stubA = stub(); + addCallback(appConfigA, stubA); + const stubB = stub(); + addCallback(appConfigB, stubB); + + fidChanged(appConfigA, FID); + + expect(stubA).to.have.been.calledOnceWith(FID); + expect(stubB).not.to.have.been.called; + }); + }); +}); diff --git a/packages-exp/installations-exp/src/helpers/fid-changed.ts b/packages-exp/installations-exp/src/helpers/fid-changed.ts new file mode 100644 index 00000000000..63f04d75cea --- /dev/null +++ b/packages-exp/installations-exp/src/helpers/fid-changed.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getKey } from '../util/get-key'; +import { AppConfig } from '../interfaces/installation-impl'; +import { IdChangeCallbackFn } from '../api'; + +const fidChangeCallbacks: Map> = new Map(); + +/** + * Calls the onIdChange callbacks with the new FID value, and broadcasts the + * change to other tabs. + */ +export function fidChanged(appConfig: AppConfig, fid: string): void { + const key = getKey(appConfig); + + callFidChangeCallbacks(key, fid); + broadcastFidChange(key, fid); +} + +export function addCallback( + appConfig: AppConfig, + callback: IdChangeCallbackFn +): void { + // Open the broadcast channel if it's not already open, + // to be able to listen to change events from other tabs. + getBroadcastChannel(); + + const key = getKey(appConfig); + + let callbackSet = fidChangeCallbacks.get(key); + if (!callbackSet) { + callbackSet = new Set(); + fidChangeCallbacks.set(key, callbackSet); + } + callbackSet.add(callback); +} + +export function removeCallback( + appConfig: AppConfig, + callback: IdChangeCallbackFn +): void { + const key = getKey(appConfig); + + const callbackSet = fidChangeCallbacks.get(key); + + if (!callbackSet) { + return; + } + + callbackSet.delete(callback); + if (callbackSet.size === 0) { + fidChangeCallbacks.delete(key); + } + + // Close broadcast channel if there are no more callbacks. + closeBroadcastChannel(); +} + +function callFidChangeCallbacks(key: string, fid: string): void { + const callbacks = fidChangeCallbacks.get(key); + if (!callbacks) { + return; + } + + for (const callback of callbacks) { + callback(fid); + } +} + +function broadcastFidChange(key: string, fid: string): void { + const channel = getBroadcastChannel(); + if (channel) { + channel.postMessage({ key, fid }); + } + closeBroadcastChannel(); +} + +let broadcastChannel: BroadcastChannel | null = null; +/** Opens and returns a BroadcastChannel if it is supported by the browser. */ +function getBroadcastChannel(): BroadcastChannel | null { + if (!broadcastChannel && 'BroadcastChannel' in self) { + broadcastChannel = new BroadcastChannel('[Firebase] FID Change'); + broadcastChannel.onmessage = e => { + callFidChangeCallbacks(e.data.key, e.data.fid); + }; + } + return broadcastChannel; +} + +function closeBroadcastChannel(): void { + if (fidChangeCallbacks.size === 0 && broadcastChannel) { + broadcastChannel.close(); + broadcastChannel = null; + } +} diff --git a/packages-exp/installations-exp/src/helpers/generate-fid.test.ts b/packages-exp/installations-exp/src/helpers/generate-fid.test.ts new file mode 100644 index 00000000000..5d5e3414d10 --- /dev/null +++ b/packages-exp/installations-exp/src/helpers/generate-fid.test.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { stub } from 'sinon'; +import '../testing/setup'; +import { generateFid, VALID_FID_PATTERN } from './generate-fid'; + +/** A few random values to generate a FID from. */ +// prettier-ignore +const MOCK_RANDOM_VALUES = [ + [14, 107, 44, 183, 190, 84, 253, 45, 219, 233, 43, 190, 240, 152, 195, 222, 237], + [184, 251, 91, 157, 125, 225, 209, 15, 116, 66, 46, 113, 194, 126, 16, 13, 226], + [197, 123, 13, 142, 239, 129, 252, 139, 156, 36, 219, 192, 153, 52, 182, 231, 177], + [69, 154, 197, 91, 156, 196, 125, 111, 3, 67, 212, 132, 169, 11, 14, 254, 125], + [193, 102, 58, 19, 244, 69, 36, 135, 170, 106, 98, 216, 246, 209, 24, 155, 149], + [252, 59, 222, 160, 82, 160, 82, 186, 14, 172, 196, 114, 146, 191, 196, 194, 146], + [64, 147, 153, 236, 225, 142, 235, 109, 184, 249, 174, 127, 33, 238, 227, 172, 111], + [129, 137, 136, 120, 248, 206, 253, 78, 159, 201, 216, 15, 246, 80, 118, 185, 211], + [117, 150, 2, 180, 116, 230, 45, 188, 183, 43, 152, 100, 50, 255, 101, 175, 190], + [156, 129, 30, 101, 58, 137, 217, 249, 12, 227, 235, 80, 248, 81, 191, 2, 5], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255], +]; + +/** The FIDs that should be generated based on MOCK_RANDOM_VALUES. */ +const EXPECTED_FIDS = [ + 'fmsst75U_S3b6Su-8JjD3u', + 'ePtbnX3h0Q90Qi5xwn4QDe', + 'dXsNju-B_IucJNvAmTS257', + 'dZrFW5zEfW8DQ9SEqQsO_n', + 'cWY6E_RFJIeqamLY9tEYm5', + 'fDveoFKgUroOrMRykr_Ewp', + 'cJOZ7OGO6224-a5_Ie7jrG', + 'cYmIePjO_U6fydgP9lB2ud', + 'dZYCtHTmLby3K5hkMv9lr7', + 'fIEeZTqJ2fkM4-tQ-FG_Ag', + 'cAAAAAAAAAAAAAAAAAAAAA', + 'f_____________________' +]; + +describe('generateFid', () => { + it('deterministically generates FIDs based on crypto.getRandomValues', () => { + let randomValueIndex = 0; + stub(crypto, 'getRandomValues').callsFake(array => { + if (!(array instanceof Uint8Array)) { + throw new Error('what'); + } + const values = MOCK_RANDOM_VALUES[randomValueIndex++]; + for (let i = 0; i < array.length; i++) { + array[i] = values[i]; + } + return array; + }); + + for (const expectedFid of EXPECTED_FIDS) { + expect(generateFid()).to.deep.equal(expectedFid); + } + }); + + it('generates valid FIDs', () => { + for (let i = 0; i < 1000; i++) { + const fid = generateFid(); + expect(VALID_FID_PATTERN.test(fid)).to.equal( + true, + `${fid} is not a valid FID` + ); + } + }); + + it('generates FIDs where each character is equally likely to appear in each location', () => { + const numTries = 200000; + + const charOccurrencesMapList: Array> = new Array(22); + for (let i = 0; i < charOccurrencesMapList.length; i++) { + charOccurrencesMapList[i] = new Map(); + } + + for (let i = 0; i < numTries; i++) { + const fid = generateFid(); + + Array.from(fid).forEach((char, location) => { + const map = charOccurrencesMapList[location]; + map.set(char, (map.get(char) || 0) + 1); + }); + } + + for (let i = 0; i < charOccurrencesMapList.length; i++) { + const map = charOccurrencesMapList[i]; + if (i === 0) { + // In the first location only 4 characters (c, d, e, f) are valid. + expect(map.size).to.equal(4); + } else { + // In locations other than the first, all 64 characters are valid. + expect(map.size).to.equal(64); + } + + Array.from(map.entries()).forEach(([_, occurrence]) => { + const expectedOccurrence = numTries / map.size; + + // 10% margin of error + expect(occurrence).to.be.above(expectedOccurrence * 0.9); + expect(occurrence).to.be.below(expectedOccurrence * 1.1); + }); + } + }).timeout(30000); + + it('returns an empty string if FID generation fails', () => { + stub(crypto, 'getRandomValues').throws(); + + const fid = generateFid(); + expect(fid).to.equal(''); + }); +}); diff --git a/packages-exp/installations-exp/src/helpers/generate-fid.ts b/packages-exp/installations-exp/src/helpers/generate-fid.ts new file mode 100644 index 00000000000..5d87df04628 --- /dev/null +++ b/packages-exp/installations-exp/src/helpers/generate-fid.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { bufferToBase64UrlSafe } from './buffer-to-base64-url-safe'; + +export const VALID_FID_PATTERN = /^[cdef][\w-]{21}$/; +export const INVALID_FID = ''; + +/** + * Generates a new FID using random values from Web Crypto API. + * Returns an empty string if FID generation fails for any reason. + */ +export function generateFid(): string { + try { + // A valid FID has exactly 22 base64 characters, which is 132 bits, or 16.5 + // bytes. our implementation generates a 17 byte array instead. + const fidByteArray = new Uint8Array(17); + const crypto = + self.crypto || ((self as unknown) as { msCrypto: Crypto }).msCrypto; + crypto.getRandomValues(fidByteArray); + + // Replace the first 4 random bits with the constant FID header of 0b0111. + fidByteArray[0] = 0b01110000 + (fidByteArray[0] % 0b00010000); + + const fid = encode(fidByteArray); + + return VALID_FID_PATTERN.test(fid) ? fid : INVALID_FID; + } catch { + // FID generation errored + return INVALID_FID; + } +} + +/** Converts a FID Uint8Array to a base64 string representation. */ +function encode(fidByteArray: Uint8Array): string { + const b64String = bufferToBase64UrlSafe(fidByteArray); + + // Remove the 23rd character that was added because of the extra 4 bits at the + // end of our 17 byte array, and the '=' padding. + return b64String.substr(0, 22); +} diff --git a/packages-exp/installations-exp/src/helpers/get-installation-entry.test.ts b/packages-exp/installations-exp/src/helpers/get-installation-entry.test.ts new file mode 100644 index 00000000000..b19ec6ea037 --- /dev/null +++ b/packages-exp/installations-exp/src/helpers/get-installation-entry.test.ts @@ -0,0 +1,477 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AssertionError, expect } from 'chai'; +import { SinonFakeTimers, SinonStub, stub, useFakeTimers } from 'sinon'; +import * as createInstallationRequestModule from '../functions/create-installation-request'; +import { AppConfig } from '../interfaces/installation-impl'; +import { + InProgressInstallationEntry, + RegisteredInstallationEntry, + RequestStatus, + UnregisteredInstallationEntry +} from '../interfaces/installation-entry'; +import { getFakeAppConfig } from '../testing/fake-generators'; +import '../testing/setup'; +import { ERROR_FACTORY, ErrorCode } from '../util/errors'; +import { sleep } from '../util/sleep'; +import * as generateFidModule from './generate-fid'; +import { getInstallationEntry } from './get-installation-entry'; +import { get, set } from './idb-manager'; + +const FID = 'cry-of-the-black-birds'; + +describe('getInstallationEntry', () => { + let clock: SinonFakeTimers; + let appConfig: AppConfig; + let createInstallationRequestSpy: SinonStub< + [AppConfig, InProgressInstallationEntry], + Promise + >; + + beforeEach(() => { + clock = useFakeTimers({ now: 1_000_000 }); + appConfig = getFakeAppConfig(); + createInstallationRequestSpy = stub( + createInstallationRequestModule, + 'createInstallationRequest' + ).callsFake( + async (_, installationEntry): Promise => { + await sleep(500); // Request would take some time + const registeredInstallationEntry: RegisteredInstallationEntry = { + // Returns new FID if client FID is invalid. + fid: installationEntry.fid || FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now(), + token: 'token', + expiresIn: 1_000_000_000 + } + }; + return registeredInstallationEntry; + } + ); + }); + + afterEach(() => { + // Clean up all pending requests. + clock.runAll(); + }); + + it('saves the InstallationEntry in the database before returning it', async () => { + const oldDbEntry = await get(appConfig); + expect(oldDbEntry).to.be.undefined; + + const { installationEntry } = await getInstallationEntry(appConfig); + + const newDbEntry = await get(appConfig); + expect(newDbEntry).to.deep.equal(installationEntry); + }); + + it('saves the InstallationEntry in the database if app is offline', async () => { + stub(navigator, 'onLine').value(false); + + const oldDbEntry = await get(appConfig); + expect(oldDbEntry).to.be.undefined; + + const { installationEntry } = await getInstallationEntry(appConfig); + + const newDbEntry = await get(appConfig); + expect(newDbEntry).to.deep.equal(installationEntry); + }); + + it('saves the InstallationEntry in the database when registration completes', async () => { + const { + installationEntry, + registrationPromise + } = await getInstallationEntry(appConfig); + expect(installationEntry.registrationStatus).to.equal( + RequestStatus.IN_PROGRESS + ); + expect(registrationPromise).to.be.an.instanceOf(Promise); + + const oldDbEntry = await get(appConfig); + expect(oldDbEntry).to.deep.equal(installationEntry); + + clock.next(); // Finish registration request. + await expect(registrationPromise).to.be.fulfilled; + + const newDbEntry = await get(appConfig); + expect(newDbEntry!.registrationStatus).to.equal(RequestStatus.COMPLETED); + }); + + it('saves the InstallationEntry in the database when registration fails', async () => { + createInstallationRequestSpy.callsFake(async () => { + await sleep(500); // Request would take some time + throw ERROR_FACTORY.create(ErrorCode.REQUEST_FAILED, { + requestName: 'Create Installation', + serverCode: 500, + serverStatus: 'INTERNAL', + serverMessage: 'Internal server error.' + }); + }); + + const { + installationEntry, + registrationPromise + } = await getInstallationEntry(appConfig); + expect(installationEntry.registrationStatus).to.equal( + RequestStatus.IN_PROGRESS + ); + expect(registrationPromise).to.be.an.instanceOf(Promise); + + const oldDbEntry = await get(appConfig); + expect(oldDbEntry).to.deep.equal(installationEntry); + + clock.next(); // Finish registration request. + await expect(registrationPromise).to.be.rejected; + + const newDbEntry = await get(appConfig); + expect(newDbEntry!.registrationStatus).to.equal(RequestStatus.NOT_STARTED); + }); + + it('removes the InstallationEntry from the database when registration fails with 409', async () => { + createInstallationRequestSpy.callsFake(async () => { + await sleep(500); // Request would take some time + throw ERROR_FACTORY.create(ErrorCode.REQUEST_FAILED, { + requestName: 'Create Installation', + serverCode: 409, + serverStatus: 'INVALID_ARGUMENT', + serverMessage: 'FID can not be used.' + }); + }); + + const { + installationEntry, + registrationPromise + } = await getInstallationEntry(appConfig); + expect(installationEntry.registrationStatus).to.equal( + RequestStatus.IN_PROGRESS + ); + + const oldDbEntry = await get(appConfig); + expect(oldDbEntry).to.deep.equal(installationEntry); + + clock.next(); // Finish registration request. + await expect(registrationPromise).to.be.rejected; + + const newDbEntry = await get(appConfig); + expect(newDbEntry).to.be.undefined; + }); + + it('returns the same FID on subsequent calls', async () => { + const { installationEntry: entry1 } = await getInstallationEntry(appConfig); + const { installationEntry: entry2 } = await getInstallationEntry(appConfig); + expect(entry1.fid).to.equal(entry2.fid); + }); + + describe('when there is no InstallationEntry in database', () => { + let generateInstallationEntrySpy: SinonStub<[], string>; + + beforeEach(() => { + generateInstallationEntrySpy = stub( + generateFidModule, + 'generateFid' + ).returns(FID); + }); + + it('returns a new pending InstallationEntry and triggers createInstallation', async () => { + const { + installationEntry, + registrationPromise + } = await getInstallationEntry(appConfig); + + if (installationEntry.registrationStatus !== RequestStatus.IN_PROGRESS) { + throw new AssertionError('InstallationEntry is not IN_PROGRESS.'); + } + + expect(registrationPromise).to.be.an.instanceOf(Promise); + expect(installationEntry).to.deep.equal({ + fid: FID, + registrationStatus: RequestStatus.IN_PROGRESS, + + // https://github.com/chaijs/chai/issues/644 + registrationTime: installationEntry.registrationTime + }); + expect(generateInstallationEntrySpy).to.be.called; + expect(createInstallationRequestSpy).to.be.called; + }); + + it('returns a new unregistered InstallationEntry if app is offline', async () => { + stub(navigator, 'onLine').value(false); + + const { installationEntry } = await getInstallationEntry(appConfig); + + expect(installationEntry).to.deep.equal({ + fid: FID, + registrationStatus: RequestStatus.NOT_STARTED + }); + expect(generateInstallationEntrySpy).to.be.called; + expect(createInstallationRequestSpy).not.to.be.called; + }); + + it('does not trigger createInstallation REST call on subsequent calls', async () => { + await getInstallationEntry(appConfig); + await getInstallationEntry(appConfig); + + expect(createInstallationRequestSpy).to.be.calledOnce; + }); + + it('returns a registrationPromise on subsequent calls before initial promise resolves', async () => { + const { registrationPromise: promise1 } = await getInstallationEntry( + appConfig + ); + const { registrationPromise: promise2 } = await getInstallationEntry( + appConfig + ); + + expect(createInstallationRequestSpy).to.be.calledOnce; + expect(promise1).to.be.an.instanceOf(Promise); + expect(promise2).to.be.an.instanceOf(Promise); + }); + + it('does not return a registrationPromise on subsequent calls after initial promise resolves', async () => { + const { registrationPromise: promise1 } = await getInstallationEntry( + appConfig + ); + expect(promise1).to.be.an.instanceOf(Promise); + + clock.next(); // Finish registration request. + await expect(promise1).to.be.fulfilled; + + const { registrationPromise: promise2 } = await getInstallationEntry( + appConfig + ); + expect(promise2).to.be.undefined; + + expect(createInstallationRequestSpy).to.be.calledOnce; + }); + + it('waits for the FID from the server if FID generation fails', async () => { + clock.restore(); + clock = useFakeTimers({ + now: 1_000_000, + shouldAdvanceTime: true /* Needed to allow the createInstallation request to complete. */ + }); + + // FID generation fails. + generateInstallationEntrySpy.returns(generateFidModule.INVALID_FID); + + const getInstallationEntryPromise = getInstallationEntry(appConfig); + + const { + installationEntry, + registrationPromise + } = await getInstallationEntryPromise; + + expect(installationEntry.fid).to.equal(FID); + expect(registrationPromise).to.be.undefined; + }); + }); + + describe('when there is an unregistered InstallationEntry in the database', () => { + beforeEach(async () => { + const unregisteredInstallationEntry: UnregisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.NOT_STARTED + }; + await set(appConfig, unregisteredInstallationEntry); + }); + + it('returns a pending InstallationEntry and triggers createInstallation', async () => { + const { + installationEntry, + registrationPromise + } = await getInstallationEntry(appConfig); + + if (installationEntry.registrationStatus !== RequestStatus.IN_PROGRESS) { + throw new AssertionError('InstallationEntry is not IN_PROGRESS.'); + } + + expect(registrationPromise).to.be.an.instanceOf(Promise); + expect(installationEntry).to.deep.equal({ + fid: FID, + registrationStatus: RequestStatus.IN_PROGRESS, + // https://github.com/chaijs/chai/issues/644 + registrationTime: installationEntry.registrationTime + }); + expect(createInstallationRequestSpy).to.be.calledOnce; + }); + + it('returns the same InstallationEntry if the app is offline', async () => { + stub(navigator, 'onLine').value(false); + + const { installationEntry } = await getInstallationEntry(appConfig); + + expect(installationEntry).to.deep.equal({ + fid: FID, + registrationStatus: RequestStatus.NOT_STARTED + }); + expect(createInstallationRequestSpy).not.to.be.called; + }); + }); + + describe('when there is a pending InstallationEntry in the database', () => { + beforeEach(async () => { + const inProgressInstallationEntry: InProgressInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.IN_PROGRESS, + registrationTime: 1_000_000 + }; + await set(appConfig, inProgressInstallationEntry); + }); + + it("returns the same InstallationEntry if the request hasn't timed out", async () => { + clock.now = 1_001_000; // One second after the request was initiated. + + const { installationEntry } = await getInstallationEntry(appConfig); + + expect(installationEntry).to.deep.equal({ + fid: FID, + registrationStatus: RequestStatus.IN_PROGRESS, + registrationTime: 1_000_000 + }); + expect(createInstallationRequestSpy).not.to.be.called; + }); + + it('updates the InstallationEntry and triggers createInstallation if the request fails', async () => { + clock.restore(); + clock = useFakeTimers({ + now: 1_001_000 /* One second after the request was initiated. */, + shouldAdvanceTime: true /* Needed to allow the createInstallation request to complete. */ + }); + + const installationEntryPromise = getInstallationEntry(appConfig); + + // The pending request fails after a while. + clock.tick(3000); + await set(appConfig, { + fid: FID, + registrationStatus: RequestStatus.NOT_STARTED + }); + + const { registrationPromise } = await installationEntryPromise; + + // Let the new getInstallationEntry process start. + await sleep(250); + + const tokenDetails = (await get( + appConfig + )) as InProgressInstallationEntry; + expect(tokenDetails.registrationTime).to.be.at.least( + /* When the first pending request failed. */ 1_004_000 + ); + expect(tokenDetails).to.deep.equal({ + fid: FID, + registrationStatus: RequestStatus.IN_PROGRESS, + // Ignore registrationTime as we already checked it. + registrationTime: tokenDetails.registrationTime + }); + + expect(registrationPromise).to.be.an.instanceOf(Promise); + await registrationPromise; + expect(createInstallationRequestSpy).to.be.calledOnce; + }); + + it('updates the InstallationEntry if the request fails and the app is offline', async () => { + stub(navigator, 'onLine').value(false); + + clock.restore(); + clock = useFakeTimers({ + now: 1_001_000 /* One second after the request was initiated. */, + shouldAdvanceTime: true /* Needed to allow the createInstallation request to complete. */ + }); + + const installationEntryPromise = getInstallationEntry(appConfig); + + // The pending request fails after a while. + clock.tick(3000); + await set(appConfig, { + fid: FID, + registrationStatus: RequestStatus.NOT_STARTED + }); + + const { registrationPromise } = await installationEntryPromise; + + // Let the new getInstallationEntry process start. + await sleep(250); + + expect(await get(appConfig)).to.deep.equal({ + fid: FID, + registrationStatus: RequestStatus.NOT_STARTED + }); + + expect(registrationPromise).to.be.an.instanceOf(Promise); + await expect(registrationPromise).to.be.rejectedWith( + 'Application offline' + ); + expect(createInstallationRequestSpy).not.to.be.called; + }); + + it('returns a new pending InstallationEntry and triggers createInstallation if the request had already timed out', async () => { + clock.now = 1_015_000; // Fifteen seconds after the request was initiated. + + const { installationEntry } = await getInstallationEntry(appConfig); + + expect(installationEntry).to.deep.equal({ + fid: FID, + registrationStatus: RequestStatus.IN_PROGRESS, + registrationTime: 1_015_000 + }); + expect(createInstallationRequestSpy).to.be.calledOnce; + }); + + it('returns a new unregistered InstallationEntry if the request had already timed out and the app is offline', async () => { + stub(navigator, 'onLine').value(false); + clock.now = 1_015_000; // Fifteen seconds after the request was initiated. + + const { installationEntry } = await getInstallationEntry(appConfig); + + expect(installationEntry).to.deep.equal({ + fid: FID, + registrationStatus: RequestStatus.NOT_STARTED + }); + expect(createInstallationRequestSpy).not.to.be.called; + }); + }); + + describe('when there is a registered InstallationEntry in the database', () => { + beforeEach(async () => { + const registeredInstallationEntry: RegisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { requestStatus: RequestStatus.NOT_STARTED } + }; + await set(appConfig, registeredInstallationEntry); + }); + + it('returns the InstallationEntry from the database', async () => { + const { installationEntry } = await getInstallationEntry(appConfig); + + expect(installationEntry).to.deep.equal({ + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { requestStatus: RequestStatus.NOT_STARTED } + }); + expect(createInstallationRequestSpy).not.to.be.called; + }); + }); +}); diff --git a/packages-exp/installations-exp/src/helpers/get-installation-entry.ts b/packages-exp/installations-exp/src/helpers/get-installation-entry.ts new file mode 100644 index 00000000000..a0e1cc425ce --- /dev/null +++ b/packages-exp/installations-exp/src/helpers/get-installation-entry.ts @@ -0,0 +1,227 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createInstallationRequest } from '../functions/create-installation-request'; +import { AppConfig } from '../interfaces/installation-impl'; +import { + InProgressInstallationEntry, + InstallationEntry, + RegisteredInstallationEntry, + RequestStatus +} from '../interfaces/installation-entry'; +import { PENDING_TIMEOUT_MS } from '../util/constants'; +import { ERROR_FACTORY, ErrorCode, isServerError } from '../util/errors'; +import { sleep } from '../util/sleep'; +import { generateFid, INVALID_FID } from './generate-fid'; +import { remove, set, update } from './idb-manager'; + +export interface InstallationEntryWithRegistrationPromise { + installationEntry: InstallationEntry; + /** Exist iff the installationEntry is not registered. */ + registrationPromise?: Promise; +} + +/** + * Updates and returns the InstallationEntry from the database. + * Also triggers a registration request if it is necessary and possible. + */ +export async function getInstallationEntry( + appConfig: AppConfig +): Promise { + let registrationPromise: Promise | undefined; + + const installationEntry = await update(appConfig, oldEntry => { + const installationEntry = updateOrCreateInstallationEntry(oldEntry); + const entryWithPromise = triggerRegistrationIfNecessary( + appConfig, + installationEntry + ); + registrationPromise = entryWithPromise.registrationPromise; + return entryWithPromise.installationEntry; + }); + + if (installationEntry.fid === INVALID_FID) { + // FID generation failed. Waiting for the FID from the server. + return { installationEntry: await registrationPromise! }; + } + + return { + installationEntry, + registrationPromise + }; +} + +/** + * Creates a new Installation Entry if one does not exist. + * Also clears timed out pending requests. + */ +function updateOrCreateInstallationEntry( + oldEntry: InstallationEntry | undefined +): InstallationEntry { + const entry: InstallationEntry = oldEntry || { + fid: generateFid(), + registrationStatus: RequestStatus.NOT_STARTED + }; + + return clearTimedOutRequest(entry); +} + +/** + * If the Firebase Installation is not registered yet, this will trigger the + * registration and return an InProgressInstallationEntry. + * + * If registrationPromise does not exist, the installationEntry is guaranteed + * to be registered. + */ +function triggerRegistrationIfNecessary( + appConfig: AppConfig, + installationEntry: InstallationEntry +): InstallationEntryWithRegistrationPromise { + if (installationEntry.registrationStatus === RequestStatus.NOT_STARTED) { + if (!navigator.onLine) { + // Registration required but app is offline. + const registrationPromiseWithError = Promise.reject( + ERROR_FACTORY.create(ErrorCode.APP_OFFLINE) + ); + return { + installationEntry, + registrationPromise: registrationPromiseWithError + }; + } + + // Try registering. Change status to IN_PROGRESS. + const inProgressEntry: InProgressInstallationEntry = { + fid: installationEntry.fid, + registrationStatus: RequestStatus.IN_PROGRESS, + registrationTime: Date.now() + }; + const registrationPromise = registerInstallation( + appConfig, + inProgressEntry + ); + return { installationEntry: inProgressEntry, registrationPromise }; + } else if ( + installationEntry.registrationStatus === RequestStatus.IN_PROGRESS + ) { + return { + installationEntry, + registrationPromise: waitUntilFidRegistration(appConfig) + }; + } else { + return { installationEntry }; + } +} + +/** This will be executed only once for each new Firebase Installation. */ +async function registerInstallation( + appConfig: AppConfig, + installationEntry: InProgressInstallationEntry +): Promise { + try { + const registeredInstallationEntry = await createInstallationRequest( + appConfig, + installationEntry + ); + return set(appConfig, registeredInstallationEntry); + } catch (e) { + if (isServerError(e) && e.serverCode === 409) { + // Server returned a "FID can not be used" error. + // Generate a new ID next time. + await remove(appConfig); + } else { + // Registration failed. Set FID as not registered. + await set(appConfig, { + fid: installationEntry.fid, + registrationStatus: RequestStatus.NOT_STARTED + }); + } + throw e; + } +} + +/** Call if FID registration is pending in another request. */ +async function waitUntilFidRegistration( + appConfig: AppConfig +): Promise { + // Unfortunately, there is no way of reliably observing when a value in + // IndexedDB changes (yet, see https://github.com/WICG/indexed-db-observers), + // so we need to poll. + + let entry: InstallationEntry = await updateInstallationRequest(appConfig); + while (entry.registrationStatus === RequestStatus.IN_PROGRESS) { + // createInstallation request still in progress. + await sleep(100); + + entry = await updateInstallationRequest(appConfig); + } + + if (entry.registrationStatus === RequestStatus.NOT_STARTED) { + // The request timed out or failed in a different call. Try again. + const { + installationEntry, + registrationPromise + } = await getInstallationEntry(appConfig); + + if (registrationPromise) { + return registrationPromise; + } else { + // if there is no registrationPromise, entry is registered. + return installationEntry as RegisteredInstallationEntry; + } + } + + return entry; +} + +/** + * Called only if there is a CreateInstallation request in progress. + * + * Updates the InstallationEntry in the DB based on the status of the + * CreateInstallation request. + * + * Returns the updated InstallationEntry. + */ +function updateInstallationRequest( + appConfig: AppConfig +): Promise { + return update(appConfig, oldEntry => { + if (!oldEntry) { + throw ERROR_FACTORY.create(ErrorCode.INSTALLATION_NOT_FOUND); + } + return clearTimedOutRequest(oldEntry); + }); +} + +function clearTimedOutRequest(entry: InstallationEntry): InstallationEntry { + if (hasInstallationRequestTimedOut(entry)) { + return { + fid: entry.fid, + registrationStatus: RequestStatus.NOT_STARTED + }; + } + + return entry; +} + +function hasInstallationRequestTimedOut( + installationEntry: InstallationEntry +): boolean { + return ( + installationEntry.registrationStatus === RequestStatus.IN_PROGRESS && + installationEntry.registrationTime + PENDING_TIMEOUT_MS < Date.now() + ); +} diff --git a/packages-exp/installations-exp/src/helpers/idb-manager.test.ts b/packages-exp/installations-exp/src/helpers/idb-manager.test.ts new file mode 100644 index 00000000000..db7eaca58f9 --- /dev/null +++ b/packages-exp/installations-exp/src/helpers/idb-manager.test.ts @@ -0,0 +1,194 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { stub } from 'sinon'; +import { AppConfig } from '../interfaces/installation-impl'; +import { + InstallationEntry, + RequestStatus +} from '../interfaces/installation-entry'; +import { getFakeAppConfig } from '../testing/fake-generators'; +import '../testing/setup'; +import { clear, get, remove, set, update } from './idb-manager'; +import * as fidChangedModule from './fid-changed'; + +const VALUE_A: InstallationEntry = { + fid: 'VALUE_A', + registrationStatus: RequestStatus.NOT_STARTED +}; +const VALUE_B: InstallationEntry = { + fid: 'VALUE_B', + registrationStatus: RequestStatus.NOT_STARTED +}; + +describe('idb manager', () => { + let appConfig: AppConfig; + + beforeEach(() => { + appConfig = { ...getFakeAppConfig(), appName: 'appName1' }; + }); + + describe('get / set', () => { + it('sets a value and then gets the same value back', async () => { + await set(appConfig, VALUE_A); + const value = await get(appConfig); + expect(value).to.deep.equal(VALUE_A); + }); + + it('gets undefined for a key that does not exist', async () => { + const value = await get(appConfig); + expect(value).to.be.undefined; + }); + + it('sets and gets multiple values with different keys', async () => { + const appConfig2: AppConfig = { + ...getFakeAppConfig(), + appName: 'appName2' + }; + + await set(appConfig, VALUE_A); + await set(appConfig2, VALUE_B); + expect(await get(appConfig)).to.deep.equal(VALUE_A); + expect(await get(appConfig2)).to.deep.equal(VALUE_B); + }); + + it('overwrites a value', async () => { + await set(appConfig, VALUE_A); + await set(appConfig, VALUE_B); + expect(await get(appConfig)).to.deep.equal(VALUE_B); + }); + + it('calls fidChanged when a new FID is generated', async () => { + const fidChangedStub = stub(fidChangedModule, 'fidChanged'); + await set(appConfig, VALUE_A); + + expect(fidChangedStub).to.have.been.calledOnceWith( + appConfig, + VALUE_A.fid + ); + }); + + it('calls fidChanged when the FID changes', async () => { + await set(appConfig, VALUE_A); + + const fidChangedStub = stub(fidChangedModule, 'fidChanged'); + await set(appConfig, VALUE_B); + + expect(fidChangedStub).to.have.been.calledOnceWith( + appConfig, + VALUE_B.fid + ); + }); + + it('does not call fidChanged when the FID is the same', async () => { + await set(appConfig, VALUE_A); + + const fidChangedStub = stub(fidChangedModule, 'fidChanged'); + await set(appConfig, /* Same value */ VALUE_A); + + expect(fidChangedStub).not.to.have.been.called; + }); + }); + + describe('remove', () => { + it('deletes a key', async () => { + await set(appConfig, VALUE_A); + await remove(appConfig); + expect(await get(appConfig)).to.be.undefined; + }); + + it('does not throw if key does not exist', async () => { + await remove(appConfig); + expect(await get(appConfig)).to.be.undefined; + }); + }); + + describe('clear', () => { + it('deletes all keys', async () => { + const appConfig2: AppConfig = { + ...getFakeAppConfig(), + appName: 'appName2' + }; + + await set(appConfig, VALUE_A); + await set(appConfig2, VALUE_B); + await clear(); + expect(await get(appConfig)).to.be.undefined; + expect(await get(appConfig2)).to.be.undefined; + }); + }); + + describe('update', () => { + it('gets and sets a value atomically, returns the new value', async () => { + let isGetCalled = false; + + await set(appConfig, VALUE_A); + + const resultPromise = update(appConfig, oldValue => { + // get is already called for the same key, but it will only complete + // after update transaction finishes, at which point it will return the + // new value. + expect(isGetCalled).to.be.true; + + expect(oldValue).to.deep.equal(VALUE_A); + return VALUE_B; + }); + + // Called immediately after update, but before update completed. + const getPromise = get(appConfig); + isGetCalled = true; + + // Update returns the new value + expect(await resultPromise).to.deep.equal(VALUE_B); + + // If update weren't atomic, this would return the old value. + expect(await getPromise).to.deep.equal(VALUE_B); + }); + + it('calls fidChanged when a new FID is generated', async () => { + const fidChangedStub = stub(fidChangedModule, 'fidChanged'); + await update(appConfig, () => VALUE_A); + + expect(fidChangedStub).to.have.been.calledOnceWith( + appConfig, + VALUE_A.fid + ); + }); + + it('calls fidChanged when the FID changes', async () => { + await set(appConfig, VALUE_A); + + const fidChangedStub = stub(fidChangedModule, 'fidChanged'); + await update(appConfig, () => VALUE_B); + + expect(fidChangedStub).to.have.been.calledOnceWith( + appConfig, + VALUE_B.fid + ); + }); + + it('does not call fidChanged when the FID is the same', async () => { + await set(appConfig, VALUE_A); + + const fidChangedStub = stub(fidChangedModule, 'fidChanged'); + await update(appConfig, () => /* Same value */ VALUE_A); + + expect(fidChangedStub).not.to.have.been.called; + }); + }); +}); diff --git a/packages-exp/installations-exp/src/helpers/idb-manager.ts b/packages-exp/installations-exp/src/helpers/idb-manager.ts new file mode 100644 index 00000000000..bc30563fa06 --- /dev/null +++ b/packages-exp/installations-exp/src/helpers/idb-manager.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DB, openDb } from 'idb'; +import { AppConfig } from '../interfaces/installation-impl'; +import { InstallationEntry } from '../interfaces/installation-entry'; +import { getKey } from '../util/get-key'; +import { fidChanged } from './fid-changed'; + +const DATABASE_NAME = 'firebase-installations-database'; +const DATABASE_VERSION = 1; +const OBJECT_STORE_NAME = 'firebase-installations-store'; + +let dbPromise: Promise | null = null; +function getDbPromise(): Promise { + if (!dbPromise) { + dbPromise = openDb(DATABASE_NAME, DATABASE_VERSION, upgradeDB => { + // We don't use 'break' in this switch statement, the fall-through + // behavior is what we want, because if there are multiple versions between + // the old version and the current version, we want ALL the migrations + // that correspond to those versions to run, not only the last one. + // eslint-disable-next-line default-case + switch (upgradeDB.oldVersion) { + case 0: + upgradeDB.createObjectStore(OBJECT_STORE_NAME); + } + }); + } + return dbPromise; +} + +/** Gets record(s) from the objectStore that match the given key. */ +export async function get( + appConfig: AppConfig +): Promise { + const key = getKey(appConfig); + const db = await getDbPromise(); + return db + .transaction(OBJECT_STORE_NAME) + .objectStore(OBJECT_STORE_NAME) + .get(key); +} + +/** Assigns or overwrites the record for the given key with the given value. */ +export async function set( + appConfig: AppConfig, + value: ValueType +): Promise { + const key = getKey(appConfig); + const db = await getDbPromise(); + const tx = db.transaction(OBJECT_STORE_NAME, 'readwrite'); + const objectStore = tx.objectStore(OBJECT_STORE_NAME); + const oldValue = await objectStore.get(key); + await objectStore.put(value, key); + await tx.complete; + + if (!oldValue || oldValue.fid !== value.fid) { + fidChanged(appConfig, value.fid); + } + + return value; +} + +/** Removes record(s) from the objectStore that match the given key. */ +export async function remove(appConfig: AppConfig): Promise { + const key = getKey(appConfig); + const db = await getDbPromise(); + const tx = db.transaction(OBJECT_STORE_NAME, 'readwrite'); + await tx.objectStore(OBJECT_STORE_NAME).delete(key); + await tx.complete; +} + +/** + * Atomically updates a record with the result of updateFn, which gets + * called with the current value. If newValue is undefined, the record is + * deleted instead. + * @return Updated value + */ +export async function update( + appConfig: AppConfig, + updateFn: (previousValue: InstallationEntry | undefined) => ValueType +): Promise { + const key = getKey(appConfig); + const db = await getDbPromise(); + const tx = db.transaction(OBJECT_STORE_NAME, 'readwrite'); + const store = tx.objectStore(OBJECT_STORE_NAME); + const oldValue: InstallationEntry | undefined = await store.get(key); + const newValue = updateFn(oldValue); + + if (newValue === undefined) { + await store.delete(key); + } else { + await store.put(newValue, key); + } + await tx.complete; + + if (newValue && (!oldValue || oldValue.fid !== newValue.fid)) { + fidChanged(appConfig, newValue.fid); + } + + return newValue; +} + +export async function clear(): Promise { + const db = await getDbPromise(); + const tx = db.transaction(OBJECT_STORE_NAME, 'readwrite'); + await tx.objectStore(OBJECT_STORE_NAME).clear(); + await tx.complete; +} diff --git a/packages-exp/installations-exp/src/helpers/refresh-auth-token.test.ts b/packages-exp/installations-exp/src/helpers/refresh-auth-token.test.ts new file mode 100644 index 00000000000..3bdd859a6b0 --- /dev/null +++ b/packages-exp/installations-exp/src/helpers/refresh-auth-token.test.ts @@ -0,0 +1,208 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { SinonFakeTimers, SinonStub, stub, useFakeTimers } from 'sinon'; +import * as generateAuthTokenRequestModule from '../functions/generate-auth-token-request'; +import { + CompletedAuthToken, + RegisteredInstallationEntry, + RequestStatus, + UnregisteredInstallationEntry +} from '../interfaces/installation-entry'; +import { getFakeInstallations } from '../testing/fake-generators'; +import '../testing/setup'; +import { TOKEN_EXPIRATION_BUFFER } from '../util/constants'; +import { sleep } from '../util/sleep'; +import { get, set } from './idb-manager'; +import { refreshAuthToken } from './refresh-auth-token'; +import { FirebaseInstallationsImpl } from '../interfaces/installation-impl'; + +const FID = 'carry-the-blessed-home'; +const AUTH_TOKEN = 'authTokenFromServer'; +const DB_AUTH_TOKEN = 'authTokenFromDB'; +const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000; + +describe('refreshAuthToken', () => { + let installations: FirebaseInstallationsImpl; + let generateAuthTokenRequestSpy: SinonStub< + [FirebaseInstallationsImpl, RegisteredInstallationEntry], + Promise + >; + + beforeEach(() => { + installations = getFakeInstallations(); + + generateAuthTokenRequestSpy = stub( + generateAuthTokenRequestModule, + 'generateAuthTokenRequest' + ).callsFake(async () => { + await sleep(100); // Request would take some time + const result: CompletedAuthToken = { + token: AUTH_TOKEN, + expiresIn: ONE_WEEK_MS, + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now() + }; + return result; + }); + }); + + it('throws when there is no installation in the DB', async () => { + await expect(refreshAuthToken(installations)).to.be.rejected; + }); + + it('throws when there is an unregistered installation in the db', async () => { + const installationEntry: UnregisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.NOT_STARTED + }; + await set(installations.appConfig, installationEntry); + + await expect(refreshAuthToken(installations)).to.be.rejected; + }); + + describe('when there is a valid auth token in the DB', () => { + beforeEach(async () => { + const installationEntry: RegisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + token: AUTH_TOKEN, + expiresIn: ONE_WEEK_MS, + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now() + } + }; + await set(installations.appConfig, installationEntry); + }); + + it('returns the token from the DB', async () => { + const { token } = await refreshAuthToken(installations); + expect(token).to.equal(AUTH_TOKEN); + }); + + it('does not call any server APIs', async () => { + await refreshAuthToken(installations); + expect(generateAuthTokenRequestSpy).not.to.be.called; + }); + + it('works even if the app is offline', async () => { + stub(navigator, 'onLine').value(false); + + const { token } = await refreshAuthToken(installations); + expect(token).to.equal(AUTH_TOKEN); + }); + }); + + describe('when there is an auth token that is about to expire in the DB', () => { + let clock: SinonFakeTimers; + + beforeEach(async () => { + clock = useFakeTimers({ shouldAdvanceTime: true }); + + const installationEntry: RegisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + token: DB_AUTH_TOKEN, + expiresIn: ONE_WEEK_MS, + requestStatus: RequestStatus.COMPLETED, + creationTime: + // Expires in ten minutes + Date.now() - ONE_WEEK_MS + TOKEN_EXPIRATION_BUFFER + 10 * 60 * 1000 + } + }; + await set(installations.appConfig, installationEntry); + }); + + it('returns a different token after expiration', async () => { + const token1 = await refreshAuthToken(installations); + expect(token1.token).to.equal(DB_AUTH_TOKEN); + + // Wait 30 minutes. + clock.tick('30:00'); + + const token2 = await refreshAuthToken(installations); + await expect(token2.token).to.equal(AUTH_TOKEN); + await expect(token2.token).not.to.equal(DB_AUTH_TOKEN); + expect(generateAuthTokenRequestSpy).to.be.calledOnce; + }); + }); + + describe('when there is an expired auth token in the DB', () => { + beforeEach(async () => { + const installationEntry: RegisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + token: DB_AUTH_TOKEN, + expiresIn: ONE_WEEK_MS, + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now() - 2 * ONE_WEEK_MS + } + }; + await set(installations.appConfig, installationEntry); + }); + + it('does not call generateAuthToken twice on subsequent calls', async () => { + await refreshAuthToken(installations); + await refreshAuthToken(installations); + expect(generateAuthTokenRequestSpy).to.be.calledOnce; + }); + + it('does not call generateAuthToken twice on simultaneous calls', async () => { + await Promise.all([ + refreshAuthToken(installations), + refreshAuthToken(installations) + ]); + expect(generateAuthTokenRequestSpy).to.be.calledOnce; + }); + + it('returns a new token', async () => { + const { token } = await refreshAuthToken(installations); + await expect(token).to.equal(AUTH_TOKEN); + await expect(token).not.to.equal(DB_AUTH_TOKEN); + expect(generateAuthTokenRequestSpy).to.be.calledOnce; + }); + + it('throws if the app is offline', async () => { + stub(navigator, 'onLine').value(false); + + await expect(refreshAuthToken(installations)).to.be.rejected; + }); + + it('saves the new token in the DB', async () => { + const { token } = await refreshAuthToken(installations); + + const installationEntry = (await get( + installations.appConfig + )) as RegisteredInstallationEntry; + expect(installationEntry).not.to.be.undefined; + expect(installationEntry.registrationStatus).to.equal( + RequestStatus.COMPLETED + ); + + const authToken = installationEntry.authToken as CompletedAuthToken; + expect(authToken.requestStatus).to.equal(RequestStatus.COMPLETED); + expect(authToken.token).to.equal(token); + }); + }); +}); diff --git a/packages-exp/installations-exp/src/helpers/refresh-auth-token.ts b/packages-exp/installations-exp/src/helpers/refresh-auth-token.ts new file mode 100644 index 00000000000..1ad5dc5da50 --- /dev/null +++ b/packages-exp/installations-exp/src/helpers/refresh-auth-token.ts @@ -0,0 +1,211 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { generateAuthTokenRequest } from '../functions/generate-auth-token-request'; +import { + AppConfig, + FirebaseInstallationsImpl +} from '../interfaces/installation-impl'; +import { + AuthToken, + CompletedAuthToken, + InProgressAuthToken, + InstallationEntry, + RegisteredInstallationEntry, + RequestStatus +} from '../interfaces/installation-entry'; +import { PENDING_TIMEOUT_MS, TOKEN_EXPIRATION_BUFFER } from '../util/constants'; +import { ERROR_FACTORY, ErrorCode, isServerError } from '../util/errors'; +import { sleep } from '../util/sleep'; +import { remove, set, update } from './idb-manager'; + +/** + * Returns a valid authentication token for the installation. Generates a new + * token if one doesn't exist, is expired or about to expire. + * + * Should only be called if the Firebase Installation is registered. + */ +export async function refreshAuthToken( + installations: FirebaseInstallationsImpl, + forceRefresh = false +): Promise { + let tokenPromise: Promise | undefined; + const entry = await update(installations.appConfig, oldEntry => { + if (!isEntryRegistered(oldEntry)) { + throw ERROR_FACTORY.create(ErrorCode.NOT_REGISTERED); + } + + const oldAuthToken = oldEntry.authToken; + if (!forceRefresh && isAuthTokenValid(oldAuthToken)) { + // There is a valid token in the DB. + return oldEntry; + } else if (oldAuthToken.requestStatus === RequestStatus.IN_PROGRESS) { + // There already is a token request in progress. + tokenPromise = waitUntilAuthTokenRequest(installations, forceRefresh); + return oldEntry; + } else { + // No token or token expired. + if (!navigator.onLine) { + throw ERROR_FACTORY.create(ErrorCode.APP_OFFLINE); + } + + const inProgressEntry = makeAuthTokenRequestInProgressEntry(oldEntry); + tokenPromise = fetchAuthTokenFromServer(installations, inProgressEntry); + return inProgressEntry; + } + }); + + const authToken = tokenPromise + ? await tokenPromise + : (entry.authToken as CompletedAuthToken); + return authToken; +} + +/** + * Call only if FID is registered and Auth Token request is in progress. + * + * Waits until the current pending request finishes. If the request times out, + * tries once in this thread as well. + */ +async function waitUntilAuthTokenRequest( + installations: FirebaseInstallationsImpl, + forceRefresh: boolean +): Promise { + // Unfortunately, there is no way of reliably observing when a value in + // IndexedDB changes (yet, see https://github.com/WICG/indexed-db-observers), + // so we need to poll. + + let entry = await updateAuthTokenRequest(installations.appConfig); + while (entry.authToken.requestStatus === RequestStatus.IN_PROGRESS) { + // generateAuthToken still in progress. + await sleep(100); + + entry = await updateAuthTokenRequest(installations.appConfig); + } + + const authToken = entry.authToken; + if (authToken.requestStatus === RequestStatus.NOT_STARTED) { + // The request timed out or failed in a different call. Try again. + return refreshAuthToken(installations, forceRefresh); + } else { + return authToken; + } +} + +/** + * Called only if there is a GenerateAuthToken request in progress. + * + * Updates the InstallationEntry in the DB based on the status of the + * GenerateAuthToken request. + * + * Returns the updated InstallationEntry. + */ +function updateAuthTokenRequest( + appConfig: AppConfig +): Promise { + return update(appConfig, oldEntry => { + if (!isEntryRegistered(oldEntry)) { + throw ERROR_FACTORY.create(ErrorCode.NOT_REGISTERED); + } + + const oldAuthToken = oldEntry.authToken; + if (hasAuthTokenRequestTimedOut(oldAuthToken)) { + return { + ...oldEntry, + authToken: { requestStatus: RequestStatus.NOT_STARTED } + }; + } + + return oldEntry; + }); +} + +async function fetchAuthTokenFromServer( + installations: FirebaseInstallationsImpl, + installationEntry: RegisteredInstallationEntry +): Promise { + try { + const authToken = await generateAuthTokenRequest( + installations, + installationEntry + ); + const updatedInstallationEntry: RegisteredInstallationEntry = { + ...installationEntry, + authToken + }; + await set(installations.appConfig, updatedInstallationEntry); + return authToken; + } catch (e) { + if (isServerError(e) && (e.serverCode === 401 || e.serverCode === 404)) { + // Server returned a "FID not found" or a "Invalid authentication" error. + // Generate a new ID next time. + await remove(installations.appConfig); + } else { + const updatedInstallationEntry: RegisteredInstallationEntry = { + ...installationEntry, + authToken: { requestStatus: RequestStatus.NOT_STARTED } + }; + await set(installations.appConfig, updatedInstallationEntry); + } + throw e; + } +} + +function isEntryRegistered( + installationEntry: InstallationEntry | undefined +): installationEntry is RegisteredInstallationEntry { + return ( + installationEntry !== undefined && + installationEntry.registrationStatus === RequestStatus.COMPLETED + ); +} + +function isAuthTokenValid(authToken: AuthToken): boolean { + return ( + authToken.requestStatus === RequestStatus.COMPLETED && + !isAuthTokenExpired(authToken) + ); +} + +function isAuthTokenExpired(authToken: CompletedAuthToken): boolean { + const now = Date.now(); + return ( + now < authToken.creationTime || + authToken.creationTime + authToken.expiresIn < now + TOKEN_EXPIRATION_BUFFER + ); +} + +/** Returns an updated InstallationEntry with an InProgressAuthToken. */ +function makeAuthTokenRequestInProgressEntry( + oldEntry: RegisteredInstallationEntry +): RegisteredInstallationEntry { + const inProgressAuthToken: InProgressAuthToken = { + requestStatus: RequestStatus.IN_PROGRESS, + requestTime: Date.now() + }; + return { + ...oldEntry, + authToken: inProgressAuthToken + }; +} + +function hasAuthTokenRequestTimedOut(authToken: AuthToken): boolean { + return ( + authToken.requestStatus === RequestStatus.IN_PROGRESS && + authToken.requestTime + PENDING_TIMEOUT_MS < Date.now() + ); +} diff --git a/packages-exp/installations-exp/src/index.ts b/packages-exp/installations-exp/src/index.ts new file mode 100644 index 00000000000..98351b54056 --- /dev/null +++ b/packages-exp/installations-exp/src/index.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { registerInstallations } from './functions/config'; +import { registerVersion } from '@firebase/app-exp'; +import { name, version } from '../package.json'; + +export * from './api'; + +registerInstallations(); +registerVersion(name, version); diff --git a/packages-exp/installations-exp/src/interfaces/api-response.ts b/packages-exp/installations-exp/src/interfaces/api-response.ts new file mode 100644 index 00000000000..f7560a6925c --- /dev/null +++ b/packages-exp/installations-exp/src/interfaces/api-response.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface CreateInstallationResponse { + readonly refreshToken: string; + readonly authToken: GenerateAuthTokenResponse; + readonly fid?: string; +} + +export interface GenerateAuthTokenResponse { + readonly token: string; + + /** + * Encoded as a string with the suffix 's' (indicating seconds), preceded by + * the number of seconds. + * + * Example: "604800s". + */ + readonly expiresIn: string; +} diff --git a/packages-exp/installations-exp/src/interfaces/installation-entry.ts b/packages-exp/installations-exp/src/interfaces/installation-entry.ts new file mode 100644 index 00000000000..4b30aa2486f --- /dev/null +++ b/packages-exp/installations-exp/src/interfaces/installation-entry.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Status of a server request. */ +export const enum RequestStatus { + NOT_STARTED, + IN_PROGRESS, + COMPLETED +} + +export interface NotStartedAuthToken { + readonly requestStatus: RequestStatus.NOT_STARTED; +} + +export interface InProgressAuthToken { + readonly requestStatus: RequestStatus.IN_PROGRESS; + + /** + * Unix timestamp when the current generateAuthRequest was initiated. + * Used for figuring out how long the request status has been IN_PROGRESS. + */ + readonly requestTime: number; +} + +export interface CompletedAuthToken { + readonly requestStatus: RequestStatus.COMPLETED; + + /** + * Firebase Installations Authentication Token. + * Only exists if requestStatus is COMPLETED. + */ + readonly token: string; + + /** + * Unix timestamp when Authentication Token was created. + * Only exists if requestStatus is COMPLETED. + */ + readonly creationTime: number; + + /** + * Authentication Token time to live duration in milliseconds. + * Only exists if requestStatus is COMPLETED. + */ + readonly expiresIn: number; +} + +export type AuthToken = + | NotStartedAuthToken + | InProgressAuthToken + | CompletedAuthToken; + +export interface UnregisteredInstallationEntry { + /** Status of the Firebase Installation registration on the server. */ + readonly registrationStatus: RequestStatus.NOT_STARTED; + + /** Firebase Installation ID */ + readonly fid: string; +} + +export interface InProgressInstallationEntry { + /** Status of the Firebase Installation registration on the server. */ + readonly registrationStatus: RequestStatus.IN_PROGRESS; + + /** + * Unix timestamp that shows the time when the current createInstallation + * request was initiated. + * Used for figuring out how long the registration status has been PENDING. + */ + readonly registrationTime: number; + + /** Firebase Installation ID */ + readonly fid: string; +} + +export interface RegisteredInstallationEntry { + /** Status of the Firebase Installation registration on the server. */ + readonly registrationStatus: RequestStatus.COMPLETED; + + /** Firebase Installation ID */ + readonly fid: string; + + /** + * Refresh Token returned from the server. + * Used for authenticating generateAuthToken requests. + */ + readonly refreshToken: string; + + /** Firebase Installation Authentication Token. */ + readonly authToken: AuthToken; +} + +/** Firebase Installation ID and related data in the database. */ +export type InstallationEntry = + | UnregisteredInstallationEntry + | InProgressInstallationEntry + | RegisteredInstallationEntry; diff --git a/packages-exp/installations-exp/src/interfaces/installation-impl.ts b/packages-exp/installations-exp/src/interfaces/installation-impl.ts new file mode 100644 index 00000000000..280e51a7478 --- /dev/null +++ b/packages-exp/installations-exp/src/interfaces/installation-impl.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Provider } from '@firebase/component'; +import { _FirebaseService } from '@firebase/app-types-exp'; +import { FirebaseInstallations } from '@firebase/installations-types-exp'; + +export interface FirebaseInstallationsImpl + extends FirebaseInstallations, + _FirebaseService { + readonly appConfig: AppConfig; + readonly platformLoggerProvider: Provider<'platform-logger'>; +} + +export interface AppConfig { + readonly appName: string; + readonly projectId: string; + readonly apiKey: string; + readonly appId: string; +} diff --git a/packages-exp/installations-exp/src/testing/compare-headers.test.ts b/packages-exp/installations-exp/src/testing/compare-headers.test.ts new file mode 100644 index 00000000000..8bd6fb81203 --- /dev/null +++ b/packages-exp/installations-exp/src/testing/compare-headers.test.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AssertionError, expect } from 'chai'; +import '../testing/setup'; +import { compareHeaders } from './compare-headers'; + +describe('compareHeaders', () => { + it("doesn't fail if headers contain the same entries", () => { + const headers1 = new Headers({ a: '123', b: '456' }); + const headers2 = new Headers({ a: '123', b: '456' }); + compareHeaders(headers1, headers2); + }); + + it('fails if headers contain different keys', () => { + const headers1 = new Headers({ a: '123', b: '456', extraKey: '789' }); + const headers2 = new Headers({ a: '123', b: '456' }); + expect(() => { + compareHeaders(headers1, headers2); + }).to.throw(AssertionError); + }); + + it('fails if headers contain different values', () => { + const headers1 = new Headers({ a: '123', b: '456' }); + const headers2 = new Headers({ a: '123', b: 'differentValue' }); + expect(() => { + compareHeaders(headers1, headers2); + }).to.throw(AssertionError); + }); +}); diff --git a/packages-exp/installations-exp/src/testing/compare-headers.ts b/packages-exp/installations-exp/src/testing/compare-headers.ts new file mode 100644 index 00000000000..5b93c14933e --- /dev/null +++ b/packages-exp/installations-exp/src/testing/compare-headers.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AssertionError, expect } from 'chai'; + +// Trick TS since it's set to target ES5. +declare class HeadersWithEntries extends Headers { + entries?(): Iterable<[string, string]>; +} + +// Chai doesn't check if Headers objects contain the same entries, +// so we need to do that manually. +export function compareHeaders( + expectedHeaders: HeadersWithEntries, + actualHeaders: HeadersWithEntries +): void { + if ( + expectedHeaders.entries === undefined || + actualHeaders.entries === undefined + ) { + throw new AssertionError('Headers object does not have entries method'); + } + + const expected = new Map(Array.from(expectedHeaders.entries())); + const actual = new Map(Array.from(actualHeaders.entries())); + expect(actual).to.deep.equal(expected); +} diff --git a/packages-exp/installations-exp/src/testing/fake-generators.ts b/packages-exp/installations-exp/src/testing/fake-generators.ts new file mode 100644 index 00000000000..5d8a59a6fa6 --- /dev/null +++ b/packages-exp/installations-exp/src/testing/fake-generators.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FirebaseApp } from '@firebase/app-types-exp'; +import { + Component, + ComponentContainer, + ComponentType +} from '@firebase/component'; +import { extractAppConfig } from '../helpers/extract-app-config'; +import { + FirebaseInstallationsImpl, + AppConfig +} from '../interfaces/installation-impl'; + +export function getFakeApp(): FirebaseApp { + return { + name: 'appName', + options: { + apiKey: 'apiKey', + projectId: 'projectId', + authDomain: 'authDomain', + messagingSenderId: 'messagingSenderId', + databaseURL: 'databaseUrl', + storageBucket: 'storageBucket', + appId: '1:777777777777:web:d93b5ca1475efe57' + }, + automaticDataCollectionEnabled: true + }; +} + +export function getFakeAppConfig( + customValues: Partial = {} +): AppConfig { + return { ...extractAppConfig(getFakeApp()), ...customValues }; +} + +export function getFakeInstallations(): FirebaseInstallationsImpl { + const container = new ComponentContainer('test'); + container.addComponent( + new Component( + 'platform-logger', + () => ({ getPlatformInfoString: () => 'a/1.2.3 b/2.3.4' }), + ComponentType.PRIVATE + ) + ); + + return { + app: getFakeApp(), + appConfig: getFakeAppConfig(), + platformLoggerProvider: container.getProvider('platform-logger'), + _delete: () => { + return Promise.resolve(); + } + }; +} diff --git a/packages-exp/installations-exp/src/testing/setup.ts b/packages-exp/installations-exp/src/testing/setup.ts new file mode 100644 index 00000000000..3db746533e0 --- /dev/null +++ b/packages-exp/installations-exp/src/testing/setup.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { use } from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import { restore } from 'sinon'; +import * as sinonChai from 'sinon-chai'; +import { clear } from '../helpers/idb-manager'; + +use(chaiAsPromised); +use(sinonChai); + +afterEach(async () => { + restore(); + await clear(); +}); diff --git a/packages-exp/installations-exp/src/util/constants.ts b/packages-exp/installations-exp/src/util/constants.ts new file mode 100644 index 00000000000..c20fa260274 --- /dev/null +++ b/packages-exp/installations-exp/src/util/constants.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { version } from '../../package.json'; + +export const PENDING_TIMEOUT_MS = 10000; + +export const PACKAGE_VERSION = `w:${version}`; +export const INTERNAL_AUTH_VERSION = 'FIS_v2'; + +export const INSTALLATIONS_API_URL = + 'https://firebaseinstallations.googleapis.com/v1'; + +export const TOKEN_EXPIRATION_BUFFER = 60 * 60 * 1000; // One hour + +export const SERVICE = 'installations'; +export const SERVICE_NAME = 'Installations'; diff --git a/packages-exp/installations-exp/src/util/errors.ts b/packages-exp/installations-exp/src/util/errors.ts new file mode 100644 index 00000000000..6332ff65901 --- /dev/null +++ b/packages-exp/installations-exp/src/util/errors.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ErrorFactory, FirebaseError } from '@firebase/util'; +import { SERVICE, SERVICE_NAME } from './constants'; + +export const enum ErrorCode { + MISSING_APP_CONFIG_VALUES = 'missing-app-config-values', + NOT_REGISTERED = 'not-registered', + INSTALLATION_NOT_FOUND = 'installation-not-found', + REQUEST_FAILED = 'request-failed', + APP_OFFLINE = 'app-offline', + DELETE_PENDING_REGISTRATION = 'delete-pending-registration' +} + +const ERROR_DESCRIPTION_MAP: { readonly [key in ErrorCode]: string } = { + [ErrorCode.MISSING_APP_CONFIG_VALUES]: + 'Missing App configuration value: "{$valueName}"', + [ErrorCode.NOT_REGISTERED]: 'Firebase Installation is not registered.', + [ErrorCode.INSTALLATION_NOT_FOUND]: 'Firebase Installation not found.', + [ErrorCode.REQUEST_FAILED]: + '{$requestName} request failed with error "{$serverCode} {$serverStatus}: {$serverMessage}"', + [ErrorCode.APP_OFFLINE]: 'Could not process request. Application offline.', + [ErrorCode.DELETE_PENDING_REGISTRATION]: + "Can't delete installation while there is a pending registration request." +}; + +interface ErrorParams { + [ErrorCode.MISSING_APP_CONFIG_VALUES]: { + valueName: string; + }; + [ErrorCode.REQUEST_FAILED]: { + requestName: string; + [index: string]: string | number; // to make Typescript 3.8 happy + } & ServerErrorData; +} + +export const ERROR_FACTORY = new ErrorFactory( + SERVICE, + SERVICE_NAME, + ERROR_DESCRIPTION_MAP +); + +export interface ServerErrorData { + serverCode: number; + serverMessage: string; + serverStatus: string; +} + +export type ServerError = FirebaseError & ServerErrorData; + +/** Returns true if error is a FirebaseError that is based on an error from the server. */ +export function isServerError(error: unknown): error is ServerError { + return ( + error instanceof FirebaseError && + error.code.includes(ErrorCode.REQUEST_FAILED) + ); +} diff --git a/packages-exp/installations-exp/src/util/get-key.ts b/packages-exp/installations-exp/src/util/get-key.ts new file mode 100644 index 00000000000..272d342d366 --- /dev/null +++ b/packages-exp/installations-exp/src/util/get-key.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AppConfig } from '../interfaces/installation-impl'; + +/** Returns a string key that can be used to identify the app. */ +export function getKey(appConfig: AppConfig): string { + return `${appConfig.appName}!${appConfig.appId}`; +} diff --git a/packages-exp/installations-exp/src/util/sleep.test.ts b/packages-exp/installations-exp/src/util/sleep.test.ts new file mode 100644 index 00000000000..6dfc4b328ee --- /dev/null +++ b/packages-exp/installations-exp/src/util/sleep.test.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { SinonFakeTimers, useFakeTimers } from 'sinon'; +import '../testing/setup'; +import { sleep } from './sleep'; + +describe('sleep', () => { + let clock: SinonFakeTimers; + + beforeEach(() => { + clock = useFakeTimers({ shouldAdvanceTime: true }); + }); + + it('returns a promise that resolves after a given amount of time', async () => { + const t0 = clock.now; + await sleep(100); + const t1 = clock.now; + + expect(t1 - t0).to.equal(100); + }); +}); diff --git a/packages-exp/installations-exp/src/util/sleep.ts b/packages-exp/installations-exp/src/util/sleep.ts new file mode 100644 index 00000000000..2bd1eb9283b --- /dev/null +++ b/packages-exp/installations-exp/src/util/sleep.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Returns a promise that resolves after given time passes. */ +export function sleep(ms: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); +} diff --git a/packages-exp/installations-exp/test-app/.gitignore b/packages-exp/installations-exp/test-app/.gitignore new file mode 100644 index 00000000000..e706d63f780 --- /dev/null +++ b/packages-exp/installations-exp/test-app/.gitignore @@ -0,0 +1,2 @@ +sdk.js +sdk.js.map diff --git a/packages-exp/installations-exp/test-app/index.html b/packages-exp/installations-exp/test-app/index.html new file mode 100644 index 00000000000..f5e2958cea0 --- /dev/null +++ b/packages-exp/installations-exp/test-app/index.html @@ -0,0 +1,43 @@ + + + + + Test App + + + + +

+ + +

+

+ + +

+

+ + +

+

+ + +

+

+ + + + +

+

Requests

+
+

Database Contents

+
+ + + diff --git a/packages-exp/installations-exp/test-app/index.js b/packages-exp/installations-exp/test-app/index.js new file mode 100644 index 00000000000..d0101e3114e --- /dev/null +++ b/packages-exp/installations-exp/test-app/index.js @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const DATABASE_NAME = 'firebase-installations-database'; +const DATABASE_VERSION = 1; +const OBJECT_STORE_NAME = 'firebase-installations-store'; + +const requestLogs = []; +let db; + +window.indexedDB.open(DATABASE_NAME, DATABASE_VERSION).onsuccess = event => { + db = event.target.result; + setInterval(refreshDatabase, 1000); +}; + +function refreshDatabase() { + const request = db + .transaction(OBJECT_STORE_NAME, 'readwrite') + .objectStore(OBJECT_STORE_NAME) + .getAll(); + + request.onsuccess = () => { + const dbElement = getElement('database'); + dbElement.innerHTML = request.result + .map(v => `

${format(v)}

`) + .join(''); + }; +} + +function clearDb() { + const request = db + .transaction(OBJECT_STORE_NAME, 'readwrite') + .objectStore(OBJECT_STORE_NAME) + .clear(); + request.onsuccess = refreshDatabase; +} + +function getElement(id) { + const element = document.getElementById(id); + if (!element) { + throw new Error(`Element not found: ${id}`); + } + return element; +} + +function getInputValue(elementId) { + const element = getElement(elementId); + return element.value; +} + +function getId() { + printRequest('Get ID', FirebaseInstallations.getId(getApp())); +} + +function getToken() { + printRequest('Get Token', FirebaseInstallations.getToken(getApp())); +} + +function deleteInstallation() { + printRequest( + 'Delete Installation', + FirebaseInstallations.deleteInstallation(getApp()) + ); +} + +async function printRequest(requestInfo, promise) { + const requestsElement = getElement('requests'); + requestsElement.innerHTML = '

Loading...

' + requestLogs.join(''); + let result; + try { + const request = await promise; + result = request ? format(request) : 'Completed successfully'; + } catch (e) { + result = e.toString(); + } + requestLogs.unshift(`

${requestInfo}:
${result}

`); + requestsElement.innerHTML = requestLogs.join(''); +} + +function format(o) { + const escapedString = JSON.stringify(o, null, 2); + return `${escapedString}`; +} + +function getApp() { + const appName = getInputValue('appName'); + const projectId = getInputValue('projectId'); + const apiKey = getInputValue('apiKey'); + const appId = getInputValue('appId'); + return { + name: appName, + appConfig: { appName, projectId, apiKey, appId } + }; +} + +getElement('getId').onclick = getId; +getElement('getToken').onclick = getToken; +getElement('deleteInstallation').onclick = deleteInstallation; +getElement('clearDb').onclick = clearDb; diff --git a/packages-exp/installations-exp/test-app/rollup.config.js b/packages-exp/installations-exp/test-app/rollup.config.js new file mode 100644 index 00000000000..c5c39d4e239 --- /dev/null +++ b/packages-exp/installations-exp/test-app/rollup.config.js @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import typescriptPlugin from 'rollup-plugin-typescript2'; +import resolve from 'rollup-plugin-node-resolve'; +import commonjs from 'rollup-plugin-commonjs'; +import json from 'rollup-plugin-json'; +import { uglify } from 'rollup-plugin-uglify'; +import typescript from 'typescript'; + +/** + * Creates an iife build to run with the Test App. + */ +export default [ + { + input: 'src/api/index.ts', + output: { + name: 'FirebaseInstallations', + file: 'test-app/sdk.js', + format: 'iife', + sourcemap: true + }, + plugins: [ + typescriptPlugin({ + typescript, + tsconfigOverride: { compilerOptions: { declaration: false } } + }), + json(), + resolve(), + commonjs(), + uglify() + ] + } +]; diff --git a/packages-exp/installations-exp/tsconfig.json b/packages-exp/installations-exp/tsconfig.json new file mode 100644 index 00000000000..420eda97a1d --- /dev/null +++ b/packages-exp/installations-exp/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../config/tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "downlevelIteration": true, + "resolveJsonModule": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "exclude": ["dist/**/*"] +} diff --git a/packages-exp/installations-types-exp/api-extractor.json b/packages-exp/installations-types-exp/api-extractor.json new file mode 100644 index 00000000000..42f37a88c4b --- /dev/null +++ b/packages-exp/installations-types-exp/api-extractor.json @@ -0,0 +1,5 @@ +{ + "extends": "../../config/api-extractor.json", + // Point it to your entry point d.ts file. + "mainEntryPointFilePath": "/index.d.ts" +} \ No newline at end of file diff --git a/packages-exp/installations-types-exp/index.d.ts b/packages-exp/installations-types-exp/index.d.ts new file mode 100644 index 00000000000..fc784509307 --- /dev/null +++ b/packages-exp/installations-types-exp/index.d.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @public + */ +export interface FirebaseInstallations {} + +/** + * An interface for Firebase internal SDKs use only. + * + * @internal + */ +export interface _FirebaseInstallationsInternal { + /** + * Creates a Firebase Installation if there isn't one for the app and + * returns the Installation ID. + */ + getId(): Promise; + + /** + * Returns an Authentication Token for the current Firebase Installation. + */ + getToken(forceRefresh?: boolean): Promise; +} + +declare module '@firebase/component' { + interface NameServiceMapping { + 'installations-exp': FirebaseInstallations; + 'installations-exp-internal': _FirebaseInstallationsInternal; + } +} diff --git a/packages-exp/installations-types-exp/package.json b/packages-exp/installations-types-exp/package.json new file mode 100644 index 00000000000..95d69e2c00a --- /dev/null +++ b/packages-exp/installations-types-exp/package.json @@ -0,0 +1,33 @@ +{ + "name": "@firebase/installations-types-exp", + "private": true, + "version": "0.0.800", + "description": "@firebase/installations-exp Types", + "author": "Firebase (https://firebase.google.com/)", + "license": "Apache-2.0", + "scripts": { + "test": "tsc", + "test:ci": "node ../../scripts/run_tests_in_ci.js", + "api-report": "api-extractor run --local --verbose", + "predoc": "node ../../scripts/exp/remove-exp.js temp", + "doc": "api-documenter markdown --input temp --output docs", + "build:doc": "yarn api-report && yarn doc" + }, + "files": [ + "index.d.ts" + ], + "peerDependencies": { + "@firebase/app-types": "0.x" + }, + "repository": { + "directory": "packages/installations-types-exp", + "type": "git", + "url": "https://github.com/firebase/firebase-js-sdk.git" + }, + "bugs": { + "url": "https://github.com/firebase/firebase-js-sdk/issues" + }, + "devDependencies": { + "typescript": "4.0.2" + } +} \ No newline at end of file diff --git a/packages-exp/installations-types-exp/tsconfig.json b/packages-exp/installations-types-exp/tsconfig.json new file mode 100644 index 00000000000..9ec79aa816b --- /dev/null +++ b/packages-exp/installations-types-exp/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../installations-exp/tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "exclude": ["dist/**/*"] +} diff --git a/scripts/exp/remove-exp.js b/scripts/exp/remove-exp.js index 33937805906..cadcd5ab51b 100644 --- a/scripts/exp/remove-exp.js +++ b/scripts/exp/remove-exp.js @@ -29,7 +29,7 @@ if (argv._[0]) { if (statSync(dirOrFile).isFile()) { removeExpSuffixFromFile(dirOrFile); } else { - removeExpSuffix(dir); + removeExpSuffix(dirOrFile); } }