diff --git a/src/profile/profileLogic.ts b/src/profile/profileLogic.ts index 2f38a7e..b3f4edf 100644 --- a/src/profile/profileLogic.ts +++ b/src/profile/profileLogic.ts @@ -1,12 +1,237 @@ -import { NamedNode } from 'rdflib' +import { literal, NamedNode, st, sym } from 'rdflib' +import { ACL_LINK } from '../acl/aclLogic' import { CrossOriginForbiddenError, FetchError, NotEditableError, SameOriginForbiddenError, UnauthorizedError, WebOperationError } from '../logic/CustomError' import * as debug from '../util/debug' import { ns as namespace } from '../util/ns' +import { privateTypeIndexDocument, publicTypeIndexDocument } from '../typeIndex/typeIndexDocuments' +import { createContainerLogic } from '../util/containerLogic' import { differentOrigin, suggestPreferencesFile } from '../util/utils' import { ProfileLogic } from '../types' export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { const ns = namespace + const containerLogic = createContainerLogic(store) + + function isAbsoluteHttpUri(uri: string | null | undefined): boolean { + return !!uri && (uri.startsWith('https://') || uri.startsWith('http://')) + } + + function docDirUri(node: NamedNode): string | null { + const doc = node.doc() + const dir = doc.dir() + if (dir?.uri && isAbsoluteHttpUri(dir.uri)) return dir.uri + const docUri = doc.uri + if (!docUri || !isAbsoluteHttpUri(docUri)) return null + const withoutFragment = docUri.split('#')[0] + const lastSlash = withoutFragment.lastIndexOf('/') + if (lastSlash === -1) return null + return withoutFragment.slice(0, lastSlash + 1) + } + + function suggestTypeIndexInPreferences(preferencesFile: NamedNode, filename: string): NamedNode { + const dirUri = docDirUri(preferencesFile) + if (!dirUri) throw new Error(`Cannot derive directory for preferences file ${preferencesFile.uri}`) + return sym(dirUri + filename) + } + + function isNotFoundError(err: any): boolean { + if (err?.response?.status === 404) return true + const text = `${err?.message || err || ''}` + return text.includes('404') || text.includes('Not Found') + } + + function ownerOnlyContainerAcl(webId: string): string { + return [ + '@prefix acl: .', + '', + '<#owner>', + 'a acl:Authorization;', + `acl:agent <${webId}>;`, + 'acl:accessTo <./>;', + 'acl:default <./>;', + 'acl:mode acl:Read, acl:Write, acl:Control.' + ].join('\n') + } + + function publicTypeIndexAcl(webId: string, publicTypeIndex: NamedNode): string { + const fileName = new URL(publicTypeIndex.uri).pathname.split('/').pop() || 'publicTypeIndex.ttl' + return [ + '# ACL resource for the Public Type Index', + '', + '@prefix acl: .', + '@prefix foaf: .', + '', + '<#owner>', + ' a acl:Authorization;', + '', + ' acl:agent', + ` <${webId}>;`, + '', + ` acl:accessTo <./${fileName}>;`, + '', + ' acl:mode', + ' acl:Read, acl:Write, acl:Control.', + '', + '# Public-readable', + '<#public>', + ' a acl:Authorization;', + '', + ' acl:agentClass foaf:Agent;', + '', + ` acl:accessTo <./${fileName}>;`, + '', + ' acl:mode acl:Read.' + ].join('\n') + } + + async function ensureContainerExists(containerUri: string): Promise { + const containerNode = sym(containerUri) + try { + await store.fetcher.load(containerNode) + return + } catch (err) { + if (!isNotFoundError(err)) throw err + } + await containerLogic.createContainer(containerUri) + } + + async function ensureOwnerOnlyAclForSettings(user: NamedNode, preferencesFile: NamedNode): Promise { + const dirUri = docDirUri(preferencesFile) + if (!dirUri) throw new Error(`Cannot derive settings directory from ${preferencesFile.uri}`) + await ensureContainerExists(dirUri) + + const containerNode = sym(dirUri) + let aclDocUri: string | undefined + try { + await store.fetcher.load(containerNode) + aclDocUri = store.any(containerNode, ACL_LINK)?.value + } catch (err) { + if (!isNotFoundError(err)) throw err + } + if (!aclDocUri) { + // Fallback for servers/tests where rel=acl is not exposed in mocked headers. + aclDocUri = `${dirUri}.acl` + } + const aclDoc = sym(aclDocUri) + try { + await store.fetcher.load(aclDoc) + return + } catch (err) { + if (!isNotFoundError(err)) throw err + } + + await store.fetcher.webOperation('PUT', aclDoc.uri, { + data: ownerOnlyContainerAcl(user.uri), + contentType: 'text/turtle' + }) + } + + async function ensurePublicTypeIndexAclOnCreate(user: NamedNode, publicTypeIndex: NamedNode, ensureAcl = false): Promise { + const created = await utilityLogic.loadOrCreateWithContentOnCreate(publicTypeIndex, publicTypeIndexDocument()) + if (!created && !ensureAcl) return + + let aclDocUri: string | undefined + try { + await store.fetcher.load(publicTypeIndex) + aclDocUri = store.any(publicTypeIndex, ACL_LINK)?.value + } catch (err) { + if (!isNotFoundError(err)) throw err + } + if (!aclDocUri) { + aclDocUri = `${publicTypeIndex.uri}.acl` + } + + const aclDoc = sym(aclDocUri) + try { + await store.fetcher.load(aclDoc) + return + } catch (err) { + if (!isNotFoundError(err)) throw err + } + + await store.fetcher.webOperation('PUT', aclDoc.uri, { + data: publicTypeIndexAcl(user.uri, publicTypeIndex), + contentType: 'text/turtle' + }) + } + + async function ensurePrivateTypeIndexOnCreate(privateTypeIndex: NamedNode): Promise { + await utilityLogic.loadOrCreateWithContentOnCreate(privateTypeIndex, privateTypeIndexDocument()) + } + + async function initializePreferencesDefaults(user: NamedNode, preferencesFile: NamedNode): Promise { + const preferencesDoc = preferencesFile.doc() as NamedNode + const profileDoc = user.doc() as NamedNode + await store.fetcher.load(preferencesDoc) + + const profilePublicTypeIndex = + (store.any(user, ns.solid('publicTypeIndex'), null, profileDoc) as NamedNode | null) + const preferencesPublicTypeIndex = + (store.any(user, ns.solid('publicTypeIndex'), null, preferencesDoc) as NamedNode | null) + const publicTypeIndex = + profilePublicTypeIndex || + preferencesPublicTypeIndex || + suggestTypeIndexInPreferences(preferencesFile, 'publicTypeIndex.ttl') + const privateTypeIndex = + (store.any(user, ns.solid('privateTypeIndex'), null, preferencesDoc) as NamedNode | null) || + suggestTypeIndexInPreferences(preferencesFile, 'privateTypeIndex.ttl') + + // Keep discovery consistent with typeIndexLogic, which resolves publicTypeIndex from the profile doc. + const createdProfilePublicTypeIndexLink = !profilePublicTypeIndex + if (createdProfilePublicTypeIndexLink) { + await utilityLogic.followOrCreateLinkWithContentOnCreate( + user, + ns.solid('publicTypeIndex') as NamedNode, + publicTypeIndex, + profileDoc, + publicTypeIndexDocument() + ) + } + + const toInsert: any[] = [] + if (!store.holds(preferencesDoc, ns.rdf('type'), ns.space('ConfigurationFile'), preferencesDoc)) { + toInsert.push(st(preferencesDoc, ns.rdf('type'), ns.space('ConfigurationFile'), preferencesDoc)) + } + if (!store.holds(preferencesDoc, ns.dct('title'), undefined, preferencesDoc)) { + toInsert.push(st(preferencesDoc, ns.dct('title'), literal('Preferences file'), preferencesDoc)) + } + if (!store.holds(user, ns.solid('publicTypeIndex'), publicTypeIndex, preferencesDoc)) { + toInsert.push(st(user, ns.solid('publicTypeIndex'), publicTypeIndex, preferencesDoc)) + } + if (!store.holds(user, ns.solid('privateTypeIndex'), privateTypeIndex, preferencesDoc)) { + toInsert.push(st(user, ns.solid('privateTypeIndex'), privateTypeIndex, preferencesDoc)) + } + + if (toInsert.length > 0) { + await store.updater.update([], toInsert) + await store.fetcher.load(preferencesDoc) + } + + await ensurePublicTypeIndexAclOnCreate(user, publicTypeIndex, createdProfilePublicTypeIndexLink) + await ensurePrivateTypeIndexOnCreate(privateTypeIndex) + } + + async function ensurePreferencesDocExists(preferencesFile: NamedNode): Promise { + try { + await store.fetcher.load(preferencesFile) + return false + } catch (err) { + if (isNotFoundError(err)) { + await utilityLogic.loadOrCreateIfNotExists(preferencesFile) + return true + } + if (err.response?.status === 401) { + throw new UnauthorizedError() + } + if (err.response?.status === 403) { + if (differentOrigin(preferencesFile)) { + throw new CrossOriginForbiddenError() + } + throw new SameOriginForbiddenError() + } + throw err + } + } /** * loads the preference without throwing errors - if it can create it it does so. @@ -34,7 +259,17 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { const possiblePreferencesFile = suggestPreferencesFile(user) let preferencesFile try { - preferencesFile = await utilityLogic.followOrCreateLink(user, ns.space('preferencesFile') as NamedNode, possiblePreferencesFile, user.doc()) + const existingPreferencesFile = store.any(user, ns.space('preferencesFile'), null, user.doc()) as NamedNode | null + if (existingPreferencesFile) { + preferencesFile = existingPreferencesFile + } else { + preferencesFile = await utilityLogic.followOrCreateLink(user, ns.space('preferencesFile') as NamedNode, possiblePreferencesFile, user.doc()) + } + + await ensureOwnerOnlyAclForSettings(user, preferencesFile as NamedNode) + + await ensurePreferencesDocExists(preferencesFile as NamedNode) + await initializePreferencesDefaults(user, preferencesFile as NamedNode) } catch (err) { const message = `User ${user} has no pointer in profile to preferences file.` debug.warn(message) diff --git a/src/typeIndex/typeIndexDocuments.ts b/src/typeIndex/typeIndexDocuments.ts new file mode 100644 index 0000000..de4112a --- /dev/null +++ b/src/typeIndex/typeIndexDocuments.ts @@ -0,0 +1,17 @@ +export function publicTypeIndexDocument(): string { + return [ + '@prefix solid: .', + '<>', + ' a solid:TypeIndex ;', + ' a solid:ListedDocument.' + ].join('\n') +} + +export function privateTypeIndexDocument(): string { + return [ + '@prefix solid: .', + '<>', + ' a solid:TypeIndex ;', + ' a solid:UnlistedDocument.' + ].join('\n') +} \ No newline at end of file diff --git a/src/typeIndex/typeIndexLogic.ts b/src/typeIndex/typeIndexLogic.ts index 9505f65..b456342 100644 --- a/src/typeIndex/typeIndexLogic.ts +++ b/src/typeIndex/typeIndexLogic.ts @@ -3,6 +3,7 @@ import { ScopedApp, TypeIndexLogic, TypeIndexScope } from '../types' import * as debug from '../util/debug' import { ns as namespace } from '../util/ns' import { newThing } from '../util/utils' +import { privateTypeIndexDocument, publicTypeIndexDocument } from './typeIndexDocuments' export function createTypeIndexLogic(store, authn, profileLogic, utilityLogic): TypeIndexLogic { const ns = namespace @@ -32,11 +33,20 @@ export function createTypeIndexLogic(store, authn, profileLogic, utilityLogic): } let publicTypeIndex try { - publicTypeIndex = - store.any(user, ns.solid('publicTypeIndex'), undefined, profile) || - (suggestion - ? await utilityLogic.followOrCreateLink(user, ns.solid('publicTypeIndex') as NamedNode, suggestion, profile) - : null) + const existingPublicTypeIndex = store.any(user, ns.solid('publicTypeIndex'), undefined, profile) + if (existingPublicTypeIndex) { + publicTypeIndex = existingPublicTypeIndex + } else if (suggestion) { + publicTypeIndex = await utilityLogic.followOrCreateLinkWithContentOnCreate( + user, + ns.solid('publicTypeIndex') as NamedNode, + suggestion, + profile, + publicTypeIndexDocument() + ) + } else { + publicTypeIndex = null + } } catch (err) { const message = `User ${user} has no pointer in profile to publicTypeIndex file: ${err}` debug.warn(message) @@ -63,11 +73,21 @@ export function createTypeIndexLogic(store, authn, profileLogic, utilityLogic): } let privateTypeIndex try { - privateTypeIndex = store.any(user, ns.solid('privateTypeIndex'), undefined, profile) || - (suggestedPrivateTypeIndex - ? await utilityLogic.followOrCreateLink(user, ns.solid('privateTypeIndex') as NamedNode, suggestedPrivateTypeIndex, preferencesFile) - : null) - } catch (err) { + const existingPrivateTypeIndex = store.any(user, ns.solid('privateTypeIndex'), undefined, profile) + if (existingPrivateTypeIndex) { + privateTypeIndex = existingPrivateTypeIndex + } else if (suggestedPrivateTypeIndex) { + privateTypeIndex = await utilityLogic.followOrCreateLinkWithContentOnCreate( + user, + ns.solid('privateTypeIndex') as NamedNode, + suggestedPrivateTypeIndex, + preferencesFile, + privateTypeIndexDocument() + ) + } else { + privateTypeIndex = null + } + } catch (err) { const message = `User ${user} has no pointer in preference file to privateTypeIndex file: ${err}` debug.warn(message) } diff --git a/src/util/utilityLogic.ts b/src/util/utilityLogic.ts index d82c63f..9e2ae88 100644 --- a/src/util/utilityLogic.ts +++ b/src/util/utilityLogic.ts @@ -5,6 +5,12 @@ import { differentOrigin } from './utils' export function createUtilityLogic(store, aclLogic, containerLogic) { + function isNotFoundError(err: any): boolean { + if (err?.response?.status === 404) return true + const text = `${err?.message || err || ''}` + return text.includes('404') || text.includes('Not Found') + } + async function recursiveDelete(containerNode: NamedNode) { try { if (containerLogic.isContainer(containerNode)) { @@ -58,6 +64,37 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { return response } + async function loadOrCreateWithContentOnCreate(doc: NamedNode, data: string): Promise { + try { + // If the document already exists, do not overwrite it; just report "not created". + await store.fetcher.load(doc) + return false + } catch (err: any) { + if (!isNotFoundError(err)) throw err + } + + // At this point, the document appears to be missing. Try to create it atomically + // with a conditional PUT so we don't overwrite a concurrently created resource. + try { + await store.fetcher.webOperation('PUT', doc, { + data, + contentType: 'text/turtle', + headers: { 'If-None-Match': '*' } + }) + await store.fetcher.load(doc) + return true + } catch (err: any) { + const status = err?.response?.status ?? err?.status + if (status === 412) { + // Another client created the resource between our initial 404 and this PUT. + // Treat it as pre-existing and do not overwrite their content. + await store.fetcher.load(doc) + return false + } + throw err + } + } + /* Follow link from this doc to another thing, or else make a new link ** ** @returns existing object, or creates it if non existent @@ -92,6 +129,39 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { return object } + async function followOrCreateLinkWithContentOnCreate( + subject: NamedNode, + predicate: NamedNode, + object: NamedNode, + doc: NamedNode, + data: string + ): Promise { + await store.fetcher.load(doc) + const result = store.any(subject, predicate, null, doc) + + if (result) return result as NamedNode + if (!store.updater.editable(doc)) { + const msg = `followOrCreateLinkWithContentOnCreate: cannot edit ${doc.value}` + debug.warn(msg) + throw new NotEditableError(msg) + } + try { + await store.updater.update([], [st(subject, predicate, object, doc)]) + } catch (err) { + const msg = `followOrCreateLinkWithContentOnCreate: Error making link in ${doc} to ${object}: ${err}` + debug.warn(msg) + throw new WebOperationError(err) + } + + try { + await loadOrCreateWithContentOnCreate(object, data) + } catch (err) { + debug.warn(`followOrCreateLinkWithContentOnCreate: Error loading or saving new linked document: ${object}: ${err}`) + throw err + } + return object + } + // Copied from https://github.com/solidos/web-access-control-tests/blob/v3.0.0/test/surface/delete.test.ts#L5 async function setSinglePeerAccess(options: { ownerWebId: string, @@ -150,7 +220,9 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { setSinglePeerAccess, createEmptyRdfDoc, followOrCreateLink, - loadOrCreateIfNotExists + followOrCreateLinkWithContentOnCreate, + loadOrCreateIfNotExists, + loadOrCreateWithContentOnCreate } } diff --git a/src/util/utils.ts b/src/util/utils.ts index c1ccd99..5eab07e 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -34,7 +34,7 @@ export function suggestPreferencesFile (me:NamedNode) { const stripped = me.uri.replace('/profile/', '/').replace('/public/', '/') // const stripped = me.uri.replace(\/[p|P]rofile/\g, '/').replace(\/[p|P]ublic/\g, '/') const folderURI = stripped.split('/').slice(0,-1).join('/') + '/Settings/' - const fileURI = folderURI + 'Preferences.ttl' + const fileURI = folderURI + 'prefs.ttl' return sym(fileURI) } diff --git a/test/profileLogic.test.ts b/test/profileLogic.test.ts index 970fd78..f734c77 100644 --- a/test/profileLogic.test.ts +++ b/test/profileLogic.test.ts @@ -12,20 +12,28 @@ import { import { createAclLogic } from '../src/acl/aclLogic' import { createContainerLogic } from '../src/util/containerLogic' +declare const fetchMock: any + +declare global { + interface Window { + $SolidTestEnvironment?: { username: string } + } +} + const prefixes = Object.keys(ns).map(prefix => `@prefix ${prefix}: ${ns[prefix]('')}.\n`).join('') // In turtle const user = alice const profile = user.doc() let requests: Request[] = [] -let profileLogic +let profileLogic: ReturnType describe('Profile', () => { describe('loadProfile', () => { window.$SolidTestEnvironment = { username: alice.uri } - let store + let store: Store requests = [] const statustoBeReturned = 200 - let web = {} + let web: Record = {} const authn = { currentUser: () => { return alice @@ -35,7 +43,7 @@ describe('Profile', () => { fetchMock.resetMocks() web = loadWebObject() requests = [] - fetchMock.mockIf(/^https?.*$/, async req => { + fetchMock.mockIf(/^https?.*$/, async (req: Request) => { if (req.method !== 'GET') { requests.push(req) @@ -85,10 +93,10 @@ describe('Profile', () => { describe('silencedLoadPreferences', () => { window.$SolidTestEnvironment = { username: alice.uri } - let store + let store: Store requests = [] const statustoBeReturned = 200 - let web = {} + let web: Record = {} const authn = { currentUser: () => { return alice @@ -98,7 +106,7 @@ describe('Profile', () => { fetchMock.resetMocks() web = loadWebObject() requests = [] - fetchMock.mockIf(/^https?.*$/, async req => { + fetchMock.mockIf(/^https?.*$/, async (req: Request) => { if (req.method !== 'GET') { requests.push(req) @@ -139,26 +147,87 @@ describe('Profile', () => { it('loads data', async () => { const result = await profileLogic.silencedLoadPreferences(alice) expect(result).toBeInstanceOf(Object) + if (!result) { + throw new Error('Expected preferences document for alice') + } expect(result.uri).toEqual(AlicePreferencesFile.uri) expect(store.holds(user, ns.rdf('type'), ns.vcard('Individual'), profile)).toEqual(true) expect(store.statementsMatching(null, null, null, profile).length).toEqual(4) - expect(store.statementsMatching(null, null, null, AlicePreferencesFile).length).toEqual(2) + expect(store.statementsMatching(null, null, null, AlicePreferencesFile).length).toBeGreaterThanOrEqual(2) expect(store.holds(user, ns.solid('privateTypeIndex'), AlicePrivateTypeIndex, AlicePreferencesFile)).toEqual(true) }) it('creates new file', async () => { await profileLogic.silencedLoadPreferences(bob) - const patchRequest = requests[0] - expect(patchRequest.method).toEqual('PATCH') - expect(patchRequest.url).toEqual(bob.doc().uri) - const text = await patchRequest.text() - expect(text).toContain('INSERT DATA { .') + const profilePatch = requests.find(req => req.method === 'PATCH' && req.url === bob.doc().uri) + expect(profilePatch).toBeDefined() + if (!profilePatch) { + throw new Error('Expected profile patch request for bob') + } + const profilePatchText = await profilePatch.text() + expect(profilePatchText).toContain('INSERT DATA { .') + + const preferencesPatch = requests.find(req => req.method === 'PATCH' && req.url === 'https://bob.example.com/Settings/prefs.ttl') + expect(preferencesPatch).toBeDefined() + if (!preferencesPatch) { + throw new Error('Expected preferences patch request for bob') + } + const preferencesPatchText = await preferencesPatch.text() + expect(preferencesPatchText).toContain(' .') + expect(preferencesPatchText).toContain(' "Preferences file" .') + expect(preferencesPatchText).toContain(' ') + expect(preferencesPatchText).toContain(' ') + + const putUrls = requests.filter(req => req.method === 'PUT').map(req => req.url) + expect(putUrls).toContain('https://bob.example.com/Settings/') + expect(putUrls).toContain('https://bob.example.com/Settings/.acl') + expect(putUrls).toContain('https://bob.example.com/Settings/prefs.ttl') + expect(putUrls).toContain('https://bob.example.com/Settings/publicTypeIndex.ttl') + expect(putUrls).toContain('https://bob.example.com/Settings/publicTypeIndex.ttl.acl') + expect(putUrls).toContain('https://bob.example.com/Settings/privateTypeIndex.ttl') + + const publicTypeIndexBody = web['https://bob.example.com/Settings/publicTypeIndex.ttl'] + expect(publicTypeIndexBody).toBeDefined() + expect(publicTypeIndexBody).toContain('@prefix solid: .') + expect(publicTypeIndexBody).toContain('<>') + expect(publicTypeIndexBody).toContain('a solid:TypeIndex ;') + expect(publicTypeIndexBody).toContain('a solid:ListedDocument.') + + const privateTypeIndexBody = web['https://bob.example.com/Settings/privateTypeIndex.ttl'] + expect(privateTypeIndexBody).toBeDefined() + expect(privateTypeIndexBody).toContain('@prefix solid: .') + expect(privateTypeIndexBody).toContain('<>') + expect(privateTypeIndexBody).toContain('a solid:TypeIndex ;') + expect(privateTypeIndexBody).toContain('a solid:UnlistedDocument.') - const putRequest = requests[1] - expect(putRequest.method).toEqual('PUT') - expect(putRequest.url).toEqual('https://bob.example.com/Settings/Preferences.ttl') - expect(web[putRequest.url]).toEqual('') + const settingsAclPut = requests.find(req => req.method === 'PUT' && req.url === 'https://bob.example.com/Settings/.acl') + expect(settingsAclPut).toBeDefined() + const settingsAclBody = web['https://bob.example.com/Settings/.acl'] + expect(settingsAclBody).toBeDefined() + expect(settingsAclBody).toContain('@prefix acl: .') + expect(settingsAclBody).toContain('<#owner>') + expect(settingsAclBody).toContain('acl:agent ;') + expect(settingsAclBody).toContain('acl:accessTo <./>;') + expect(settingsAclBody).toContain('acl:default <./>;') + expect(settingsAclBody).toContain('acl:mode acl:Read, acl:Write, acl:Control.') + + const publicTypeIndexAclPut = requests.find(req => req.method === 'PUT' && req.url === 'https://bob.example.com/Settings/publicTypeIndex.ttl.acl') + expect(publicTypeIndexAclPut).toBeDefined() + const publicTypeIndexAclBody = web['https://bob.example.com/Settings/publicTypeIndex.ttl.acl'] + expect(publicTypeIndexAclBody).toBeDefined() + expect(publicTypeIndexAclBody).not.toEqual('') + expect(publicTypeIndexAclBody).toContain('@prefix acl: .') + expect(publicTypeIndexAclBody).toContain('@prefix foaf: .') + expect(publicTypeIndexAclBody).toContain('<#owner>') + expect(publicTypeIndexAclBody).toContain('acl:agent') + expect(publicTypeIndexAclBody).toContain(';') + expect(publicTypeIndexAclBody).toContain('acl:accessTo <./publicTypeIndex.ttl>;') + expect(publicTypeIndexAclBody).toContain('acl:mode') + expect(publicTypeIndexAclBody).toContain('acl:Read, acl:Write, acl:Control.') + expect(publicTypeIndexAclBody).toContain('<#public>') + expect(publicTypeIndexAclBody).toContain('acl:agentClass foaf:Agent;') + expect(publicTypeIndexAclBody).toContain('acl:mode acl:Read.') }) }) @@ -166,10 +235,10 @@ describe('Profile', () => { describe('loadPreferences', () => { window.$SolidTestEnvironment = { username: boby.uri } - let store + let store: Store requests = [] const statustoBeReturned = 200 - let web = {} + let web: Record = {} const authn = { currentUser: () => { return boby @@ -179,7 +248,7 @@ describe('Profile', () => { fetchMock.resetMocks() web = loadWebObject() requests = [] - fetchMock.mockIf(/^https?.*$/, async req => { + fetchMock.mockIf(/^https?.*$/, async (req: Request) => { if (req.method !== 'GET') { requests.push(req) @@ -224,22 +293,80 @@ describe('Profile', () => { expect(store.holds(user, ns.rdf('type'), ns.vcard('Individual'), profile)).toEqual(true) expect(store.statementsMatching(null, null, null, profile).length).toEqual(4) - expect(store.statementsMatching(null, null, null, AlicePreferencesFile).length).toEqual(2) + expect(store.statementsMatching(null, null, null, AlicePreferencesFile).length).toBeGreaterThanOrEqual(2) expect(store.holds(user, ns.solid('privateTypeIndex'), AlicePrivateTypeIndex, AlicePreferencesFile)).toEqual(true) }) it('creates new file', async () => { await profileLogic.loadPreferences(boby) - const patchRequest = requests[0] - expect(patchRequest.method).toEqual('PATCH') - expect(patchRequest.url).toEqual(boby.doc().uri) - const text = await patchRequest.text() - expect(text).toContain('INSERT DATA { .') + const profilePatch = requests.find(req => req.method === 'PATCH' && req.url === boby.doc().uri) + expect(profilePatch).toBeDefined() + if (!profilePatch) { + throw new Error('Expected profile patch request for boby') + } + const profilePatchText = await profilePatch.text() + expect(profilePatchText).toContain('INSERT DATA { .') + + const preferencesPatch = requests.find(req => req.method === 'PATCH' && req.url === 'https://boby.example.com/Settings/prefs.ttl') + expect(preferencesPatch).toBeDefined() + if (!preferencesPatch) { + throw new Error('Expected preferences patch request for boby') + } + const preferencesPatchText = await preferencesPatch.text() + expect(preferencesPatchText).toContain(' .') + expect(preferencesPatchText).toContain(' "Preferences file" .') + expect(preferencesPatchText).toContain(' ') + expect(preferencesPatchText).toContain(' ') + + const putUrls = requests.filter(req => req.method === 'PUT').map(req => req.url) + expect(putUrls).toContain('https://boby.example.com/Settings/') + expect(putUrls).toContain('https://boby.example.com/Settings/.acl') + expect(putUrls).toContain('https://boby.example.com/Settings/prefs.ttl') + expect(putUrls).toContain('https://boby.example.com/Settings/publicTypeIndex.ttl') + expect(putUrls).toContain('https://boby.example.com/Settings/publicTypeIndex.ttl.acl') + expect(putUrls).toContain('https://boby.example.com/Settings/privateTypeIndex.ttl') + + const publicTypeIndexBody = web['https://boby.example.com/Settings/publicTypeIndex.ttl'] + expect(publicTypeIndexBody).toBeDefined() + expect(publicTypeIndexBody).toContain('@prefix solid: .') + expect(publicTypeIndexBody).toContain('<>') + expect(publicTypeIndexBody).toContain('a solid:TypeIndex ;') + expect(publicTypeIndexBody).toContain('a solid:ListedDocument.') + + const privateTypeIndexBody = web['https://boby.example.com/Settings/privateTypeIndex.ttl'] + expect(privateTypeIndexBody).toBeDefined() + expect(privateTypeIndexBody).toContain('@prefix solid: .') + expect(privateTypeIndexBody).toContain('<>') + expect(privateTypeIndexBody).toContain('a solid:TypeIndex ;') + expect(privateTypeIndexBody).toContain('a solid:UnlistedDocument.') + + const settingsAclPut = requests.find(req => req.method === 'PUT' && req.url === 'https://boby.example.com/Settings/.acl') + expect(settingsAclPut).toBeDefined() + const settingsAclBody = web['https://boby.example.com/Settings/.acl'] + expect(settingsAclBody).toBeDefined() + expect(settingsAclBody).toContain('@prefix acl: .') + expect(settingsAclBody).toContain('<#owner>') + expect(settingsAclBody).toContain('acl:agent ;') + expect(settingsAclBody).toContain('acl:accessTo <./>;') + expect(settingsAclBody).toContain('acl:default <./>;') + expect(settingsAclBody).toContain('acl:mode acl:Read, acl:Write, acl:Control.') - const putRequest = requests[1] - expect(putRequest.method).toEqual('PUT') - expect(putRequest.url).toEqual('https://boby.example.com/Settings/Preferences.ttl') - expect(web[putRequest.url]).toEqual('') + const publicTypeIndexAclPut = requests.find(req => req.method === 'PUT' && req.url === 'https://boby.example.com/Settings/publicTypeIndex.ttl.acl') + expect(publicTypeIndexAclPut).toBeDefined() + const publicTypeIndexAclBody = web['https://boby.example.com/Settings/publicTypeIndex.ttl.acl'] + expect(publicTypeIndexAclBody).toBeDefined() + expect(publicTypeIndexAclBody).not.toEqual('') + expect(publicTypeIndexAclBody).toContain('@prefix acl: .') + expect(publicTypeIndexAclBody).toContain('@prefix foaf: .') + expect(publicTypeIndexAclBody).toContain('<#owner>') + expect(publicTypeIndexAclBody).toContain('acl:agent') + expect(publicTypeIndexAclBody).toContain(';') + expect(publicTypeIndexAclBody).toContain('acl:accessTo <./publicTypeIndex.ttl>;') + expect(publicTypeIndexAclBody).toContain('acl:mode') + expect(publicTypeIndexAclBody).toContain('acl:Read, acl:Write, acl:Control.') + expect(publicTypeIndexAclBody).toContain('<#public>') + expect(publicTypeIndexAclBody).toContain('acl:agentClass foaf:Agent;') + expect(publicTypeIndexAclBody).toContain('acl:mode acl:Read.') }) }) diff --git a/test/typeIndexLogic.test.ts b/test/typeIndexLogic.test.ts index 529deb2..41e7da0 100644 --- a/test/typeIndexLogic.test.ts +++ b/test/typeIndexLogic.test.ts @@ -22,7 +22,7 @@ const Image = ns.schema('Image') //web = loadWebObject() const user = alice const profile = user.doc() -const web = {} +const web: Record = {} web[profile.uri] = AliceProfile web[AlicePreferencesFile.uri] = AlicePreferences web[AlicePrivateTypeIndex.uri] = AlicePrivateTypes @@ -36,10 +36,10 @@ web[ClubPrivateTypeIndex.uri] = ClubPrivateTypes web[ClubPublicTypeIndex.uri] = ClubPublicTypes let requests: Request[] = [] let statustoBeReturned = 200 -let typeIndexLogic +let typeIndexLogic: ReturnType describe('TypeIndex logic NEW', () => { - let store + let store: Store const authn = { currentUser: () => { return alice @@ -51,7 +51,7 @@ describe('TypeIndex logic NEW', () => { requests = [] statustoBeReturned = 200 - fetchMock.mockIf(/^https?.*$/, async req => { + fetchMock.mockIf(/^https?.*$/, async (req: Request) => { if (req.method !== 'GET') { requests.push(req) @@ -206,25 +206,33 @@ describe('TypeIndex logic NEW', () => { it('creates new preferenceFile and typeIndex files where they dont exist', async () => { await typeIndexLogic.getScopedAppInstances(Tracker, bob) - expect(requests[0].method).toEqual('PATCH') // Add preferrencesFile link to profile - expect(requests[0].url).toEqual('https://bob.example.com/profile/card.ttl') + const byUrlAndMethod = (url: string, method: string) => + requests.some(req => req.url === url && req.method === method) - expect(requests[1].method).toEqual('PUT') // create publiTypeIndex - expect(requests[1].url).toEqual('https://bob.example.com/profile/publicTypeIndex.ttl') + // Existing behavior that must remain true + expect(byUrlAndMethod('https://bob.example.com/profile/card.ttl', 'PATCH')).toEqual(true) + expect(byUrlAndMethod('https://bob.example.com/profile/publicTypeIndex.ttl', 'PUT')).toEqual(true) + expect(byUrlAndMethod('https://bob.example.com/Settings/prefs.ttl', 'PUT')).toEqual(true) + expect(byUrlAndMethod('https://bob.example.com/Settings/prefs.ttl', 'PATCH')).toEqual(true) + expect(byUrlAndMethod('https://bob.example.com/Settings/privateTypeIndex.ttl', 'PUT')).toEqual(true) - expect(requests[2].method).toEqual('PATCH') // Add link of publiTypeIndex to profile - expect(requests[2].url).toEqual('https://bob.example.com/profile/card.ttl') + const createdPublicTypeIndexBody = web['https://bob.example.com/profile/publicTypeIndex.ttl'] + expect(createdPublicTypeIndexBody).toBeDefined() + expect(createdPublicTypeIndexBody).toContain('@prefix solid: .') + expect(createdPublicTypeIndexBody).toContain('<>') + expect(createdPublicTypeIndexBody).toContain('a solid:TypeIndex ;') + expect(createdPublicTypeIndexBody).toContain('a solid:ListedDocument.') - expect(requests[3].method).toEqual('PUT') // create preferenceFile - expect(requests[3].url).toEqual('https://bob.example.com/Settings/Preferences.ttl') + const createdPrivateTypeIndexBody = web['https://bob.example.com/Settings/privateTypeIndex.ttl'] + expect(createdPrivateTypeIndexBody).toBeDefined() + expect(createdPrivateTypeIndexBody).toContain('@prefix solid: .') + expect(createdPrivateTypeIndexBody).toContain('<>') + expect(createdPrivateTypeIndexBody).toContain('a solid:TypeIndex ;') + expect(createdPrivateTypeIndexBody).toContain('a solid:UnlistedDocument.') - expect(requests[4].method).toEqual('PATCH') // Add privateTypeIndex link preference file - expect(requests[4].url).toEqual('https://bob.example.com/Settings/Preferences.ttl') - - expect(requests[5].method).toEqual('PUT') //create privatTypeIndex - expect(requests[5].url).toEqual('https://bob.example.com/Settings/privateTypeIndex.ttl') - - expect(requests.length).toEqual(6) + // New ACL/setup behavior + expect(byUrlAndMethod('https://bob.example.com/Settings/', 'PUT')).toEqual(true) + expect(byUrlAndMethod('https://bob.example.com/Settings/.acl', 'PUT')).toEqual(true) }) }) diff --git a/test/utilityLogic.test.ts b/test/utilityLogic.test.ts index 2d51589..747094f 100644 --- a/test/utilityLogic.test.ts +++ b/test/utilityLogic.test.ts @@ -95,6 +95,33 @@ describe('utilityLogic', () => { }) }) + describe('loadOrCreateWithContentOnCreate', () => { + it('exists', () => { + expect(utilityLogic.loadOrCreateWithContentOnCreate).toBeInstanceOf(Function) + }) + it('creates and seeds content when missing', async () => { + const suggestion = 'https://bob.example.com/settings/new-index.ttl' + const body = [ + '@prefix solid: .', + '<>', + ' a solid:TypeIndex ;', + ' a solid:ListedDocument.' + ].join('\n') + const created = await utilityLogic.loadOrCreateWithContentOnCreate(sym(suggestion), body) + + expect(created).toEqual(true) + expect(web[suggestion]).toEqual(body) + }) + it('does not overwrite existing content', async () => { + const existing = AlicePrivateTypeIndex.uri + const before = web[existing] + const created = await utilityLogic.loadOrCreateWithContentOnCreate(sym(existing), 'NEW') + + expect(created).toEqual(false) + expect(web[existing]).toEqual(before) + }) + }) + describe('followOrCreateLink', () => { it('exists', () => { expect(utilityLogic.followOrCreateLink).toBeInstanceOf(Function) @@ -125,6 +152,35 @@ describe('utilityLogic', () => { }) }) + + describe('followOrCreateLinkWithContentOnCreate', () => { + it('exists', () => { + expect(utilityLogic.followOrCreateLinkWithContentOnCreate).toBeInstanceOf(Function) + }) + it('does not create target doc when link patch fails', async () => { + const suggestion = 'https://bob.example.com/settings/prefsSuggestion.ttl' + const body = [ + '@prefix solid: .', + '<>', + ' a solid:TypeIndex ;', + ' a solid:ListedDocument.' + ].join('\n') + + statustoBeReturned = 403 // Make PATCH fail + await expect( + utilityLogic.followOrCreateLinkWithContentOnCreate( + bob, + ns.space('preferencesFile'), + sym(suggestion), + bob.doc(), + body + ) + ).rejects.toThrow(WebOperationError) + + expect(web[suggestion]).toBeUndefined() + }) + }) + describe('setSinglePeerAccess', () => { beforeEach(() => { fetchMock.mockOnceIf(