Fix app not rendering with bad auth storage states (#597)
* render even if session store throws * handle after timeout * remove localstorage key * update tests
This commit is contained in:
		
							parent
							
								
									d15e6c8ad8
								
							
						
					
					
						commit
						9532f48515
					
				| @ -1,33 +1,18 @@ | ||||
| import { spy, stub, mock, restore } from "sinon"; | ||||
| import { fixture, expect } from "@open-wc/testing"; | ||||
| // import { expect } from "@esm-bundle/chai";
 | ||||
| 
 | ||||
| import AuthService from "./utils/AuthService"; | ||||
| import { App } from "./index"; | ||||
| 
 | ||||
| describe("browsertrix-app", () => { | ||||
|   beforeEach(() => { | ||||
|     stub(window.sessionStorage, "setItem"); | ||||
|     stub(App.prototype, "getUserInfo").callsFake(() => | ||||
|       Promise.resolve({ | ||||
|         id: "test_id", | ||||
|         email: "test-user@example.com", | ||||
|         name: "Test User", | ||||
|         is_verified: false, | ||||
|         is_superuser: false, | ||||
|         orgs: [ | ||||
|           { | ||||
|             id: "test_org_id", | ||||
|             name: "test org", | ||||
|             role: 10, | ||||
|             email: "test@org.org", | ||||
|           }, | ||||
|         ], | ||||
|       }) | ||||
|     ); | ||||
|     AuthService.broadcastChannel = new BroadcastChannel(AuthService.storageKey); | ||||
|     window.sessionStorage.clear(); | ||||
|     stub(window.history, "pushState"); | ||||
|   }); | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|     AuthService.broadcastChannel.close(); | ||||
|     restore(); | ||||
|   }); | ||||
| 
 | ||||
| @ -36,6 +21,24 @@ describe("browsertrix-app", () => { | ||||
|     expect(el).instanceOf(App); | ||||
|   }); | ||||
| 
 | ||||
|   it("renders home when authenticated", async () => { | ||||
|     stub(AuthService, "initSessionStorage").returns( | ||||
|       Promise.resolve({ | ||||
|         headers: { Authorization: "_fake_headers_" }, | ||||
|         tokenExpiresAt: 0, | ||||
|         username: "test-auth@example.com", | ||||
|       }) | ||||
|     ); | ||||
|     const el = await fixture("<browsertrix-app></browsertrix-app>"); | ||||
|     expect(el).lightDom.descendants("btrix-home"); | ||||
|   }); | ||||
| 
 | ||||
|   it("renders when `AuthService.initSessionStorage` rejects", async () => { | ||||
|     stub(AuthService, "initSessionStorage").returns(Promise.reject()); | ||||
|     const el = await fixture("<browsertrix-app></browsertrix-app>"); | ||||
|     expect(el).lightDom.descendants("btrix-home"); | ||||
|   }); | ||||
| 
 | ||||
|   // TODO move tests to AuthService
 | ||||
|   it("sets auth state from session storage", async () => { | ||||
|     stub(AuthService.prototype, "startFreshnessCheck"); | ||||
| @ -58,6 +61,23 @@ describe("browsertrix-app", () => { | ||||
|   }); | ||||
| 
 | ||||
|   it("sets user info", async () => { | ||||
|     stub(App.prototype, "getUserInfo").callsFake(() => | ||||
|       Promise.resolve({ | ||||
|         id: "test_id", | ||||
|         email: "test-user@example.com", | ||||
|         name: "Test User", | ||||
|         is_verified: false, | ||||
|         is_superuser: false, | ||||
|         orgs: [ | ||||
|           { | ||||
|             id: "test_org_id", | ||||
|             name: "test org", | ||||
|             role: 10, | ||||
|             email: "test@org.org", | ||||
|           }, | ||||
|         ], | ||||
|       }) | ||||
|     ); | ||||
|     stub(AuthService.prototype, "startFreshnessCheck"); | ||||
|     stub(window.sessionStorage, "getItem").callsFake((key) => { | ||||
|       if (key === "btrix.auth") | ||||
|  | ||||
| @ -12,7 +12,7 @@ import type { OrgTab } from "./pages/org"; | ||||
| import type { NotifyEvent, NavigateEvent } from "./utils/LiteElement"; | ||||
| import LiteElement, { html } from "./utils/LiteElement"; | ||||
| import APIRouter from "./utils/APIRouter"; | ||||
| import AuthService from "./utils/AuthService"; | ||||
| import AuthService, { AuthState } from "./utils/AuthService"; | ||||
| import type { LoggedInEvent } from "./utils/AuthService"; | ||||
| import type { ViewState } from "./utils/APIRouter"; | ||||
| import type { CurrentUser, UserOrg } from "./types/user"; | ||||
| @ -91,7 +91,12 @@ export class App extends LiteElement { | ||||
|   private selectedOrgId?: string; | ||||
| 
 | ||||
|   async connectedCallback() { | ||||
|     const authState = await AuthService.initSessionStorage(); | ||||
|     let authState: AuthState = null; | ||||
|     try { | ||||
|       authState = await AuthService.initSessionStorage(); | ||||
|     } catch (e: any) { | ||||
|       console.debug(e); | ||||
|     } | ||||
|     this.syncViewState(); | ||||
|     if (this.viewState.route === "org") { | ||||
|       this.selectedOrgId = this.viewState.params.orgId; | ||||
|  | ||||
							
								
								
									
										61
									
								
								frontend/src/utils/AuthService.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								frontend/src/utils/AuthService.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,61 @@ | ||||
| import { spy, stub, mock, restore } from "sinon"; | ||||
| import { fixture, expect } from "@open-wc/testing"; | ||||
| 
 | ||||
| import AuthService from "./AuthService"; | ||||
| 
 | ||||
| describe("AuthService", () => { | ||||
|   beforeEach(() => { | ||||
|     AuthService.broadcastChannel = new BroadcastChannel(AuthService.storageKey); | ||||
|     window.sessionStorage.clear(); | ||||
|     stub(window.history, "pushState"); | ||||
|   }); | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|     AuthService.broadcastChannel.close(); | ||||
|     restore(); | ||||
|   }); | ||||
| 
 | ||||
|   describe("#initSessionStorage", () => { | ||||
|     it("returns auth in session storage", async () => { | ||||
|       stub(window.sessionStorage, "getItem").returns( | ||||
|         JSON.stringify({ | ||||
|           headers: { Authorization: "_fake_headers_" }, | ||||
|           tokenExpiresAt: "_fake_tokenExpiresAt_", | ||||
|           username: "test-auth@example.com", | ||||
|         }) | ||||
|       ); | ||||
|       const result = await AuthService.initSessionStorage(); | ||||
|       expect(result).to.deep.equal({ | ||||
|         headers: { Authorization: "_fake_headers_" }, | ||||
|         tokenExpiresAt: "_fake_tokenExpiresAt_", | ||||
|         username: "test-auth@example.com", | ||||
|       }); | ||||
|     }); | ||||
|     it("returns auth from another tab", async () => { | ||||
|       stub(window.sessionStorage, "getItem"); | ||||
|       const otherTabChannel = new BroadcastChannel(AuthService.storageKey); | ||||
|       otherTabChannel.addEventListener("message", () => { | ||||
|         otherTabChannel.postMessage({ | ||||
|           name: "responding_auth", | ||||
|           auth: { | ||||
|             headers: { Authorization: "_fake_headers_from_tab_" }, | ||||
|             tokenExpiresAt: "_fake_tokenExpiresAt_from_tab_", | ||||
|             username: "test-auth@example.com_from_tab_", | ||||
|           }, | ||||
|         }); | ||||
|       }); | ||||
|       const result = await AuthService.initSessionStorage(); | ||||
|       expect(result).to.deep.equal({ | ||||
|         headers: { Authorization: "_fake_headers_from_tab_" }, | ||||
|         tokenExpiresAt: "_fake_tokenExpiresAt_from_tab_", | ||||
|         username: "test-auth@example.com_from_tab_", | ||||
|       }); | ||||
|       otherTabChannel.close(); | ||||
|     }); | ||||
|     it("resolves without stored auth or another tab", async () => { | ||||
|       stub(window.sessionStorage, "getItem"); | ||||
|       const result = await AuthService.initSessionStorage(); | ||||
|       expect(result).to.equal(null); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
| @ -27,10 +27,6 @@ export interface LoggedInEvent<T = LoggedInEventDetail> extends CustomEvent { | ||||
|   readonly detail: T; | ||||
| } | ||||
| 
 | ||||
| type HasAuthStorageData = { | ||||
|   auth: boolean; | ||||
| }; | ||||
| 
 | ||||
| type AuthRequestEventData = { | ||||
|   name: "requesting_auth"; | ||||
| }; | ||||
| @ -171,10 +167,6 @@ export default class AuthService { | ||||
|       } | ||||
|     ); | ||||
| 
 | ||||
|     window.addEventListener("beforeunload", () => { | ||||
|       window.localStorage.removeItem(AuthService.storageKey); | ||||
|     }); | ||||
| 
 | ||||
|     return authState; | ||||
|   } | ||||
| 
 | ||||
| @ -192,26 +184,43 @@ export default class AuthService { | ||||
|    * Retrieve shared session from another tab/window | ||||
|    **/ | ||||
|   private static async getSharedSessionAuth(): Promise<AuthState> { | ||||
|     return new Promise((resolve) => { | ||||
|     const broadcastPromise = new Promise((resolve) => { | ||||
|       // Check if there's any authenticated tabs
 | ||||
|       const value = window.localStorage.getItem(AuthService.storageKey); | ||||
|       if (value && (JSON.parse(value) as HasAuthStorageData).auth) { | ||||
|         // Ask for auth
 | ||||
|         AuthService.broadcastChannel.postMessage(<AuthRequestEventData>{ | ||||
|           name: "requesting_auth", | ||||
|         }); | ||||
|         // Wait for another tab to respond
 | ||||
|         const cb = ({ data }: any) => { | ||||
|           if (data.name === "responding_auth") { | ||||
|             AuthService.broadcastChannel.removeEventListener("message", cb); | ||||
|             resolve(data.auth); | ||||
|           } | ||||
|         }; | ||||
|         AuthService.broadcastChannel.addEventListener("message", cb); | ||||
|       } else { | ||||
|         resolve(null); | ||||
|       } | ||||
|       AuthService.broadcastChannel.postMessage(<AuthRequestEventData>{ | ||||
|         name: "requesting_auth", | ||||
|       }); | ||||
|       // Wait for another tab to respond
 | ||||
|       const cb = ({ data }: any) => { | ||||
|         if (data.name === "responding_auth") { | ||||
|           AuthService.broadcastChannel.removeEventListener("message", cb); | ||||
|           resolve(data.auth); | ||||
|         } | ||||
|       }; | ||||
|       AuthService.broadcastChannel.addEventListener("message", cb); | ||||
|     }); | ||||
|     // Ensure that `getSharedSessionAuth` is resolved within a reasonable
 | ||||
|     // timeframe, even if another window/tab doesn't respond:
 | ||||
|     const timeoutPromise = new Promise((resolve) => { | ||||
|       window.setTimeout(() => { | ||||
|         resolve(null); | ||||
|       }, 10); | ||||
|     }); | ||||
| 
 | ||||
|     return Promise.race([broadcastPromise, timeoutPromise]).then( | ||||
|       (value: any) => { | ||||
|         try { | ||||
|           if (value.username && value.headers && value.tokenExpiresAt) { | ||||
|             return value; | ||||
|           } | ||||
|         } catch { | ||||
|           return null; | ||||
|         } | ||||
|       }, | ||||
|       (error) => { | ||||
|         console.debug(error); | ||||
|         return null; | ||||
|       } | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   constructor() { | ||||
| @ -227,16 +236,11 @@ export default class AuthService { | ||||
|   } | ||||
| 
 | ||||
|   saveLogin(auth: Auth) { | ||||
|     window.localStorage.setItem( | ||||
|       AuthService.storageKey, | ||||
|       JSON.stringify(<HasAuthStorageData>{ auth: true }) | ||||
|     ); | ||||
|     this.persist(auth); | ||||
|     this.startFreshnessCheck(); | ||||
|   } | ||||
| 
 | ||||
|   logout() { | ||||
|     window.localStorage.removeItem(AuthService.storageKey); | ||||
|     this.cancelFreshnessCheck(); | ||||
|     this.revoke(); | ||||
|   } | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user