diff --git a/src/login/login.ts b/src/login/login.ts index f69f8206..fa8762a5 100644 --- a/src/login/login.ts +++ b/src/login/login.ts @@ -65,6 +65,12 @@ const { deleteTypeIndexRegistration } = solidLogicSingleton.typeIndex +// Dedupe/caching for preference loading across repeated UI callers. +const ensureLoadedPreferencesInFlight = new Map>() +const cachedPreferencesFileByWebId = new Map() +const getUserRolesInFlight = new Map>>() +const cachedUserRolesByWebId = new Map>() + /** * Resolves with the logged in user's WebID * @@ -83,6 +89,7 @@ export function ensureLoggedIn (context: AuthenticationContext): Promise { if (context.preferencesFile) return Promise.resolve(context) // already done + const webId = context?.me?.uri + if (webId) { + const cachedPreferencesFile = cachedPreferencesFileByWebId.get(webId) + if (cachedPreferencesFile) { + context.preferencesFile = cachedPreferencesFile + return context + } + + const inFlight = ensureLoadedPreferencesInFlight.get(webId) + if (inFlight) { + const resolved = await inFlight + context.preferencesFile = resolved.preferencesFile + context.preferencesFileError = resolved.preferencesFileError + return context + } + } + + const run = (async (): Promise => { + // const statusArea = context.statusArea || context.div || null let progressDisplay /* COMPLAIN FUNCTION NOT USED/TAKING IT OUT FOR NOW @@ -163,7 +189,24 @@ export async function ensureLoadedPreferences ( throw new Error(`(via loadPrefs) ${err}`) } } - return context + return context + })() + + if (webId) { + ensureLoadedPreferencesInFlight.set(webId, run) + } + + try { + const resolved = await run + if (webId && resolved.preferencesFile) { + cachedPreferencesFileByWebId.set(webId, resolved.preferencesFile) + } + return resolved + } finally { + if (webId && ensureLoadedPreferencesInFlight.get(webId) === run) { + ensureLoadedPreferencesInFlight.delete(webId) + } + } } /** @@ -1047,17 +1090,28 @@ export function newAppInstance ( * and/or a developer */ export async function getUserRoles (): Promise> { - const sessionInfo = authSession.info + const currentUser = authn.currentUser() + const sessionInfo = authSession.info if (!sessionInfo?.isLoggedIn || !sessionInfo?.webId) { return [] } - - const currentUser = authn.currentUser() - if (!currentUser) { + + if (!currentUser || currentUser.uri !== sessionInfo.webId) { return [] } - try { + const webId = currentUser.uri + const cachedUserRoles = cachedUserRolesByWebId.get(webId) + if (cachedUserRoles) { + return cachedUserRoles + } + + const inFlight = getUserRolesInFlight.get(webId) + if (inFlight) { + return inFlight + } + + const run = (async (): Promise> => { const { me, preferencesFile, preferencesFileError } = await ensureLoadedPreferences({ me: currentUser }) if (!preferencesFile || preferencesFileError) { throw new Error(preferencesFileError || 'Unable to load user preferences file.') @@ -1068,10 +1122,21 @@ export async function getUserRoles (): Promise> { null, preferencesFile.doc() ) as NamedNode[] + })() + + getUserRolesInFlight.set(webId, run) + try { + const roles = await run + cachedUserRolesByWebId.set(webId, roles) + return roles } catch (error) { debug.warn('Unable to fetch your preferences - this was the error: ', error) + return [] + } finally { + if (getUserRolesInFlight.get(webId) === run) { + getUserRolesInFlight.delete(webId) + } } - return [] } /** diff --git a/test/unit/login/login.test.ts b/test/unit/login/login.test.ts index ceb14bb4..0aa23e0e 100644 --- a/test/unit/login/login.test.ts +++ b/test/unit/login/login.test.ts @@ -1,4 +1,69 @@ import * as testLogin from '../../../src/login/login' +import { sym } from 'rdflib' + +function buildSolidLogicMock () { + const loadPreferences = jest.fn() + const loadProfile = jest.fn(async (me) => me?.doc?.() ?? me) + const store = { + each: jest.fn(() => []), + any: jest.fn(() => null), + holds: jest.fn(() => false), + add: jest.fn(), + sym, + fetcher: { + requested: {}, + load: jest.fn(async () => undefined) + } + } + + const mockModule = { + AppDetails: class AppDetails {}, + AuthenticationContext: class AuthenticationContext {}, + authn: { + currentUser: jest.fn(() => null), + checkUser: jest.fn(async () => null), + saveUser: jest.fn((user) => user) + }, + authSession: { + info: { isLoggedIn: false, webId: undefined }, + events: { on: jest.fn() }, + login: jest.fn(async () => undefined), + logout: jest.fn(async () => undefined) + }, + CrossOriginForbiddenError: class CrossOriginForbiddenError extends Error {}, + FetchError: class FetchError extends Error { status?: number }, + getSuggestedIssuers: jest.fn(() => []), + NotEditableError: class NotEditableError extends Error {}, + offlineTestID: jest.fn(() => null), + SameOriginForbiddenError: class SameOriginForbiddenError extends Error {}, + UnauthorizedError: class UnauthorizedError extends Error {}, + WebOperationError: class WebOperationError extends Error {}, + store, + solidLogicSingleton: { + store, + profile: { + loadPreferences, + loadProfile + }, + typeIndex: { + getScopedAppInstances: jest.fn(async () => []), + getRegistrations: jest.fn(() => []), + loadAllTypeIndexes: jest.fn(async () => []), + getScopedAppsFromIndex: jest.fn(async () => []), + deleteTypeIndexRegistration: jest.fn(async () => undefined) + } + } + } + + return { mockModule, loadPreferences, store } +} + +function loadLoginWithMock () { + const { mockModule, loadPreferences, store } = buildSolidLogicMock() + jest.doMock('solid-logic', () => mockModule) + const loginModule = require('../../../src/login/login') + return { loginModule, solidLogic: mockModule, loadPreferences, store } +} describe('ensureLoggedIn', () => { afterAll(() => { @@ -16,6 +81,7 @@ describe('getUserRoles', () => { afterEach(() => { jest.restoreAllMocks() jest.resetModules() + jest.clearAllMocks() }) it('returns [] and does not load preferences when current user is missing', async () => { @@ -36,4 +102,112 @@ describe('getUserRoles', () => { expect(roles).toEqual([]) expect(loadPreferencesSpy).not.toHaveBeenCalled() }) + + it('shares in-flight ensureLoadedPreferences work for concurrent callers', async () => { + const { loginModule, solidLogic, loadPreferences } = loadLoginWithMock() + + const me = sym('https://alice.example.com/profile/card#me') + let resolvePreferences: (value: any) => void = () => {} + const preferencesFile = sym('https://alice.example.com/settings/prefs.ttl') + + solidLogic.authn.currentUser.mockReturnValue(me) + loadPreferences.mockImplementation(() => new Promise((resolve) => { + resolvePreferences = resolve + })) + + const p1 = loginModule.ensureLoadedPreferences({ me, publicProfile: me.doc() }) + const p2 = loginModule.ensureLoadedPreferences({ me, publicProfile: me.doc() }) + + await Promise.resolve() + expect(loadPreferences).toHaveBeenCalledTimes(1) + + resolvePreferences(preferencesFile) + const [first, second] = await Promise.all([p1, p2]) + + expect(first.preferencesFile).toEqual(preferencesFile) + expect(second.preferencesFile).toEqual(preferencesFile) + expect(loadPreferences).toHaveBeenCalledTimes(1) + }) + + it('caches successful role lookups per WebID', async () => { + const { loginModule, solidLogic, loadPreferences, store } = loadLoginWithMock() + + const me = sym('https://alice.example.com/profile/card#me') + const preferencesFile = sym('https://alice.example.com/settings/prefs.ttl') + const role = sym('http://example.com/ns#PowerUser') + + solidLogic.authSession.info = { isLoggedIn: true, webId: me.uri } + solidLogic.authn.currentUser.mockReturnValue(me) + loadPreferences.mockResolvedValue(preferencesFile) + store.each.mockReturnValue([role]) + + const first = await loginModule.getUserRoles() + const second = await loginModule.getUserRoles() + + expect(first).toEqual([role]) + expect(second).toEqual([role]) + expect(loadPreferences).toHaveBeenCalledTimes(1) + expect(store.each).toHaveBeenCalledTimes(1) + }) + + it('does not cache failed role lookups', async () => { + const { loginModule, solidLogic, loadPreferences, store } = loadLoginWithMock() + + const me = sym('https://alice.example.com/profile/card#me') + const preferencesFile = sym('https://alice.example.com/settings/prefs.ttl') + const role = sym('http://example.com/ns#Developer') + + solidLogic.authSession.info = { isLoggedIn: true, webId: me.uri } + solidLogic.authn.currentUser.mockReturnValue(me) + loadPreferences.mockRejectedValueOnce(new Error('transient failure')) + loadPreferences.mockResolvedValueOnce(preferencesFile) + store.each.mockReturnValue([role]) + + const first = await loginModule.getUserRoles() + const second = await loginModule.getUserRoles() + + expect(first).toEqual([]) + expect(second).toEqual([role]) + expect(loadPreferences).toHaveBeenCalledTimes(2) + expect(store.each).toHaveBeenCalledTimes(1) + }) + + it('does not clear cached storage request failures during login UI handling', async () => { + const { loginModule, solidLogic, store } = loadLoginWithMock() + + const me = sym('https://alice.example.com/profile/card#me') + const initialRequested = { + 'https://alice.example.com/settings/': 404, + 'https://alice.example.com/private/notes.ttl': 404, + 'https://other.example.com/resource.ttl': 404 + } + store.fetcher.requested = { ...initialRequested } + + const dom = document.implementation.createHTMLDocument('login-test') + const userUriInput = dom.createElement('input') + userUriInput.id = 'UserURI' + userUriInput.value = 'https://alice.example.com/private/notes.ttl' + dom.body.appendChild(userUriInput) + + solidLogic.authn.currentUser + .mockReturnValueOnce(null) + .mockReturnValue(me) + .mockReturnValue(me) + + const box = loginModule.loginStatusBox(dom, jest.fn()) + dom.body.appendChild(box) + + const loginHandlers = solidLogic.authSession.events.on.mock.calls + .filter(([eventName]) => eventName === 'login') + .map(([, handler]) => handler) + + expect(loginHandlers.length).toBeGreaterThan(0) + for (const handler of loginHandlers) { + await handler() + } + + expect(store.fetcher.requested['https://alice.example.com/settings/']).toBe(404) + expect(store.fetcher.requested['https://alice.example.com/private/notes.ttl']).toBe(404) + expect(store.fetcher.requested['https://other.example.com/resource.ttl']).toBe(404) + }) })