Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 72 additions & 7 deletions src/login/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ const {
deleteTypeIndexRegistration
} = solidLogicSingleton.typeIndex

// Dedupe/caching for preference loading across repeated UI callers.
const ensureLoadedPreferencesInFlight = new Map<string, Promise<AuthenticationContext>>()
const cachedPreferencesFileByWebId = new Map<string, NamedNode>()
const getUserRolesInFlight = new Map<string, Promise<Array<NamedNode>>>()
const cachedUserRolesByWebId = new Map<string, Array<NamedNode>>()

/**
* Resolves with the logged in user's WebID
*
Expand All @@ -83,6 +89,7 @@ export function ensureLoggedIn (context: AuthenticationContext): Promise<Authent
// Already logged in?
if (webId) {
debug.log(`logIn: Already logged in as ${webId}`)
authn.saveUser(webId as NamedNode | string, context)
return resolve(context)
}
if (!context.div || !context.dom) {
Expand Down Expand Up @@ -111,6 +118,25 @@ export async function ensureLoadedPreferences (
): Promise<AuthenticationContext> {
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<AuthenticationContext> => {

// const statusArea = context.statusArea || context.div || null
let progressDisplay
/* COMPLAIN FUNCTION NOT USED/TAKING IT OUT FOR NOW
Expand Down Expand Up @@ -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)
}
}
}

/**
Expand Down Expand Up @@ -1047,17 +1090,28 @@ export function newAppInstance (
* and/or a developer
*/
export async function getUserRoles (): Promise<Array<NamedNode>> {
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<Array<NamedNode>> => {
const { me, preferencesFile, preferencesFileError } = await ensureLoadedPreferences({ me: currentUser })
if (!preferencesFile || preferencesFileError) {
throw new Error(preferencesFileError || 'Unable to load user preferences file.')
Expand All @@ -1068,10 +1122,21 @@ export async function getUserRoles (): Promise<Array<NamedNode>> {
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 []
}

/**
Expand Down
174 changes: 174 additions & 0 deletions test/unit/login/login.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand All @@ -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 () => {
Expand All @@ -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)
})
})