diff --git a/auth/auth.test.ts b/auth/auth.test.ts index 93ea56d..fa7d3d5 100644 --- a/auth/auth.test.ts +++ b/auth/auth.test.ts @@ -21,6 +21,7 @@ describe("#createUserWithEmailAndPassword()", () => { expect(auth.store.add).toHaveBeenCalledWith({ email: "foo@bar.com", password: "password", + providerId: firebase.auth.EmailAuthProvider.PROVIDER_ID, }); }); @@ -54,7 +55,7 @@ describe("#onAuthStateChange()", () => { expect(listener).toHaveBeenCalledWith(credential.user); }); - it("doesn't invokes the listener after it's disposed", async () => { + it("doesn't invoke the listener after it's disposed", async () => { const auth = new MockAuth(app); const listener = jest.fn(); const disposer = auth.onAuthStateChanged(listener); diff --git a/auth/auth.ts b/auth/auth.ts index 09ee64c..298fca8 100644 --- a/auth/auth.ts +++ b/auth/auth.ts @@ -4,6 +4,7 @@ import { SocialSignInMock } from "./social-signin-mock"; import { User } from "./user"; import { UserStore } from "./user-store"; import { AuthSettings } from "./auth-settings"; +import { UserCredential, UserCredentialOptions } from "./user-credential"; export type AuthStateChangeListener = (user: firebase.User | null) => void; @@ -13,7 +14,6 @@ export class MockAuth implements firebase.auth.Auth { public settings: firebase.auth.AuthSettings = new AuthSettings(); public tenantId: string | null; public readonly store = new UserStore(); - private readonly socialSignIns = new Set(); private readonly authStateEvents = new Set(); constructor(public readonly app: MockApp) {} @@ -38,8 +38,9 @@ export class MockAuth implements firebase.auth.Auth { throw new Error("auth/email-already-in-use"); } - const user = this.store.add({ email, password }); - return this.signIn(user); + const { providerId } = new firebase.auth.EmailAuthProvider(); + const user = this.store.add({ email, password, providerId }); + return this.signIn(user, { isNewUser: true }); } fetchProvidersForEmail(email: string): Promise { @@ -60,7 +61,7 @@ export class MockAuth implements firebase.auth.Auth { mockSocialSignIn(provider: firebase.auth.AuthProvider) { const mock = new SocialSignInMock(provider.providerId); - this.socialSignIns.add(mock); + this.store.addSocialMock(mock); return mock; } @@ -105,19 +106,26 @@ export class MockAuth implements firebase.auth.Auth { private signIn( user: User, - additionalUserInfo: firebase.auth.AdditionalUserInfo | null = null + options: UserCredentialOptions ): Promise { this.currentUser = user; this.authStateEvents.forEach((listener) => { listener(user); }); - return Promise.resolve({ - user, - additionalUserInfo, - credential: null, - operationType: "signIn", - }); + return Promise.resolve(new UserCredential(user, "signIn", options)); + } + + private async signInWithSocial(provider: firebase.auth.AuthProvider) { + const mockResponse = await this.store.consumeSocialMock(provider); + let user = this.store.findByProviderAndEmail(mockResponse.email, provider.providerId); + if (user) { + return this.signIn(user, { isNewUser: false }); + } + + // user didn't exist, so it's created and then signed in + user = this.store.add({ ...mockResponse, providerId: provider.providerId }); + return this.signIn(user, { isNewUser: true }); } signInAndRetrieveDataWithCredential(credential: firebase.auth.AuthCredential): Promise { @@ -126,11 +134,11 @@ export class MockAuth implements firebase.auth.Auth { signInAnonymously(): Promise { if (this.currentUser && this.currentUser.isAnonymous) { - return this.signIn(this.currentUser); + return this.signIn(this.currentUser, { isNewUser: false }); } const user = this.store.add({ isAnonymous: true }); - return this.signIn(user); + return this.signIn(user, { isNewUser: true }); } signInWithCredential(credential: firebase.auth.AuthCredential): Promise { @@ -152,7 +160,7 @@ export class MockAuth implements firebase.auth.Auth { return Promise.reject(new Error("auth/wrong-password")); } - return this.signIn(user); + return this.signIn(user, { isNewUser: false }); } signInWithEmailLink( @@ -169,42 +177,8 @@ export class MockAuth implements firebase.auth.Auth { throw new Error("Method not implemented."); } - async signInWithPopup( - provider: firebase.auth.AuthProvider - ): Promise { - const mock = Array.from(this.socialSignIns.values()).find( - (mock) => mock.type === provider.providerId - ); - - if (!mock) { - throw new Error("No mock response set."); - } - - // Mock is used, then it must go - this.socialSignIns.delete(mock); - - const data = await mock.response; - let user = this.store.findByEmail(data.email); - if (user) { - if (user.providerId !== provider.providerId) { - throw new Error("auth/account-exists-with-different-credential"); - } - - return this.signIn(user, { - isNewUser: false, - providerId: provider.providerId, - profile: null, - username: data.email, - }); - } - - user = this.store.add({ ...data, providerId: provider.providerId }); - return this.signIn(user, { - isNewUser: true, - providerId: provider.providerId, - profile: null, - username: data.email, - }); + signInWithPopup(provider: firebase.auth.AuthProvider): Promise { + return this.signInWithSocial(provider); } signInWithRedirect(provider: firebase.auth.AuthProvider): Promise { diff --git a/auth/user-credential.ts b/auth/user-credential.ts new file mode 100644 index 0000000..b794b8b --- /dev/null +++ b/auth/user-credential.ts @@ -0,0 +1,26 @@ +import { User } from "./user"; + +export interface UserCredentialOptions { + isNewUser: boolean; +} + +export class UserCredential implements firebase.auth.UserCredential { + readonly additionalUserInfo: firebase.auth.AdditionalUserInfo | null = null; + readonly credential = null; + + constructor( + readonly user: User, + readonly operationType: "signIn" | "link" | "reauthenticate", + { isNewUser }: UserCredentialOptions + ) { + if (!user.isAnonymous) { + this.additionalUserInfo = { + isNewUser, + profile: null, + // providerId should be at the right value in the user object already. + providerId: user.providerId, + username: user.email, + }; + } + } +} diff --git a/auth/user-store.ts b/auth/user-store.ts index b5cb879..af01c23 100644 --- a/auth/user-store.ts +++ b/auth/user-store.ts @@ -1,27 +1,75 @@ -import { User, UserSchema } from "./user"; +import { User, UserSchema, UserInfo } from "./user"; +import { SocialSignInMock } from "./social-signin-mock"; export class UserStore { private nextId = 0; - private idStore = new Map(); - private emailStore = new Map(); + private store = new Map(); + private readonly socialMocks: SocialSignInMock[] = []; + /** + * Adds a user to this store with the given data. + * The new user is assigned a new ID and is returned. + */ add(data: Partial): User { const uid = ++this.nextId + ""; - const user = new User({ ...data, uid }, this); + const user = new User( + { + ...data, + // If the user is being created with a provider ID, then its data is coming from that provider. + providerData: data.providerId ? [new UserInfo(uid, data.providerId, data)] : [], + uid, + }, + this + ); const schema = user.toJSON() as UserSchema; - this.idStore.set(schema.uid, schema); - schema.email && this.emailStore.set(schema.email, schema); + this.store.set(schema.uid, schema); return user; } + addSocialMock(mock: SocialSignInMock) { + this.socialMocks.push(mock); + } + + /** + * Finds the mock response for a given AuthProvider, removes it from the list of mocks + * and returns its response. + * + * If a mock for that AuthProvider is not set, then this method throws. + */ + consumeSocialMock(provider: firebase.auth.AuthProvider) { + const index = this.socialMocks.findIndex((mock) => mock.type === provider.providerId); + if (index === -1) { + throw new Error("No mock response set."); + } + + // Mock is used, then it must go + const mock = this.socialMocks[index]; + this.socialMocks.splice(index, 1); + + return mock.response; + } + findByEmail(email: string): User | undefined { - const schema = this.emailStore.get(email); - return schema ? new User(schema, this) : undefined; + const schema = [...this.store.values()].find((user) => user.email === email); + return schema && new User(schema, this); + } + + findByProviderAndEmail(email: string, providerId: string): User | undefined { + const user = this.findByEmail(email); + if (!user) { + return undefined; + } + + if (user.providerData.some((info) => info.providerId === providerId)) { + return new User({ ...user.toJSON(), providerId }, this); + } + + throw new Error("auth/account-exists-with-different-credential"); } update(id: string, data: Partial) { - const schema = this.idStore.get(id); + const schema = this.store.get(id); if (!schema) { return; } diff --git a/auth/user.ts b/auth/user.ts index 0f1e06d..03de5a4 100644 --- a/auth/user.ts +++ b/auth/user.ts @@ -7,7 +7,7 @@ export interface UserSchema { metadata: firebase.auth.UserMetadata; password?: string; phoneNumber: string | null; - providerData: (firebase.UserInfo | null)[]; + providerData: UserInfo[]; refreshToken: string; displayName: string | null; email: string | null; @@ -17,7 +17,26 @@ export interface UserSchema { tenantId: string | null; } +export class UserInfo implements firebase.UserInfo { + readonly displayName: string | null; + readonly email: string | null; + readonly phoneNumber: string | null; + readonly photoURL: string | null; + + constructor( + readonly uid: string, + readonly providerId: string, + rest: Partial> + ) { + this.displayName = rest.displayName ?? null; + this.email = rest.email ?? null; + this.phoneNumber = rest.phoneNumber ?? null; + this.photoURL = rest.photoURL ?? null; + } +} + export class User implements firebase.User, UserSchema { + readonly uid: string; displayName: string | null; email: string | null; emailVerified: boolean; @@ -26,11 +45,10 @@ export class User implements firebase.User, UserSchema { password?: string; phoneNumber: string | null; photoURL: string | null; - providerData: (firebase.UserInfo | null)[]; + providerData: UserInfo[]; providerId: string; refreshToken: string; tenantId: string | null; - uid: string; multiFactor: firebase.User.MultiFactorUser; constructor(data: Partial, private readonly store: UserStore) {