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 { spy, stub, mock, restore } from "sinon";
|
||||||
import { fixture, expect } from "@open-wc/testing";
|
import { fixture, expect } from "@open-wc/testing";
|
||||||
// import { expect } from "@esm-bundle/chai";
|
|
||||||
|
|
||||||
import AuthService from "./utils/AuthService";
|
import AuthService from "./utils/AuthService";
|
||||||
import { App } from "./index";
|
import { App } from "./index";
|
||||||
|
|
||||||
describe("browsertrix-app", () => {
|
describe("browsertrix-app", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
stub(window.sessionStorage, "setItem");
|
AuthService.broadcastChannel = new BroadcastChannel(AuthService.storageKey);
|
||||||
stub(App.prototype, "getUserInfo").callsFake(() =>
|
window.sessionStorage.clear();
|
||||||
Promise.resolve({
|
stub(window.history, "pushState");
|
||||||
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",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
AuthService.broadcastChannel.close();
|
||||||
restore();
|
restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -36,6 +21,24 @@ describe("browsertrix-app", () => {
|
|||||||
expect(el).instanceOf(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
|
// TODO move tests to AuthService
|
||||||
it("sets auth state from session storage", async () => {
|
it("sets auth state from session storage", async () => {
|
||||||
stub(AuthService.prototype, "startFreshnessCheck");
|
stub(AuthService.prototype, "startFreshnessCheck");
|
||||||
@ -58,6 +61,23 @@ describe("browsertrix-app", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sets user info", async () => {
|
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(AuthService.prototype, "startFreshnessCheck");
|
||||||
stub(window.sessionStorage, "getItem").callsFake((key) => {
|
stub(window.sessionStorage, "getItem").callsFake((key) => {
|
||||||
if (key === "btrix.auth")
|
if (key === "btrix.auth")
|
||||||
|
@ -12,7 +12,7 @@ import type { OrgTab } from "./pages/org";
|
|||||||
import type { NotifyEvent, NavigateEvent } from "./utils/LiteElement";
|
import type { NotifyEvent, NavigateEvent } from "./utils/LiteElement";
|
||||||
import LiteElement, { html } from "./utils/LiteElement";
|
import LiteElement, { html } from "./utils/LiteElement";
|
||||||
import APIRouter from "./utils/APIRouter";
|
import APIRouter from "./utils/APIRouter";
|
||||||
import AuthService from "./utils/AuthService";
|
import AuthService, { AuthState } from "./utils/AuthService";
|
||||||
import type { LoggedInEvent } from "./utils/AuthService";
|
import type { LoggedInEvent } from "./utils/AuthService";
|
||||||
import type { ViewState } from "./utils/APIRouter";
|
import type { ViewState } from "./utils/APIRouter";
|
||||||
import type { CurrentUser, UserOrg } from "./types/user";
|
import type { CurrentUser, UserOrg } from "./types/user";
|
||||||
@ -91,7 +91,12 @@ export class App extends LiteElement {
|
|||||||
private selectedOrgId?: string;
|
private selectedOrgId?: string;
|
||||||
|
|
||||||
async connectedCallback() {
|
async connectedCallback() {
|
||||||
const authState = await AuthService.initSessionStorage();
|
let authState: AuthState = null;
|
||||||
|
try {
|
||||||
|
authState = await AuthService.initSessionStorage();
|
||||||
|
} catch (e: any) {
|
||||||
|
console.debug(e);
|
||||||
|
}
|
||||||
this.syncViewState();
|
this.syncViewState();
|
||||||
if (this.viewState.route === "org") {
|
if (this.viewState.route === "org") {
|
||||||
this.selectedOrgId = this.viewState.params.orgId;
|
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;
|
readonly detail: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
type HasAuthStorageData = {
|
|
||||||
auth: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AuthRequestEventData = {
|
type AuthRequestEventData = {
|
||||||
name: "requesting_auth";
|
name: "requesting_auth";
|
||||||
};
|
};
|
||||||
@ -171,10 +167,6 @@ export default class AuthService {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
window.addEventListener("beforeunload", () => {
|
|
||||||
window.localStorage.removeItem(AuthService.storageKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
return authState;
|
return authState;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -192,26 +184,43 @@ export default class AuthService {
|
|||||||
* Retrieve shared session from another tab/window
|
* Retrieve shared session from another tab/window
|
||||||
**/
|
**/
|
||||||
private static async getSharedSessionAuth(): Promise<AuthState> {
|
private static async getSharedSessionAuth(): Promise<AuthState> {
|
||||||
return new Promise((resolve) => {
|
const broadcastPromise = new Promise((resolve) => {
|
||||||
// Check if there's any authenticated tabs
|
// Check if there's any authenticated tabs
|
||||||
const value = window.localStorage.getItem(AuthService.storageKey);
|
AuthService.broadcastChannel.postMessage(<AuthRequestEventData>{
|
||||||
if (value && (JSON.parse(value) as HasAuthStorageData).auth) {
|
name: "requesting_auth",
|
||||||
// Ask for auth
|
});
|
||||||
AuthService.broadcastChannel.postMessage(<AuthRequestEventData>{
|
// Wait for another tab to respond
|
||||||
name: "requesting_auth",
|
const cb = ({ data }: any) => {
|
||||||
});
|
if (data.name === "responding_auth") {
|
||||||
// Wait for another tab to respond
|
AuthService.broadcastChannel.removeEventListener("message", cb);
|
||||||
const cb = ({ data }: any) => {
|
resolve(data.auth);
|
||||||
if (data.name === "responding_auth") {
|
}
|
||||||
AuthService.broadcastChannel.removeEventListener("message", cb);
|
};
|
||||||
resolve(data.auth);
|
AuthService.broadcastChannel.addEventListener("message", cb);
|
||||||
}
|
|
||||||
};
|
|
||||||
AuthService.broadcastChannel.addEventListener("message", cb);
|
|
||||||
} else {
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
// 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() {
|
constructor() {
|
||||||
@ -227,16 +236,11 @@ export default class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
saveLogin(auth: Auth) {
|
saveLogin(auth: Auth) {
|
||||||
window.localStorage.setItem(
|
|
||||||
AuthService.storageKey,
|
|
||||||
JSON.stringify(<HasAuthStorageData>{ auth: true })
|
|
||||||
);
|
|
||||||
this.persist(auth);
|
this.persist(auth);
|
||||||
this.startFreshnessCheck();
|
this.startFreshnessCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
logout() {
|
logout() {
|
||||||
window.localStorage.removeItem(AuthService.storageKey);
|
|
||||||
this.cancelFreshnessCheck();
|
this.cancelFreshnessCheck();
|
||||||
this.revoke();
|
this.revoke();
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user