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:
sua yoo 2023-02-14 18:35:21 -08:00 committed by GitHub
parent d15e6c8ad8
commit 9532f48515
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 142 additions and 52 deletions

View File

@ -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")

View File

@ -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;

View 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);
});
});
});

View File

@ -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();
}